This document records what the TetherKit graphical interface has implemented, why it is implemented that way, and which parts must not be touched.
Companion reading: GUI-SPIKE.md records the feasibility spike from before the work started (in particular the three routes that were ruled out); this document records the structure and the constraints after it landed. Read both of them before changing any GUI code -- it saves re-learning the pitfalls the hard way.
+---------------------------------------------------+ +-------------------------------------------+
| TetherKit.app (uid 501) | | tetherkit-helper (uid 0) |
| | XPC | |
| - SwiftUI interface | ------> | - holds the RNDIS session |
| - pops the system auth dialog for a credential | | - creates/destroys feth, opens BPF |
| - polls the state on a timer to refresh the UI | <------ | - configures the IP (ipconfig / route) |
| - never touches USB, never touches the interface | | - re-checks the credential on every call |
+---------------------------------------------------+ +-------------------------------------------+
^
| started on demand (MachServices)
+-----+------+
| launchd |
+------------+
The helper's root comes from launchd and has nothing to do with whether the
user touched their fingerprint. It is root from the moment it starts, and any
process that can reach the Mach service can send requests. So "who is calling"
has to be answered by the helper itself -- every privileged method requires the
external form of the AuthorizationRef the App just obtained, and the helper
re-checks it on the spot.
Security rests on credential re-checking, not on code-signature verification. The latter (the SMJobBless approach) depends on the certificate's designated requirement, and under source distribution the cdhash produced on each machine differs, so a hard-coded DR can never match. Credential re-checking is the better model for an open-source tool: whoever built it, it is equally safe.
One sentence to remember: authorization != root. A successful
AuthorizationCopyRightschanges nothing about the process; not one of uid / euid moves. It only produces a credential that says "the user confirmed at some moment". The order cannot be reversed: it is not "pop the dialog and get root", but "have a resident root helper first, pop the dialog, then hand the credential to it for re-checking".
include/tetherkit/capi/tetherkit_c.h the only extern "C" boundary in the project
src/capi/ C ABI implementation
|-- capi_support.{h,cc} fixed-size buffer copies, error translation, interface name checks
|-- core_foundation_support.{h,cc} minimal RAII wrappers for CF / SCDynamicStore
|-- environment.cc version, environment preflight, device enumeration (no root needed)
|-- log_ring.cc log ring buffer
|-- net_config.cc DHCP / static IP / status read-back
|-- orphan_cleanup.cc on-disk feth registry and orphan cleanup
|-- process_runner.{h,cc} runs external tools through posix_spawn (never through a shell)
`-- session.cc session lifecycle, status snapshots, event polling
gui/
|-- Package.swift SwiftPM project
|-- Sources/
| |-- CTetherKit/ module map for the C ABI (the headers are symlinks)
| |-- TetherKitIPC/ shared by the App and the helper: protocol, models, authorization, string table
| |-- TetherKitCore/ Swift wrapper of the C ABI
| |-- TetherKitHelper/ privileged helper
| |-- TetherKitUIModel/ main window page model (SidebarPage: fixed colors and
| | session state colors, persisted selection parsing) -- a
| | separate library so that it can be unit tested (an executable
| | target cannot be @testable imported)
| `-- TetherKitApp/ SwiftUI interface (main window management and the page views)
|-- Tests/TetherKitIPCTests/ authorization credential lifecycle + string table placeholder consistency
|-- Tests/TetherKitUIModelTests/ page tint semantics + persisted selection parsing
|-- Resources/ Info.plist, LaunchDaemon plist
`-- Scripts/ build / install / uninstall scripts
| Direction | Mechanism | Period |
|---|---|---|
| library -> helper: status | tk_session_status_get copies a snapshot |
on demand |
| library -> helper: events | tk_session_poll_events takes from the ring queue |
rides along with the status query |
| library -> helper: logs | tk_drain_logs takes from the ring queue |
every drainFeed |
| helper -> App | XPC, JSON-encoded Codable | 500 ms |
Not a single callback crosses the language boundary anywhere in the chain.
This is deliberate: the low-level callbacks arrive from the libusb event
thread and from the control thread, so crossing languages means marshalling;
worse is re-entrancy -- calling stop from inside a callback self-waits into a
deadlock (Stop() joins the control thread). Everything is changed to "queue
inside the library, the host polls".
- No ownership crosses the boundary. Output always goes into the
fixed-length struct the caller provides. The only exception is
tk_session_t*, whose create / destroy pairing is explicit. - Truncation of a fixed-length buffer must land on a UTF-8 character
boundary. The error messages can contain non-ASCII characters, and cutting
them hard at a byte boundary turns the whole
String(cString:)on the Swift side into replacement characters. Both directions need care: on the C side it isCopyTextincapi_support.h, on the Swift side it isCInterop.swift. - The C enums and the C++ enums are cast directly on the strength of a
matching order, and a set of
static_asserts insession.ccwelds that down. Inserting an enum value in the middle shifts them silently, with no error at all. - The member declaration order of
tk_sessionis constrained:eventsmust come beforeruntime, so that on destructionruntimejoins the control thread first. The other way round is a use-after-free.
Runtime owns its own control thread; the start sequence, the keep-alive
loop and the shutdown teardown all run on it. This is not just about making
Start() non-blocking; it turns the StateMachine's "every method is called from
the same thread" from a verbal convention into a structural guarantee -- a
graceful shutdown has to send a synchronous control message, and libusb's
synchronous API returns LIBUSB_ERROR_BUSY when it is called from the event
thread.
The host thread reads the state only through Snapshot() and never touches
those component pointers -- otherwise the reset() inside Stop() and the
host's reads form a data race, which shows up as random crashes.
RequestStop() stays a single atomic write (async-signal-safe), at the cost of
being seen up to one loop period late (<=250 ms); Stop() additionally wakes
the control thread with a condition variable, so pressing "Disconnect" in the
interface responds immediately.
- Always push the addresses through
ipconfig; do not go back to a bareSIOCAIFADDR. An address configured with the bare ioctl is accepted by the kernel but not by configd -- no service, no scoped DNS, no default route. This was measured and confirmed in sections 3.2 / 6.3 of GUI-SPIKE. - Run the external tools through
posix_spawnwith an argv array, never through a shell. The arguments are all validated, but as soon as they pass through/bin/shthe defense rests on a single question: "was the validation complete?" - Drain the pipe before
waitpid. The other way round is the classic pipe deadlock: the child fills the 64 KiB buffer and blocks inwritewhile we block inwaitpid.process_runner.cchas a matching test. - Interface names are only accepted in the form
feth<number>. This is not fastidiousness -- it blocks accidents of the "someone passes en0 and wipes out the user's Wi-Fi configuration" kind. - The
SCDynamicStorehandle must be process-lifetime. The values in the dynamic store are held by the session that set them; once the session is released the values are gone. A temporary store produces the extremely hard-to-diagnose phenomenon "the write returns success, the read comes back empty".
Three details that turn into a vulnerability the moment they are written wrong; all of them are documented in the comments of that file:
- On the helper side, the re-check must never carry
.interactionAllowed-- a daemon has no UI session, and letting it pop a dialog would let any process trigger the system authorization dialog at will to harass the user. AuthorizationFreemust not carry.destroyRights-- the credential belongs to the App, the helper only borrows it to check, and carrying it would void the App's authorization as well.- The probe methods do not require authorization -- otherwise the two failures "the helper is not installed" and "the authorization did not pass" get mixed together and the user cannot be given an accurate message.
Obtaining the credential (App side, with .interactionAllowed) and
re-checking it (helper side, without) are deliberately kept in the same
file: they differ by a single flag, so when one side is changed the other is
right in front of you.
A fourth one, actually hit in practice:
-
The 32 bytes produced by
AuthorizationMakeExternalFormare not the credential itself; they are only a key pointing at the authorization inside securityd. If the App side releases theAuthorizationRefbefore the XPC round trip completes (especially with.destroyRights), the helper fails to restore it and reportserrAuthorizationDenied (-60005).And from the code alone this error code gives no hint of "it was destroyed early by ourselves" -- it looks like the user denied the authorization. So the credential is held by
AuthorizationToken, andAuthorizationBroker.withAuthorizationuseswithExtendedLifetimeto guarantee that it lives until the call ends. Do not bare-catch the return value ofrequestAuthorization()and use it afterwards -- ARC is free to release the token right after the last read ofexternalForm.gui/Tests/TetherKitIPCTests/AuthorizationTests.swiftnails this behaviour down; neither of the two cases pops an authorization dialog, so it can go into CI.
The token must be cached and reused, otherwise every operation makes the user
authenticate again. The measured parameters of system.privilege.admin
(security authorizationdb read):
| Field | Value | Meaning |
|---|---|---|
shared |
false |
The credential is not shared across AuthorizationRefs. Every new ref created by AuthorizationCreate necessarily re-authenticates. |
timeout |
300 |
But a credential on the same ref stays valid for 5 minutes. |
So the App caches the AuthorizationToken and reuses it: the first operation
pops one dialog, and for the next 5 minutes neither "configure the network" nor
"disconnect" bothers the user any more. Once it expires, the helper's re-check
fails and sets the second parameter of the reply to true; the App then drops
the cache, pops the dialog once more and retries the operation.
Conclusion: the system authorization dialog popped by
AuthorizationCopyRights supports no fingerprint, only a password. This is a
limitation of the API, not an implementation problem. Do not spend more time
trying.
The verification process (this machine has Touch ID hardware, bioutil -r shows
it enrolled and enabled):
- Wrote a minimal probe app that requests
system.privilege.admin, read the text of the SecurityAgent window, and got "Enter password to allow this operation." -- there is no biometric entry point anywhere in the interface. - Compared it with the system's own
system.preferencesrule:class/group/authenticate-userare exactly the same, onlysharedandtimeoutdiffer -- and those two fields only affect the credential cache, not the authentication method. So a different right name does not help. - Looked in the SDK for a bridge between LocalAuthentication and the
Authorization Services: the environment items in
AuthorizationTags.hare only username / password / shared / prompt / icon, no LAContext. The only placeGetLAContextappears isAuthorizationPlugin.h-- that is a callback for writing a SecurityAgent plugin, and its comment says it returns a smart card PIN (LACredentialCTKPIN), not a Touch ID.
LAContext.evaluatePolicy really can pop a fingerprint prompt, but the result
it produces cannot be converted into a credential the helper can
re-check -- switching to it would mean giving up the entire trust model. If
you really want to do it, the only road left is "write your own SecurityAgent
plugin and install it into /Library/Security/SecurityAgentPlugins", and both
the cost and the risk are wildly out of proportion for this project.
The workable alternative is to reduce how often the dialog pops, which is the
token caching described above. If that still feels too frequent, you can use
security authorizationdb write in install-helper.sh to install a right of
your own, lengthen timeout and set shared to true -- but that is still a
password box, it just asks less often.
HelperConstants.protocolRevision must be incremented every time
TetherKitHelperProtocol changes. It is currently 3 (1: the first
version; 2: the reply of a privileged method carries "did the authorization
fail"; 3: adds setLanguage). The helper encodes it in the helperVersion
reply, and the App compares it as soon as it connects.
Why not add a dedicated method just to report the version: adding a method is itself a protocol change, and the old helper simply does not have it -- that puts us back to "mismatched and undetectable". Only a method that even an old helper is guaranteed to answer can reliably identify an old helper. The string returned by an old helper contains no separator, the parsed revision number comes out as 0, and the interface uses that to show "the privileged helper needs updating".
- The XPC error block and the reply block may both be invoked (the
connection dropped after the request went out), and fulfilling a
CheckedContinuationtwice is a straight crash. Every call inHelperClientis wrapped in aContinuationGuard. - The cached
NSXPCConnectionmust be discarded after the connection drops, otherwise every later call keeps hitting the dead connection, which shows up as "the helper is clearly installed but cannot be reached". - Every operation in the helper that might take time replies asynchronously (starting a session needs a USB handshake, DHCP waits for a lease for up to 10 seconds). Blocking on the XPC queue stalls the later status polls on the same connection, and the interface looks completely frozen.
- The denominator of the rate is the monotonic clock delta on the library side, not the interface timer period -- the real interval between two fetches is stretched by the scheduling, and using a fixed period as the denominator computes the rate too high.
- A fixed-length C char array is a tuple in Swift; it cannot be
subscripted and cannot be turned into a String directly, so everything goes
through
CInterop.swift. Read it by "scan to the NUL or to the end", not withString(cString:)-- the latter reads out of bounds when the buffer is full and has no NUL. - A non-negative C enum is imported as
UInt32while the struct field isint32_t, so an explicit conversion is required before comparing. - The main window is managed directly by AppKit (
MainWindowManager), no longer through a SwiftUI Window scene. Two reasons: first, SwiftUI will "recreate the Window scene on its own" in menu bar only mode and the main window mysteriously comes back (applicationShouldHandleReopenis not taken; instrumentation confirmed that it is never called, and there is no clean delegate hook to block it) -- managing the window yourself removes that road entirely; second, on macOS 26 the window chrome is decided by the SDK inLC_BUILD_VERSION: a SwiftPM-linkedsdk 14.0gives the whole machine the legacy traffic lights (14x16pt button frames, 20pt spacing, only a roughly 12pt circle drawn inside), and adding-platform_version macos 14.0.0 26.5.0while keeping minos 14.0 gets the stock traffic lights back (both aspects are in items 19-21 of section 7 of AGENTS.md). The main window is therefore an ordinary NSWindow withfullSizeContentViewplus a transparent, hidden title bar, its content hosted by an NSHostingView; both the App startup and the menu bar's "Open the main window" go throughMainWindowManager.shared.present(model), closing = orderOut, back to menu bar only mode (NSWindowDelegate.windowWillClosefalls back to the accessory policy). - A bare dev run now pops the window as usual. The window is created by
the delegate in
applicationDidFinishLaunchingandmakeKeyAndOrderFront-ed, no longer depending on the instantiation timing of SwiftUI and no longer needing a manual rescue from the menu bar. The.prohibited -> .regularself-check inTetherKitApplication.init()is kept: it governs the normal activation semantics of a process that is not active yet. - The polling is adaptive: a running session / starting or stopping / the window open -> 500 ms; purely idle in the background -> 2 seconds. When the window is reopened, a refresh is issued immediately so that the first glance is not stale data.
The string table is compiled into the binary, it does not go through .lproj.
The reason is written at the top of TetherKitIPC/Localization.swift, and its
core is that the helper is a bare executable installed in
/Library/PrivilegedHelperTools, with no resource bundle next to it, and
there should not be one, yet it still has to produce user-facing text of its
own.
The table currently holds a single column (English) and the GUI language menu
offers "Follow system" and "English"; the multi-language plumbing is
deliberately kept so that adding a language later stays a bounded five-touch
change (the checklist lives at the top of
include/tetherkit/common/messages.def).
When the language changes, all three places must be updated together, and missing one of them produces "the interface in one language, the log in another":
| Place | How to change it | Consequence of not changing it |
|---|---|---|
| Swift string table | L10n.apply(_:) |
the interface does not change |
| libtetherkit | TetherKitLibrary.setLanguage(_:) -> tk_set_language |
the library logs in the log card stay in the old language |
| helper | the XPC setLanguage(_:) |
the helper's messages and the library logs on its side stay in the old language |
All three are done together in AppModel.applyLanguage; do not call one of
them on its own somewhere else. The helper has to be told separately because it
runs as root under launchd and cannot see the user's language preference.
Two SwiftUI specific pitfalls (both hit in practice):
- Switching the language does not make any
@Observableproperty look changed, so the view bodies do not recompute. The countermeasure is to make the language change itself an observable value (AppModel.languageRevisionincrements every time), and then use it as an identity:.id(model.languageRevision), which forces the whole tree to be rebuilt. - Each
Buttoninside.commands { }is its own expression, so only the one that reads an observable value recomputes. In practice, after switching the language only the language menu itself changed, while "Check for Updates…" next to it was still in the old language. The fix is to wrap that group in a single View (AppMenuItems) and read the revision once in its body. The same goes for thestatic letmembers that hold text: they are evaluated only once, so turn them all into computed properties.
- Copying the dylib must use
cp -a, neverinstall.libtetherkit.dylibandlibtetherkit.0.dylibare symlinks;installcopies a separate real file for each, and afterwardsinstall_name_toolonly changes one of them -- while the one that is loaded via@rpathis exactly the one that was not changed. - After
install_name_toolhas changed the bytes, the signature must be redone. arm64 requires a valid signature; changing the bytes invalidates the original signature and the load is then rejected by the kernel outright (Killed: 9), with no reason visible in the log. - libusb is a dependency of
libtetherkit.dylib, not of the executable. Runningotool -Lagainst the executable yields an empty result, the whole step is silently skipped -- after the install everything looks normal, yet it still depends on Homebrew. - The rpath that points at the build directory must be removed from the
release artifacts. It is an absolute path, it certainly exists on the
development machine, dyld prefers it, and the embedded copy is therefore
never validated -- it only shows up on a different machine.
build-gui.shremoves it;Package.swiftalso puts it last as a second line of defence. - In bash, a variable immediately followed by Chinese punctuation must be
written as
${VAR}. Under a UTF-8 locale the lead byte of the full-width punctuation (0xEF) is judged true byisalnum()and absorbed into the variable name. It does not reproduce under the C locale.
When the helper is missing or its version does not match, the interface offers
an "Install the helper" or "Update the helper" button: click it, type the
administrator password once, and it is installed -- no need to open a terminal.
The mechanism (HelperInstaller.swift):
- The payload is embedded in the .app:
Contents/Library/HelperTools/holds the helper binary, the dylib, the plist and the install / uninstall scripts flat, assembled bybuild-gui.sh. At install time the whole directory is copied away, so the install source naturally travels with the .app. The olddist/helper/layout is abolished. - The privilege escalation goes through
AuthorizationExecuteWithPrivileges(AEWP): it takes exactly the AuthorizationRef, so the authorization token cached by the App (section 4.4) is reused directly -- within the 5-minute window not a single dialog pops; clicking "Connect" right after the install does not pop one either. The osascript route was ruled out precisely because it creates its own authorization session and cannot hook into this cache. SMJobBless / SMAppService were ruled out long ago (SPIKE 3.3). - AEWP is deprecated but usable (measured on macOS 26): the symbol still
resolves, and the setuid trampoline
/usr/libexec/security_authtrampolineis still there. The code resolves it with dlsym at runtime instead of linking it directly: if the system ever removes it, the app degrades into "please install from a terminal" instead of failing to bind at dyld time. - The execution target of AEWP must be a binary, not a script: the
credential AEWP gives the child process is euid=0 / ruid=the normal user,
and bash (zsh and dash likewise) drops the euid back to the ruid when
euid != ruid and
-pis not given -- the old rule against setuid scripts. AEWP-ing the install script directly yields, on a real machine, the script's own "root privileges required" error (hit in practice). So what the App launches is the--installmode of the helper binary in the payload (InstallerMode.swift): setuid(0) normalizes the credential into exactly the same real root as sudo, dup2 merges stderr into stdout (AEWP's pipe only picks up stdout), and then it execs theinstall-helper.shin the same directory. The script locates the payload by "the directory the script is in -> the repository artifacts", so the terminal and the App share one implementation. - Success is not judged by the exit code: AEWP gives neither a pid nor an
exit code, only a pipe connected to the child's stdout. The criterion is
"after reading EOF, can we really connect to a helper of the matching
version" --
AppModel.installHelper()confirms it with an XPC round trip as a backstop, and on failure it puts the tail of the script output (with the ANSI colour codes stripped) into the dialog. - Self-check before the dialog: if the payload is missing (the bare
executable of
swift run) or AEWP is gone, that has to be reported before the password is requested, and the user guided back to the terminal script. - The uninstall goes through the same chain: the "Uninstall the privileged
helper?" (a low-key caption line on the "Manage" page -- a very rare
action must not steal the attention, but it must exist in the main
interface) -> confirmation dialog ->
--uninstall->uninstall-helper.sh. The success criterion runs the other way: what is waited for is "cannot connect". After the uninstall the interface naturally returns to the install guide page, and it can be reinstalled at any time.
UpdateChecker.swift requests GitHub's releases/latest and does a semantic
comparison against the version number in Info.plist (CMakeLists is the single
source of truth). The automatic download and replace is deliberately not
done: under certificate-free distribution the download carries quarantine,
and the replaced .app gets blocked by Gatekeeper -- the same wall that rules
out a Cask. The real update channel is brew upgrade or rebuilding from the
source.
- Two entry points: the App menu's "Check for Updates…" (a result dialog with a copyable brew command and a link to the release page); the automatic check runs at most once a day, and when a new version is found the privileged helper row on the "Manage" page lights up "x.y.z is available" -- it never pops a dialog to interrupt. The hint is remembered across the launches (defaults) and goes out by itself after the upgrade because of the version comparison.
- Privacy: only the public GitHub API is accessed, and the failures are
silent;
defaults write com.tetherkit.app updateCheckDisabled -bool YESturns it off completely. - The bare executable of
swift runhas no Info.plist version number, so the check skips itself automatically. - Release discipline: the check reads the GitHub Releases, so every version
must be tagged
vX.Y.Zand published as a Release, otherwise it is not found. - When a new version is found, the existing mechanisms take over:
brew upgradereplaces the App, and after the restart theprotocolRevisionmismatch triggers the one-click "update the privileged helper" reinstall (sections 4.6 / 4.9).
Switching pages in the sidebar has to be pixel-exact. Previously, on macOS 26, clicking to switch pages briefly scaled the new page up by about 8-10% and then back down, and only a click triggered it (the same state driven by a timer was completely clean). Two layers of root cause (items 22 and 23 in section 7 of AGENTS.md) stack up; blocking either one alone only makes the jump faster and does not eliminate it -- three clamps, none of them optional:
SidebarRow:InstantButtonStyle(PrimitiveButtonStyle) -- the default system style creates a button activation context (a transaction with an animation) for the pressed / label state; it uses.transaction { animation = nil }+.contentShape(Rectangle)+.simultaneousGesture(TapGesture().onEnded { trigger() })to create none at all (it callstrigger()on the configuration, notaction()).SidebarShell.select: the state write jumps out of the activation beat --DispatchQueue.main.asyncwrites on the next beat, and the write site carries an instant transaction (animation = nil+disablesAnimations = true) as a backstop; content/pageBody also carry.transition(.identity), so the entry and the exit never interpolate.MainWindowManager: cut off the resize event source -- macOS 26 synchronizes the "content size change" with the "window frame animation" (WWDC25-256, release notes 61158194); the defaultsizingOptionsofNSHostingViewpushes each page's intrinsic size into the window (different page heights -> one resize per page switch).hosting.sizingOptions = []is set before the contentView is attached; min/max are pinned to a fixed size.
Why blocking one layer alone is not enough: a transition only interpolates
when the transaction carries an animation, and an activation context always
carries an animation; disablesAnimations only blocks new implicit .animation
writes (the two objc.io articles on Transaction / transitions) -- the only
environment that "does not interpolate at all" is the next beat transaction
with a nil animation, which is item 2 above.
The invariant: every new code path on the view side that changes the
sidebar selection must go through select() (keeping the jump out of the
activation beat); writing storedPage directly inside an activation
transaction is forbidden -- every additional page switch entry point has to
keep this. The only exception is the window claim path (the arrow keys and the
Return claim of SidebarMainWindow): the window layer has no access to the
shell's model, so it writes the storage key directly through
ButtonTurn.write, and deliberately does not re-anchor the AX focus (it does
not call syncAXFocus) -- a jump out write as well, semantically consistent
with the invariant. On the window side: the delegate's windowDidBecomeMain /
windowDidResize idempotently re-pins min/max -- measured, macOS 26 resets
min/max to "not set" shortly after the window is shown, accompanied by a +32pt
chrome settle (call stack _setFrameCommon:display:fromServer:); the
styleMask never contains .resizable (min/max only guard against
programmatic changes), and the idempotent form also prevents windowDidResize
from recursing into itself.
The accessibility press (VoiceOver's AXPress): within the press round
NSApp.currentEvent == nil, and macOS 26.5 discards everything that was
queued into DispatchQueue.main.async / Task { @MainActor } within that
round (the body never runs), while a synchronous write survives. So select()
splits on the event type of NSApp.currentEvent: inside a mouse/keyboard round
it keeps the iron rule of this section, "jump out of the beat and then write",
and in an accessibility press round it writes synchronously. A direct write
inside a keyboard round has a second pitfall: the re-render after the page
switch re-dispatches the same key event, so one keypress advances two pages.
The complete investigation record is in pitfall 24 in section 7 of AGENTS.md.
The keyboard (the activation pipeline, round 6): with a real FKA the Space
and the arrow keys are intercepted by universalaccessd at the HID layer (a
synthesized NSEvent never reaches that layer), and the Space becomes an
out-of-process AXPress on the focused row -- it lands on the row's
.accessibilityAction(.default) and is written directly by
ButtonTurn.writeDirect (the NSApp.currentEvent left over from an
out-of-process AX round fools the beat detection and is discarded by 26.5);
the Return passes through unchanged and is intercepted in the
performKeyEquivalent of SidebarMainWindow (condition: the accessibility
panel's FKA is on (SelfAccessibility.isFullKeyboardAccessOn, the ground
truth correction is in pitfall 24 round 7), the system-wide AX focus of the
own process resolves to the row title, and no field editor is active), and
when nothing matches it falls through to the focused row's own
.onKeyPress(.return) backstop. NSApp.isFullKeyboardAccessEnabled reads the
old preference AppleKeyboardUIMode (keyboard navigation), not the
accessibility panel's FKA switch; with the panel switch off, the AX focus left
on a row makes a bare Space/Return silently switch pages (a real machine
regression in round 7). The arrow keys: the sidebar claims the bare arrow keys
in performKeyEquivalent first and switches the page directly (conditions:
FKA off, the first responder is not a text input; the .function bit is not a
chord, so the ban has to be an explicit modifier key set), after the claim the
container does not step a second time; the container keeps the arrow key
.onKeyPress as a bubbling backstop; with FKA on the arrow keys belong to the
HID layer. For the rest of the details: ButtonTurn.write; the level filter
and the clear button of the LogCard go through the same path, and a
click-driven filter change / clear no longer carries the interpolation of an
activation transaction.
The focus response synchronization and the self-healing exploration (round 8,
the self-healing logic was superseded in round 11): the keyboard
participation of the rows is gated on the ground truth of the accessibility
panel (the shape settled in round 11: every row declares
.focusable(SelfAccessibility.isFullKeyboardAccessOn || isSelected), and the
container no longer carries focusable); the .focused / .accessibilityFocused
dual engines are bound row by row, and the landing point of the keyboard engine
is synchronized into the AX response via onChange(of: focusedRow) -- this is
the only supported channel for moving the AX focus inside the process, writing
kAXFocusedUIElement from the outside is a no-op (pitfall 24 round 8 (1)).
(Historical exploration / superseded): Round 8 once tried to self-heal the
system response after the polling re-render via
.onChange(of: model.status.monotonicNanos) and an involvement gate. That
mechanism was completely switched off in the Round 11 native architecture (the
related self-healing code was removed, the focus is now managed by the SwiftUI
native engine). The FKA-off contract was narrowed to the arrow keys only
(r8 used .focusable(false), later restructured in r9/r11). The level filter
changed from segmented to .menu: segmented does not respond to the keyboard
under keyboard navigation and under a real FKA, and the .menu popup is the
orthodox keyboard-operable alternative; the popup runs in
NSEventTrackingRunLoopMode, and a headless environment cannot synthesize
opening and committing it (a hardware checklist item), so the existence of the
menu is proven by a CGWindowList diff (pitfall 24 round 8 (4)); the filter
binding now goes through ButtonTurn.routed (ButtonTurn was moved into
TetherKitUIModel as well, its commit semantics have unit tests).
Tab re-entering the sidebar and the row level fallback for the Return (round
9, real machine re-check): the user's real machine re-check overturned the
r8 FKA-off "arrow keys only" contract -- under keyboard navigation Tab could
never reach the sidebar (the rows were .focusable(false), which excludes
them from the Tab ring), and as long as any control held the focus, the
window's arrow key claim preconditions failed and the arrow keys stalled. The
contract changed to: Tab reaches the sidebar in both modes; Space/Return
stay inert on the rows with FKA off; the arrow keys keep switching pages from
the row focus. Three landings: the rows' .focusable(true) became a
constant (one condition less, a steadier AX identity; the r8 probe matrix's
expectation "Tab never lands on a row" is void from now on, so change the
expectation before running the matrix again); the actions of the row Buttons
all go through rowSelect, and with FKA off they split on the type of
NSApp.currentEvent -- the keyboard driven native activation (the live
keyDown) is discarded, mouse events are let through (currentEvent is read in
two places -- here and in the beat detection of ButtonTurn.write -- and both
are used only as a negative / routing signal: an AXPress round leaves the old
event behind, see pitfall 24 round 6); the window's arrow key claim steps the
page when the first responder is not a text input. The Return gets the row
level fallback: under a real FKA the AX self-read that the window's claim
depends on cannot resolve the row (the synthesized probe passes everything --
a headless environment cannot get around the HID layer, and only a real machine
can verify that link), so the focused row itself carries
.onKeyPress(.return) (an FKA-on gate, calling rowSelect directly, with
zero AX-read dependency); the keypad Enter (keycode 76) is still only covered
by the window claim (SwiftUI's KeyPress.Key has no keypad variant). The real
machine re-check list is updated accordingly: FKA off -- Tab reaches the
sidebar rows, Space/Return inert, the arrow keys switch the pages from the row
focus; FKA on -- the Return after a Tab also switches the page.
The self-driven keyboard ring exploration (round 10, removed and superseded
in round 11): round 9's "Tab reachable in both modes" had been misjudged in
the early exploration as "the sidebar rows are unreachable with FKA off", and
round 10 tried a scheme in which AppKit intercepts the bare Tab and a
self-driven keyboard ring is maintained on AppModel.sidebarRingRow. But that
scheme had the problems that the AppKit interception easily encroaches on the
focus order of the content buttons and that it introduces a complex state
machine. That self-driven keyboard ring scheme was completely removed and
abandoned in round 11 (sidebarRingRow and SidebarFocusBridge are both
deleted), and the final shape is the purely native architecture of round 11.
The native focus ring and the arrow keys in every state (round 11, the final architecture):
- The Tab cycle is connected in both directions: the AppKit level
self-driven interception of Tab tried in r10 encroaches on the natural focus
order of the content buttons, while the root cause of the sidebar being
skipped before, under pure SwiftUI, was the
.focusable(SelfAccessibility.isFullKeyboardAccessOn)bound to therowscontainer, which blocks the whole sidebar group when FKA is off. Remove the focusable of the container, the selected row uses.focusable(isSelected)when FKA is off (with FKA on every row is focusable), Tab then forms a bidirectional reachable cycle of "the sidebar selection <-> the content buttons", and after Tab has walked through all the content buttons it returns smoothly to the sidebar. - The arrow keys switch the pages in every state: the old
handleArrowKeyguard demandedfirstResponder == nil || firstResponder === contentView, and as soon as the user pressed Tab and a content button (such asSwiftUIAppKitButton/KeyViewProxy) took the focus, the arrow keys were wrongly blocked. The pass condition is now!isFirstResponderEditableText: as long as the first responder is not a text input (where the caret has to keep moving), the up and down arrow keys step the selection and switch the pages at any time, whether the focus is in the sidebar or on a content button, and after the switch the focus is reset and self-heals, so the user is never stuck on the current page. The container keeps the up and down.onKeyPressas a bubbling backstop. - The Return claim and the row level fallback: with FKA on, when the
first responder is not a field editor, the window's
performKeyEquivalenttries to read the system-wide FKA focus track (SelfAccessibility.systemWideFocusedDescription()). Warning: the TCC permission note: inspecting a UIElement of the own process needs no permission, but reading the cross-process system-wide focus element throughAXUIElementCreateSystemWide()is gated by the macOS TCC (Accessibility); the app does not prompt for it by default, and when the Accessibility permission has not been grantedAXIsProcessTrusted()is false, the system-wide read silently returns nil, and the event naturally falls through to the focused row's own.onKeyPress(.return)backstop which switches the page (no system permission required). - The visual feedback and the window losing the focus: when a row has the
focus and the window is active (
controlActiveState == .key), a 1pt accent stroke and a light gray background are shown; when the window goes to the background the stroke hides itself; the selection moves along with the arrow keys.
The verification (headless, not by eye): a synthesized click of NSEvent
mouseDown/mouseUp + NSWindow.sendEvent goes through the real response chain
(no Accessibility / screen recording permission needed); AXUIElement can be
used against the own process to read the button geometry and self-check the
hit (no TCC prompt for one's own process), 12/12 hits; for each click, 20
frames of the upper content band are captured at 35ms intervals = 240 frames,
and a YAVG pairing analysis shows that every page switch is exactly a single
frame jump, with no decaying sequence (if a 0.2-0.3s settle were still
there, 5-8 consecutive medium amplitude pairs would appear), and the click
path has zero didResize. Warning: the CA animation key probe stays at
animLayers=0 throughout: the SwiftUI interpolation is invisible to
CALayer, only a bitmap capture can see it -- do not use "is there a CA
animation" as the criterion. AX notes: the constants are imported as String
(cast them to as CFString when passing them to the AX API); the CFArray
elements are taken with CFArrayGetValueAtIndex + unsafeBitCast, checked
against AXValueGetTypeID().
Not affected: the auto-scroll of the LogCard
(withAnimation(.easeOut 0.15)) and the repeatForever breathing animation of
the StatusHeroCard own their transactions, so the clamps of select() do not
affect them (verified in practice).
| Capability | Where |
|---|---|
| Environment preflight (root, feth sysctls, MTU ceiling) | tk_check_environment |
| Device enumeration + vendor name / product name / serial number | tk_list_devices |
| Session lifecycle (non-blocking start, status snapshot, events) | tk_session_* |
| Log capture and polling | tk_drain_logs |
| DHCP / static IP / clearing | tk_net_apply, tk_net_clear |
| Read-back of the really effective state (address, gateway, DNS, main default route) | tk_net_query |
| Orphan feth cleanup | tk_cleanup_orphan_interfaces |
| Privileged helper + credential re-check | gui/Sources/TetherKitHelper |
| One-click install / update / uninstall of the privileged helper inside the App | gui/Sources/TetherKitApp/HelperInstaller.swift |
| Update check (check only, never replace; daily automatic + manual menu) | gui/Sources/TetherKitApp/UpdateChecker.swift |
| Automatic Finder alias maintenance (created on the first launch, searchable and launchable in Spotlight; the brew postinstall cannot create it because of the sandbox) | gui/Sources/TetherKitApp/FinderAlias.swift |
SwiftUI interface (five sidebar pages: Connect / Network / Throughput / Log / Manage, paged by SidebarShell; the list of the log page fills the page height) |
gui/Sources/TetherKitApp/Views/SidebarShell.swift |
| Live rate in the menu bar + background running (menu bar only mode) | gui/Sources/TetherKitApp/Views/MenuBarPanel.swift |
| Dev self capture tool (the App captures its own window into a PNG, so the iteration loop needs no screen recording permission) | gui/Sources/TetherKitApp/Views/UiSelfCapture.swift |
| English user-facing strings (the runtime language switch is kept, and the interface / the library log / the helper messages are switched together) | gui/Sources/TetherKitIPC/Localization.swift, tk_set_language |
| Item | Explanation |
|---|---|
| The DNS of the static IP is not verified on a real device | IPConfiguration publishes DNS only in DHCP mode. In static mode we add a DNS key to the service it created, and whether IPMonitor adopts it has not been verified. Therefore tk_net_query always reads back the resolver that is really in effect, and the interface shows the read-back result -- if we guessed wrong, at worst the DNS does not take effect, nothing is broken. |
| End to end already works on a real device (the DHCP path) | Verified on 2026-07-28 with an Android phone: plug it in -> the App connects -> feth0 gets an address in the phone's subnet via DHCP -> the host and the phone can ping each other in both directions -> iperf3 measures RX 325 / TX 235 Mbps. The step "can it reach the public internet" could not be verified -- a VPN on this machine holds the default route and hijacks the tethered traffic, which is a problem of the test environment and not of the GUI. After the App quits the session is still held by the helper (background running mode, as designed). |
| The authorization can only take a password, no fingerprint | A limitation of the system API, not a defect. Verified and recorded in section 4.5. The mitigation is the token cache: it asks only once every 5 minutes. |
| Hot plug | Context::SupportsHotplug() exists but is not wired up; for now the interface re-enumerates every 2 seconds. |
| IPv6 | The interface configuration only covers IPv4. |
| Multiple sessions | The helper allows only one session at a time. |
| App icon | There is no .icns yet, the default system icon is used. |
| English only, for now | The string table is compiled into the binary as a single column (not .lproj, the reason is at the top of Localization.swift). The multi-language plumbing is deliberately kept, so adding another language means changing the Language enum and two switch statements, it is not just a matter of adding a directory. |
# 1. The C++ part (produces libtetherkit.dylib)
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build -j
# 2. The GUI (equivalent to running ./gui/Scripts/build-gui.sh directly)
cmake --build build --target gui
# 3. Run. On the first launch it walks you through installing the privileged
# helper: click the button, type the administrator password once (see
# section 4.9).
# Terminal alternative: sudo ./gui/Scripts/install-helper.sh
open dist/TetherKit.appDuring development you can also use SwiftPM directly, without going through the packaging script:
export TETHERKIT_LIB_DIR="$PWD/build/lib"
swift build --package-path gui
swift test --package-path gui # the unit tests of TetherKitIPC + TetherKitUIModelUninstall: the "Uninstall the privileged helper?" button on the "Manage" page (see section 4.9), or:
sudo ./gui/Scripts/uninstall-helper.shTroubleshooting entry points:
| Symptom | Where to look first |
|---|---|
| The interface keeps showing "The privileged helper must be installed" | launchctl print system/com.tetherkit.helper |
| The helper does not come up | /var/log/tetherkit-helper.log |
| The connection fails | the "Log" panel in the interface (one click to copy) |
| After the install it still depends on Homebrew | otool -L dist/TetherKit.app/Contents/Frameworks/libtetherkit.0.*.dylib |
| Clicking "Connect" reports "Cannot restore the authorization credential (-60005)" | The App side released the AuthorizationRef too early, see item 4 in section 4.4 |
| The interface shows "The privileged helper needs updating" | The XPC protocol was changed but the helper was not reinstalled; click "Update the helper" (or run the install script) |
| Clicking "Install the helper" reports "This copy of TetherKit carries no installer payload" | The bare executable of swift run is running; the payload exists only in the .app assembled by build-gui.sh; install it with the terminal script |
| The authorization box only takes a password, no fingerprint | This is a limitation of the system API, not a defect, see section 4.5 |
| After unplugging and replugging the device, clicking "Connect" reports "A session is already running" | The helper is an old version (a failed state old session was not cleaned up); reinstall the helper |
| Double-clicking the Finder icon does nothing | Already fixed: the main window is presented again in applicationShouldHandleReopen (this callback only became available once AppKit managed the window itself) |