Skip to content

Show published file-sharing protocols and disk health detail - #8

Open
JYPochez wants to merge 45 commits into
jackhumphries:mainfrom
JYPochez:feature/disk-health-and-protocols
Open

Show published file-sharing protocols and disk health detail#8
JYPochez wants to merge 45 commits into
jackhumphries:mainfrom
JYPochez:feature/disk-health-and-protocols

Conversation

@JYPochez

Copy link
Copy Markdown

Stacked on #5 and #6. The Bonjour service declaration lives in the app bundle's
Info.plist from #5, and the new strings go in the tables from #6, so this branch builds
on both. Until they merge, the diff against main shows their commits as well; the
changes specific to this PR are the last four commits.

Summary

Surfaces two things the base station already reports but the app never showed: which
file-sharing protocols a device publishes, and the health and capacity of its disks.

Published file-sharing protocols. The Bonjour browser now also watches
_afpovertcp._tcp and _smb._tcp and records, per device name, which of them it
advertises. The device popover shows the result ("AFP, SMB"), so you can tell at a
glance whether a Time Capsule is actually publishing its disk. NSBonjourServices in
the bundle's Info.plist declares the two extra service types — without that entry
macOS's Local Network privacy gate silently returns no results.

Disk detail. Each partition row now carries three lines: the volume name, the
physical disk's vendor and firmware revision, and "used / total" with the reported
SMART status:

Data2000
WDC WD20EARX-00PASB0 · 51.0AB51
1,34 To utilisés / 2 To · SMART : vérifié

The used figure is labelled and the total left implicit: "1.34 TB / 2 TB" alone never
says which half is which, and there is not room for both words.

Vendor, revision, SMART status and capacity are only reported on the physical disk, so
InheritedDiskValues threads them down to the partitions underneath it. Used size comes
from the partition when the device reports it and is otherwise derived from total minus
free. Note that maSt reports megabytes, and that its integers arrive wrapped as
{"decimal": "…", "type": "integer", "width": n} rather than as bare JSON numbers —
DiskInventoryParser handles both shapes, with a regression test built from a real
device payload.

There is no SMART test: the status shown is the one the device already keeps, so the
row needs no button and costs no extra round trip.

Notes

  • Both detail lines are 10pt and scale down to a floor of 8pt rather than truncating.
    The row has about 205pt of text width, which a full-precision capacity pair overruns
    even unlabelled ("502,15 GB / 931,32 GB · SMART: verificado" is 206pt), so shrinking is
    what keeps the SMART status on screen in the worst cases. Ordinary values render at the
    full 10pt.
  • The mock backend's disk inventory was a second, hand-written set of DiskRecord
    literals that had drifted from the mock MaSt payload; it now decodes that payload so
    the two cannot disagree.

Testing

  • swift test — 702 tests, 0 failures
  • Verified against a real Time Capsule (2 TB, WDC WD20EARX-00PASB0) and via
    AIRPORT_UTILITY_MOCK=1 snapshot renders in all five languages

JYPochez and others added 30 commits August 25, 2026 12:42
SwiftPM has no .app product type, so until now the app could only be launched
from a terminal via run.sh. Add two equivalent build paths that each produce a
real, self-contained "AirPort Utility.app":

  ./make-app.sh [--sign] [--notarize] [--zip]   shell script, no Xcode needed
  open AirPortUtility.xcodeproj                 scheme "AirPort Utility App"

Both build a universal binary (arm64 + x86_64) and embed the Python backend so
the bundle runs without a source checkout beside it.

Several bundling constraints drove the layout:

* The backend lives in Contents/Resources, not Contents/MacOS. codesign treats
  everything in Contents/MacOS as code and fails verification on non-Mach-O
  files there ("code object is not signed at all"). This is the only change
  that touches shared source: defaultRepoPath() now also resolves the backend
  through Bundle.main.resourceURL.

* Info.plist declares NSLocalNetworkUsageDescription and NSBonjourServices. On
  macOS 15+ a bundled app is its own TCC principal, and without these Bonjour
  discovery silently returns nothing and ACP connections to port 5009 fail.
  Running from a terminal masks this, because there the permission belongs to
  the terminal.

