refactor(apple): code-quality pass — audit fixes + centralized defaults keys

A 6-agent adversarial audit of the client (11 confirmed of 39 findings, the rest
filtered) drove these:

- fix: SessionAudio ring buffer — guard a write larger than the ring (would push
  readIdx past writeIdx and corrupt the buffer; never happens, but guard not corrupt).
- fix: CADisplayLink retain cycle (stage-2 presenter) — a weak-target DisplayLinkProxy
  so the view can deallocate (the link retains its target); stage-2 teardown added to
  both StreamView/StreamViewController deinits as a safety net.
- fix: GamepadFeedback deinit { flag.stop() } — the drain thread holds the connection
  strongly and self weakly, so an abrupt teardown without stop() would leak it.
- refactor: centralize the 12 UserDefaults/@AppStorage key literals (scattered across
  8 files) into one DefaultsKey enum — a typo silently splits a setting's reader from
  its writer.
- docs: RumbleRenderer @unchecked Sendable invariant; the HID digit-row table; the
  stage-2 layer compositing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 16:30:08 +02:00
parent c8099c0125
commit 9e8135ccec
12 changed files with 86 additions and 28 deletions
@@ -218,7 +218,7 @@ public final class StreamViewController: UIViewController {
// Presenter choice default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
if UserDefaults.standard.string(forKey: "punktfunk.presenter") == "stage2",
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
let meter = presentMeter,
let pipeline = Stage2Pipeline(presentMeter: meter) {
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
@@ -294,17 +294,21 @@ public final class StreamViewController: UIViewController {
) {
let metal = pipeline.layer
metal.contentsScale = streamView.contentScaleFactor
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
streamView.layer.addSublayer(metal)
metalLayer = metal
stage2 = pipeline
layoutMetalLayer()
let link = CADisplayLink(target: self, selector: #selector(stage2Tick(_:)))
// Weak-proxy target so the link doesn't retain-cycle with the controller (see
// DisplayLinkProxy) the link retains the proxy; the proxy holds self weakly.
let proxy = DisplayLinkProxy { [weak self] link in self?.stage2Tick(link) }
let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common)
stage2Link = link
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
}
@objc private func stage2Tick(_ link: CADisplayLink) {
private func stage2Tick(_ link: CADisplayLink) {
stage2?.renderTick(
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
}
@@ -394,6 +398,7 @@ public final class StreamViewController: UIViewController {
deinit {
observers.forEach(NotificationCenter.default.removeObserver(_:))
pump?.stop()
teardownStage2() // invalidate the display link + stop the pipeline if stop() was missed
}
}