fix(apple): recover dropped macOS client features lost in the W7/W8 refactor
apple / swift (push) Successful in 1m20s
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Successful in 24s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled

The W7/W8 client refactor (extracting FullscreenController/ApprovalRequest out of
ContentView, splitting Settings) landed the DefaultsKeys + scaffolding but dropped
the feature WIRING, which was uncommitted WIP shelved in a stash during the
16:28 clipboard/v4 reconcile. Recovered from that stash (+ its untracked-files
parent for the whole new ModifierLayout.swift) and re-ported onto the refactored
tree:

- invertScroll: applied in InputCapture.sendScroll (the one scroll sink) + Settings toggle
- modifierLayout (Cmd/Option switch): restored ModifierLayout.swift enum + KeyMaps
  .applyModifierLayout + InputCapture.emitKey wire-boundary + Settings picker
- fullscreen shortcut (Ctrl+Cmd+F): InputCapture ⌃⌘F interception + onToggleFullscreen
  + StreamView wiring + Stream-menu item (the .punktfunkToggleFullscreen sink +
  FullscreenController observer already survived on main)
- render scale: Settings picker + MatchWindowFollower renderScale/maxDimension
  application + StreamView/StreamViewIOS plumbing (RenderScale.swift already on main)

StreamCommands re-ported by hand (the stash hunk deleted the since-landed clipboard
item — kept it, added the fullscreen item alongside). Recovered ModifierLayout +
RenderScale tests. swift build green; 15 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:20:33 +02:00
parent 3584b47fb8
commit 205e5c5a59
11 changed files with 348 additions and 10 deletions
@@ -41,6 +41,7 @@ import UIKit
import Foundation
import GameController
import PunktfunkCore
import PunktfunkShared
import os
/// Diagnostic logging for the input path. Off by default (input is high-rate); set
@@ -125,6 +126,12 @@ public final class InputCapture {
public var onDisconnect: (() -> Void)?
public var onCycleStats: (() -> Void)?
/// Fired on F (macOS) toggle the streaming window in/out of fullscreen. Detected in the
/// monitor only WHILE FORWARDING, for the same reason as the combos: a captured stream view
/// swallows keys, so the Stream menu's identical F equivalent never reaches it; released, the
/// menu handles it. Main queue.
public var onToggleFullscreen: (() -> Void)?
#if os(iOS)
/// Windows VKs of the three modifier classes in the Q release chord, both L/R sides:
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
@@ -273,6 +280,14 @@ public final class InputCapture {
break
}
}
// F toggles the streaming window's fullscreen. Intercepted only while forwarding (the
// captured stream view swallows the menu's identical equivalent); the F is latched so its
// keyUp can't type into the host. keyCode 3 = kVK_ANSI_F (layout-independent).
if self.forwarding, flags == [.control, .command], event.keyCode == 3 /* F */ {
self.suppressedVK = 0x46 // VK_F the same physical F is en route via GC
self.onToggleFullscreen?()
return nil
}
return event
}
#endif
@@ -318,7 +333,7 @@ public final class InputCapture {
chordModifiersDown.removeAll()
suppressedVK = nil
for vk in pressedVKs {
connection.send(.key(vk, down: false))
emitKey(vk, down: false)
}
for button in pressedButtons {
connection.send(.mouseButton(button, down: false))
@@ -331,6 +346,15 @@ public final class InputCapture {
residualScrollY = 0
}
/// The single wire boundary for a key event. Every `.key` send funnels through here so the
/// active location-based modifier layout is applied in exactly one place while all internal
/// press/release bookkeeping (`pressedVKs`, `cmdKeysDown`, `resolveModifier`'s `isDown`) stays on
/// the physical VK. Read live from the setting so a mid-session change (rare) takes on the next
/// key without re-arming capture. Non-modifier VKs pass through untouched.
private func emitKey(_ vk: UInt32, down: Bool) {
connection.send(.key(Self.applyModifierLayout(vk, ModifierLayout.current), down: down))
}
/// Release any held MOUSE buttons host-side, leaving keyboard state untouched. Used when
/// the iPad pointer lock drops while a GCMouse button is held: by then the GCMouse release
/// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
@@ -399,7 +423,7 @@ public final class InputCapture {
inputLog.debug(
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent")
}
connection.send(.key(vk, down: down))
emitKey(vk, down: down)
}
/// NSEvent modifier path (macOS): modifier keys never fire keyDown/keyUp they arrive
@@ -566,8 +590,15 @@ public final class InputCapture {
/// Moonlight's convention). Fed by StreamLayerView.scrollWheel the only delivery
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away.
public func sendScroll(dx: Float, dy: Float) {
public func sendScroll(dx rawDx: Float, dy rawDy: Float) {
guard forwarding else { return }
// Optionally invert both axes (read live). This is the ONE scroll sink for every platform
// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel all land here so the toggle
// flips them consistently. Residuals are accumulated AFTER inversion so a direction change
// between events doesn't strand a fractional remainder of the old sign.
let invert = UserDefaults.standard.bool(forKey: DefaultsKey.invertScroll)
let dx = invert ? -rawDx : rawDx
let dy = invert ? -rawDy : rawDy
let fy = dy + residualScrollY
let fx = dx + residualScrollX
let iy = fy.rounded(.towardZero)
@@ -643,7 +674,7 @@ public final class InputCapture {
} else {
self.pressedVKs.remove(vk)
}
self.connection.send(.key(vk, down: pressed))
self.emitKey(vk, down: pressed)
}
#endif
}