* LSEnvironment sets PATH, because a Finder-launched app inherits a minimal one
  and "#!/usr/bin/env python3" would resolve to /usr/bin/python3, the Command
  Line Tools stub (3.9.6). The backend's suite does pass there, but a real
  interpreter is preferred when installed.

* LSEnvironment also sets PYTHONDONTWRITEBYTECODE, since Python would otherwise
  write __pycache__ beside the bundled backend and invalidate the signature.

* The entitlements file is intentionally empty: the app is not sandboxed, and
  Hardened Runtime comes from `codesign --options runtime`. Note that AMFI
  rejects XML comments inside an entitlements plist, so the rationale lives in
  Packaging/README.md.

Package.swift gains a library product for AirPortUtilityCore so the Xcode
target can link it as a local package product instead of duplicating the
source list.

Tagging v* runs .github/workflows/release.yml, which builds, signs, notarizes,
staples, verifies and attaches the zip to a GitHub release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Adds localization infrastructure plus a first vertical slice: the menu bar,
the pane tabs, the Base Station pane and the initial status string. Remaining
panes keep working in English and can be migrated incrementally.

Keys are the English source strings, so untranslated text falls back to
readable English instead of a symbolic key, and the 680 existing tests -- many
of which assert on English UI strings -- pass unchanged.

Notes on the design:

* Language resolution uses Locale.preferredLanguages rather than the
  one-argument Bundle.preferredLocalizations(from:). That variant matches
  against the *main* bundle's localizations, which are empty for command-line
  and test builds, so it silently pins every lookup to English. Verified across
  en/fr/de, including fr-CA correctly falling back to fr.

* Classic .lproj/Localizable.strings rather than a String Catalog: SwiftPM
  copies .xcstrings into the resource bundle without compiling it, so a catalog
  would work under Xcode but silently fall back to English via make-app.sh,
  run.sh and swift test.

* Protocol text is deliberately excluded. ACP keys, backend flags and persisted
  raw values are wire format, not display strings. Pane.rawValue stays English
  because it is Codable-persisted, used for snapshot file names and used to
  build accessibility identifiers; Pane.displayName carries the translation.
  LocalizationTests asserts no table contains an ACP-shaped key or a flag.

* Two German strings are shortened from their literal translations. The window
  is a fixed 800x504 and the literal forms clipped: "Konfiguration ueber
  Ethernet-WAN-Anschluss erlauben" lost its final character. Rendering each
  language is the only reliable check for this; the tests cannot catch it.

AirPortLocalization.text(_:) is public so the menu bar in the app target can
reach the table; PublicAPISurfaceTests is updated for the two new symbols.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Extends the localization slice to the remaining configuration panes and
sheets, taking the translations from Apple's own AirPort Utility where it
covers the same UI (/System/Applications/Utilities/AirPort Utility.app).
Its tables live in compiled NIBArchive files rather than .strings, so the
wording was extracted from the nibs and cross-checked term by term.

That correction mattered:

* German used "Kennwort" throughout; Apple uses "Passwort" (20 occurrences
  changed). Apple's wording is also consistently shorter, which resolves the
  fixed-width overflow rather than papering over it -- "Konfiguration ueber
  WAN zulassen" replaces the clipped "Konfiguration ueber Ethernet-WAN-
  Anschluss erlauben".
* French adopts "Confirmer le mot de passe" and "Conserver dans mon
  trousseau"; Spanish and Italian likewise follow Apple's own forms.

Two protocol hazards were caught by inspection before wrapping:

* AdvancedPaneSection had UI labels as enum raw values, which is both a
  compile error when wrapped and the same persistence hazard as Pane. It now
  keeps English raw values and carries a displayName.
* "Off" and "Disk" appear both as display text and as data -- a networkName
  sentinel and an accessibility-identifier comparison respectively. They are
  translated but wired by hand, never swept.

Language resolution is pinned to the development language under XCTest. Many
tests assert on English UI strings, and this machine runs in French, so the
same suite would otherwise pass in CI and fail locally.

The identical-to-English check is now per language rather than global: a term
that is genuinely identical in German may still be a real word in French, and
a global allowlist would stop catching a lazy French entry. A companion test
flags allowlist entries that have since been translated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Region names now resolve via Locale rather than the .strings tables. Hand
translating 172 country names into four languages would be redundant and
error-prone when Foundation already carries authoritative names for every one
of them.

WirelessRegionOption.code stays the ACP wire index and .name stays English: the
English name is the lookup key and the fallback. 154 of the 172 names match
Foundation's current English directly; the remaining 18 are spelled differently
here (Czech Republic, Ivory Coast, Turkey, ...) and carry an explicit alias. A
test asserts the whole list maps, since an unmapped entry would silently fall
back to English in every language.

Foundation-provided text now follows AirPortLocalization.locale rather than
Locale.current, so it obeys the same XCTest pinning as the tables -- otherwise
a test would see English labels beside French country names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
The pane widths were measured against English labels and pinned exactly, so
longer translations were cut off: the Internet pane's DHCP button rendered
"Rinnova DHCP asseg..." in Italian and "Renovar la asignació..." in Spanish.

InternetPaneButton pinned its width with an Auto Layout
widthAnchor.constraint(equalToConstant:), and AirPortButtonStyle with an exact
.frame(width:). Both now express a minimum instead. English is unaffected --
its intrinsic width is smaller than the minimum, so the measured width still
wins, verified pixel-identical against a render taken before the change.

Spanish and Italian also adopt Apple's own wording for that button ("Nueva
concesión DHCP", "Rinnova DHCP assegnato"); French and German already matched
Apple exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Adds 103 keys covering DeviceStatusMessage, the topology view and popovers,
AirPortServices operation titles and validation messages, and the setup
assistant.

Excluded by inspection rather than by pattern: ACP problem codes (ArcI, DubN,
nDNS, ...), profile paths (settings.sySt.problems), device artwork file names,
mock fixtures (Jack's Network, time-capsule.local), the keychain account
prefix, and 'time capsule', which is matched with .contains() rather than
displayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Adds 127 keys covering the validation and status text in AirportAppModel*,
which is the bulk of what a user sees while actually operating a device.

Most are formulaic ("X cannot be empty.", "X must be an IPv4 address.",
"X passwords do not match."), so they are generated from templates over a
translated field-name map rather than written out one by one, which keeps the
phrasing consistent across 4 languages.

Two bugs in that generation, both caught before commit:

* Spanish and Italian produced a doubled article -- "El campo el nombre del
  altavoz AirPlay no puede estar vacio" -- because the field phrases already
  carry one. They now agree in gender instead ("El nombre del altavoz AirPlay
  no puede estar vacio", "La contrasena del disco no puede estar vacia").
  French and German need no such handling: "vide" and "leer" are invariable.

* Two keys appeared in both batches and were wrapped twice. Collapsing the
  doubled localized( prefix left the extra closing paren behind and broke the
  build. The helper now refuses to wrap a literal that is already wrapped.

Only the 262 non-UI strings in these files remain untouched: ACP keys,
dictionary keys, identifiers and test-environment probes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
The Cancel and Update buttons in the configuration sheet footer stayed English
because PaneChrome was never swept -- only its pane titles had been touched.
A completeness pass over every source file found the rest: the command-log
placeholder, the dry-run message, disk-inventory and firmware error text, the
router-mode and modem idle option labels, and the window title.

Also fixes a bug introduced when the default status was localized. The sheet
footer decided visibility with

    status != "Not connected"

against the English literal, so in French the comparison was always true and
the footer showed a status it was meant to suppress. It now compares against
the same localized value. The neighbouring "Connected to ..." and "Ready to
connect to ..." prefixes are deliberately left English: they interpolate the
host, so they were never localized, and changing only the comparison would
break them. A comment records that the two must move together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Replaces the placeholder io.github.jackhumphries.airport-utility in the three
places it was defined: make-app.sh, the Xcode target, and the release workflow
fallback. BUNDLE_ID still overrides it for one-off builds.

Note that the identifier is what macOS ties Local Network (TCC) approval and
keychain access to, so the first launch after this change prompts again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Every device rendered amber in a non-English UI. The topology decides a
device's status colour by comparing display text:

    status == localized("Working normally") ? green : amber

but several producers of that text still returned the English literal, so the
comparison never matched once the UI was localized. Fixed at every producer:
the BaseStationState default and its Codable decode fallback, the "Restoring"
and "Restarting" states in deviceStatusText, and the mock backend's status
values including its default branch.

Reverted one comparison instead: TopologyPopovers matched model.status against
localized("Connected"), but that status is "Connected to <host>", which
interpolates the host and is therefore still English. Localizing only the
comparison broke it. It now matches English again, with a comment saying the
two must move together.

Adds a regression test asserting BaseStationState().statusText equals
DeviceStatusMessage.text(problemCodes: []). Both mean "no problems" and the
topology compares them, so they must be the same string in every language.

The underlying hazard is that status is display text used as a state token.
The comparisons are now consistent, but a status enum would remove the class
of bug entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
…room

"Mettre à jour" rendered as "Mettre à..." in the sheet footer. Seven controls
across six files pinned their width with an Auto Layout constraint measured
against the English label, so any longer translation was truncated.

Each constraint is now equalToConstant: max(designWidth, intrinsicContentSize)
-- still exact, so the control never expands to fill, but never narrower than
its label needs.

A plain greaterThanOrEqualToConstant was tried first and rejected: removing the
upper bound let SwiftUI's proposed width win, so the buttons expanded to fill
their row and the English Disks pane visibly changed, with Erase Disk and
Archive Disk growing until they nearly touched.

Verified by pixel-diffing all 17 rendered panes against a baseline taken before
the change: 16 are identical, and firmware.png shifts about two points because
one English label was fractionally wider than its constant, which is the bug
being fixed rather than a regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Three problems, all visible in the Wireless Options sheet.

Cancel and Save were still English there. A sweep for literals that already
have a translation but were never wrapped found 28 across seven files: the
sheet buttons, the erase-method and modem-idle option labels, several topology
popover fields, and a few stragglers. Enum raw values, the "Off" sentinel,
Bonjour model names and the firmware download directory are excluded, since
those are data rather than display text.

The option labels truncated ("Débit multica...", "Puissance d'é..."). They are
right-aligned and end at the control column, so the label frames now extend
leftward to the sheet margin -- English is unaffected because the text is
right-aligned and its trailing edge does not move. "WPA Group Key Timeout" is
long enough that it also needed shorter wording in all four languages.

Widening the buttons then made "Abbrechen" overlap "Sichern": the pair was
placed at fixed x offsets, so a wider Cancel grew rightward into Save. All
three options sheets now right-anchor the pair in an HStack, so longer labels
grow leftward. The trailing edge and 12pt gap reproduce the English layout
exactly.

Verified by pixel-diffing all 17 panes against a baseline: only firmware.png
differs, by the ~2pt already accounted for in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
VNS.airport.utility was hardcoded in make-app.sh, the Xcode target and the
release workflow. That is fork branding and does not belong upstream, but it
still has to reach the compiled app.

The identifier now lives in Packaging/Base.xcconfig with a neutral default,
ending in an optional `#include? "Local.xcconfig"`. Packaging/Local.xcconfig is
gitignored, so a fork sets its own identifier without modifying a tracked file,
and a clone without that file builds with the neutral default rather than
failing.

The target's buildSettings no longer define PRODUCT_BUNDLE_IDENTIFIER, since a
target-level setting would override the xcconfig. make-app.sh reads the same
Local.xcconfig, so the script and Xcode agree; BUNDLE_ID in the environment
still overrides both.

Verified: with Local.xcconfig present both configurations resolve to
VNS.airport.utility and the signed bundle carries it; with the file removed,
Xcode resolves the neutral default and the build still succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Four things were still English or truncated in a French UI.

The DHCP lease unit menu (second/minute/hour/day/week) and the syslog level
menu (0 - Emergency ... 7 - Debug) showed English. Only the label: argument is
wrapped; value: and level: are wire values and stay as they are.

The erase and archive confirmation titles were never localized because each
embedded a literal \n, which kept them out of the extraction sweep. They are
localized now without the line break -- where English happens to wrap is not
where a translation should.

Body text in both disk sheets truncated rather than wrapping: the frames were
sized to the height of one or two English lines, so a longer translation was
cut instead of flowing. Each frame now grows into the space before the next
control.

The Advanced pane's segmented tabs overflowed a fixed 330pt control shared by
three segments. Widening it would change the English layout, so the two long
tab labels take shorter forms per language instead.

Both disk sheets also right-anchor their button pairs, as the options sheets
already do.

Note: the snapshot renderer does not cover the erase and archive sheets, so
those two were verified by reading the layout rather than by rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
…ncation

The popover's Edit button read "Édition" in French. That is the Edit *menu*,
not the verb. With the English source as the lookup key there was no way to
tell the two apart, so AirPortLocalization.text now takes an optional context
that is tried as "context.key" before the plain key. The menu bar asks for
"menu.Edit" and keeps "Édition"; the bare key is the verb used on buttons and
becomes "Modifier". Two other Edit buttons, in the network port-mapping table,
were reading the menu translation for the same reason and are fixed by it.

The "status" and "network" row labels were never wrapped, so they stayed
English beside translated siblings.

Three truncations: the Edit button was pinned to the 47pt English width and now
sizes to its label; "LAN IP address" overflowed the 108pt label column and takes
a shorter form per language; and the value column was boxed at 152pt, which cut
"Fonctionne normalement". The popover is 20pt wider so the value column gets
172pt -- the label column origin and the value origin are unchanged, so no
English text moves, only the trailing edge.

Removing the button's fixed width alone made it expand to fill the row;
fixedSize() pins it to its intrinsic width instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Package.swift declares swift-tools-version 6.0, but the project did not build
on Swift 6.0 -- only on the newer toolchain most contributors happen to have.
CI surfaced it on an Xcode 16 runner:

    error: call can throw, but it is not marked with 'try'
      accounts += selectedTopologyDeviceIdentifiers.compactMap(Self.passwordStoreAccount)

passwordStoreAccount is a plain non-throwing "-> String?". The problem is that
passing it as a *function value* to the nonisolated rethrows compactMap crosses
an actor boundary, since it inherits @mainactor from AirportAppModel. Swift 6.0
reports that as the argument being able to throw; 6.2 accepts it.

Calling it from inside a closure keeps the call in the isolated context, so no
function value crosses the boundary. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
The compiled app is a release asset, not a file in the tree, so nothing on the
repository front page led to it. GitHub's sidebar callout and its
/releases/latest endpoint both ignore pre-releases, and v0.1.0 is flagged as
one, so neither surfaced the download either.

Adds a Download section near the top of the README linking to the v0.1.0 tag
directly, with the two things that otherwise look like bugs on first launch:
the Local Network prompt and the Python requirement.

Fork-specific, so it lives on main only and is deliberately not part of either
upstream pull request.
The release workflow pinned Xcode 16, whose Swift 6.0 rejects concurrency
patterns this project uses -- passing actor-isolated methods as function values
to nonisolated generics, in AirPortCommandRunner, AirPortServices, TopologyView
and AirportAppModelPasswords. None of that is specific to this branch; the
project simply needs a newer toolchain than the pin allowed.

Selects the highest-numbered Xcode present instead, and logs what was available
so a future mismatch is diagnosable from the run alone.

This supersedes the previous commit, which patched one of those call sites. That
was treating a symptom: the other three remained, so the project still did not
build on Swift 6.0, and the change was unrelated churn in shared code. Reverted.
The file holds a fork's own bundle identifier and signing team and is meant to
stay untracked. The ignore rule lived only on a later branch, so on this one it
was not ignored at all and got committed.

Adds the rule here, where Packaging/ is introduced, so it applies to every
branch built on this one.
The setup assistant still showed English headings ("What do you want to do with
this AirPort Time Capsule?") because those strings interpolate the device model
name, so the English source could not be the lookup key -- the key would change
with the value. Adds localizedFormat, which looks up a format ("What do you want
to do with this %@?") and fills it in, letting a translation move the
placeholder where its grammar needs it. Applied to the setup assistant and to
the connection status messages.

The sheet footer hid connection messages by matching the English prefix
"Connected". Localizing those messages would have silently broken that, so it
now derives the prefix from the same format string it was built from, which
holds in any language. This replaces the note left earlier warning that the two
had to move together.

passwordRow derived its accessibility identifier from the label with
localizedCaseInsensitiveContains("Disk"), which stops matching once the label is
translated. The identifier is now passed in by the caller.

Tab widths were constants measured against the English labels, so translated
labels were cramped. Each tab is now sized to its own label, with the English
constants kept as a floor: 19pt is the tightest padding those constants imply,
so no English tab can grow and that layout is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
It named v0.1.0 explicitly and went stale as soon as v0.1.1 shipped. The list
page always shows the newest build; /releases/latest is not usable here because
it ignores pre-releases, which both of these are.
Tagging a release in a repository without the signing secrets failed at the
certificate import, leaving a red run on every tag. That is the state any fork
starts in, including this one, so the workflow reported a problem where there
was none.

The credential-dependent steps are now skipped when MACOS_CERTIFICATE is unset:
the run builds, tests and stops, and emits a notice saying signing and
publishing were skipped and why. With the secrets configured it behaves exactly
as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
JYPochez and others added 15 commits August 27, 2026 19:25
Two additions to the disk and topology surfaces, both reading data the device
already publishes rather than introducing new commands.

The device popover gains a "file sharing" row listing the protocols the base
station advertises over Bonjour. The browser now watches _afpovertcp._tcp and
_smb._tcp alongside _airport._tcp, matching those services to a device by name,
which is how a base station publishes its shares. Those services are recorded
as evidence about a device rather than treated as devices themselves, or every
share would appear as its own base station. The row is omitted when nothing is
advertised.

Note what this cannot say: Bonjour advertises that SMB is present, never which
dialect. SMB1 vs SMB2 vs SMB3 is negotiated per connection, so reporting a
version would mean either implementing an SMB client or inferring it from the
firmware version and presenting a guess as fact.

The Disks pane gains a S.M.A.R.T. check. The status is already carried in the
disk inventory the pane reads, so this surfaces it rather than starting
anything: there is no ACP command to initiate a self-test, and the button says
"check" only in the sense of reading what the device reports.

The inventory reports SMART once per physical disk, so the parser carries it
down to each partition, with a partition's own value taking precedence. Two
tests cover both directions.

The smartStatus field name was identified from the pepperstm fork, which
surfaces the same value; the implementation here is separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Two additions to the disk and topology surfaces, both reading data the device
already publishes rather than introducing new commands.

The device popover gains a "file sharing" row listing the protocols the base
station advertises over Bonjour. The browser now watches _afpovertcp._tcp and
_smb._tcp alongside _airport._tcp, matching those services to a device by name,
which is how a base station publishes its shares. Those services are recorded
as evidence about a device rather than treated as devices themselves, or every
share would appear as its own base station. The row is omitted when nothing is
advertised.

Note what this cannot say: Bonjour advertises that SMB is present, never which
dialect. SMB1 vs SMB2 vs SMB3 is negotiated per connection, so reporting a
version would mean either implementing an SMB client or inferring it from the
firmware version and presenting a guess as fact.

The Disks pane gains a S.M.A.R.T. check. The status is already carried in the
disk inventory the pane reads, so this surfaces it rather than starting
anything: there is no ACP command to initiate a self-test, and the button says
"check" only in the sense of reading what the device reports.

The inventory reports SMART once per physical disk, so the parser carries it
down to each partition, with a partition's own value taking precedence. Two
tests cover both directions.

The smartStatus field name was identified from the pepperstm fork, which
surfaces the same value; the implementation here is separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
…eneration

Three changes, one of them a fix to the previous commit.

**The file-sharing row never appeared.** Browsing _afpovertcp._tcp and _smb._tcp
was not enough: macOS only permits browsing the Bonjour service types an app
declares in NSBonjourServices, and Info.plist listed only _airport._tcp, so the
browse returned nothing and the row was always omitted. The two types are now
declared. Confirmed against real hardware with dns-sd, which shows six Time
Capsules advertising _smb._tcp and five of them _afpovertcp._tcp.

**SMART moved from a button to the volume it describes.** A button implied an
action; the status simply arrives with the disk inventory. It now sits on the
row's second line beside free space, which also localizes that line -- "497.85
GB Free" was still English, having been skipped as interpolated text. The
reported value passes through the localization table, so a known term is
translated and anything unfamiliar shows verbatim. The disk name was not a
candidate for this: it is an editable field.

**The Base Station pane names the hardware generation.** The device reports a
product ID and no generation, so the mapping comes from jamesyc/TimeCapsuleSMB,
which reads the same syAP field. Generations are numbered per product line, so a
Time Capsule and an Extreme can share a number without being the same hardware.
An unknown product ID shows no row rather than a guess. The labels are written
out per language because ordinals carry gender and abbreviation rules a
formatter gets wrong -- "1re génération", not "1er génération".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
# Conflicts:
#	Sources/AirPortUtilityCore/Resources/de.lproj/Localizable.strings
#	Sources/AirPortUtilityCore/Resources/en.lproj/Localizable.strings
#	Sources/AirPortUtilityCore/Resources/es.lproj/Localizable.strings
#	Sources/AirPortUtilityCore/Resources/fr.lproj/Localizable.strings
#	Sources/AirPortUtilityCore/Resources/it.lproj/Localizable.strings
A conflicted merge in a .strings file does not break the build: Swift never
parses these, so the markers are copied into the app bundle verbatim and
everything appears to succeed. That happened during this branch's merge and
produced a signed app carrying conflict markers in all five tables.
The merge kept both the button from the earlier commit and the inline status
that replaced it, since they sit in different hunks and git combined them
additively. The button, its model action and its now-unused strings are gone;
the status stays on the volume row.

Also shortens the file-sharing label per language. The popover label column is a
fixed 108pt, sized for "serial number", and "partage de fichiers" overflowed
it.
The window widens as base stations are discovered. Centred, it expands in both
directions, so on a network with several devices it ends up partly off-screen.
Anchored near the top-left it grows right and down into free space.

Positioned against the screen's visibleFrame, so it clears the menu bar and the
Dock, and falls back to centring when no screen is available.
The row label was a bare literal, invisible in French and German where the word
is identical but wrong in Spanish and Italian.
The button survived an earlier merge, which combined it additively with the
inline status meant to replace it. Removed along with its model action.

Free space is missing for real hardware while Apple's own utility shows it, so
the value is reported somewhere this parser does not look. Capacity now falls
back to the physical disk the same way SMART already does: some devices report
size and sizeFree once per disk rather than per partition. A partition's own
values still win.

This is a plausible cause, not a confirmed one -- it needs checking against a
real inventory. Tests cover both directions of the fallback, and caught that
maSt reports megabytes rather than bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
The Disks pane showed no free space against real hardware while Apple's own
utility showed it. A MaSt read explains why: the device never sends bare
numbers. Every integer arrives wrapped,

    "sizeFree": {"decimal": "623863", "type": "integer", "width": 4}

and the decoder handled only JSON numbers and strings, so every size decoded to
nil and the subtitle was omitted. It now reads the decimal field.

The earlier guess -- that capacity was reported per physical disk and needed
inheriting -- was wrong: sizeFree sits on the partition exactly where the parser
looked. The inheritance is kept because SMART genuinely is reported per disk,
and the fallback is harmless where a value is already present.

Adds a test built from a real device's response, with identifiers anonymised and
the structure verbatim, which also documents that MaSt reports megabytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
The row now carries three lines: the volume name, then the physical
disk's vendor and firmware revision, then "used / total" with the
SMART status. Both detail lines are 10pt — at 11pt the German and
French capacity lines clipped the SMART status off the end, and the
longer "502,15 GB used" phrasing clipped it even at 10pt, hence the
compact "used / total" form.

Used size comes from the partition when the device reports it and is
otherwise derived from total minus free. Vendor and revision are only
reported on the physical disk, so InheritedDiskValues threads them down
to the partitions alongside the SMART status and capacity.

The mock's disk inventory was a second, hand-written set of DiskRecord
literals that had drifted from the mock MaSt payload; it now decodes
that payload so the two cannot disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
"1.34 TB / 2 TB" leaves the reader to guess which half is which. The
first number is now labelled — "1,34 To utilisés / 2 To" — and the
total is left implicit, since it is the only thing the second number
can be.

The label does not always fit: a full-precision pair with the Spanish
wording is 244pt against 205pt of row, and even the unlabelled form
overran it at 206pt, so the fit was luck of the numbers rather than
anything the layout guaranteed. Both detail lines now scale down to 8pt
instead of truncating, which keeps the SMART status — the part worth
reading — on screen.

DiskInventoryRow becomes internal so its two line builders can be
tested; the new cases cover the labelling, deriving used from free, and
dropping a line entirely when the device reports nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146wivD92uwBMRnBFjF6cDh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant