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
@@ -72,6 +72,14 @@ struct StreamCommands: Commands {
} }
.keyboardShortcut("c", modifiers: [.control, .option, .shift]) .keyboardShortcut("c", modifiers: [.control, .option, .shift])
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true) .disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
// Toggle the window's fullscreen. F is the macOS-standard fullscreen combo; here it's
// explicit so it's discoverable AND survives capture while streaming the stream view
// swallows keys, so InputCapture's monitor detects the same combo and posts the same
// notification the key window's FullscreenController observes.
Button("Toggle Fullscreen") {
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
}
.keyboardShortcut("f", modifiers: [.control, .command])
#endif #endif
Divider() Divider()
Button("Disconnect") { session?.disconnect() } Button("Disconnect") { session?.disconnect() }
@@ -38,6 +38,7 @@ extension SettingsView {
} }
#endif #endif
#if !os(tvOS) #if !os(tvOS)
renderScaleRow
bitrateRows bitrateRows
#endif #endif
} header: { } header: {
@@ -54,6 +55,50 @@ extension SettingsView {
} }
} }
#if !os(tvOS)
/// Render-scale picker + the resulting host resolution. > 1 supersamples (sharper, at more
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
@ViewBuilder var renderScaleRow: some View {
Picker("Render scale", selection: $renderScale) {
ForEach(RenderScale.presets, id: \.self) { scale in
Text(RenderScale.label(scale)).tag(scale)
}
}
// The concrete host resolution makes the cost legible. Only meaningful for the explicit mode
// (match-window derives the base from the live window, not these fields).
if renderScale != 1.0, !matchWindow {
let mode = RenderScale.apply(
baseWidth: width, baseHeight: height,
scale: renderScale,
maxDimension: RenderScale.maxDimension(codec: codec))
Text("Host renders \(Int(mode.width))×\(Int(mode.height)); this device downscales it to your display.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
/// Keyboard & mouse forwarding applies wherever a hardware keyboard/mouse drives the stream
/// (always on macOS; an attached keyboard/mouse on iPad). Absent on tvOS (no such input path).
@ViewBuilder var inputSection: some View {
Section {
Picker("Modifier keys", selection: $modifierLayout) {
ForEach(ModifierLayout.allCases, id: \.self) { layout in
Text(layout.label).tag(layout.rawValue)
}
}
Toggle("Invert scroll direction", isOn: $invertScroll)
} header: {
Text("Keyboard & mouse")
} footer: {
Text((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail
+ " Invert scroll reverses the wheel/trackpad scroll direction sent to the host.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
}
#endif
#if os(iOS) #if os(iOS)
// MARK: - Stream mode (iOS wheel) // MARK: - Stream mode (iOS wheel)
@@ -1,5 +1,6 @@
// App settings. The host creates a native virtual output at exactly the chosen size/refresh // App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
// there is no scaling anywhere in the pipeline. // deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
// downscales supersampling for sharpness, or under-rendering for a lighter host/link).
// //
// Navigation differs per platform, but all three group the same categories (General, Display, // Navigation differs per platform, but all three group the same categories (General, Display,
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses // Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
@@ -25,6 +26,10 @@ struct SettingsView: View {
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays // windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window. // pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false @AppStorage(DefaultsKey.matchWindow) var matchWindow = false
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
// link). 1.0 = Native (the prior behaviour).
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
@AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.compositor) var compositor = 0
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
@@ -51,6 +56,12 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true @AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false @AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10 @AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
#if !os(tvOS)
// Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips
// both wheel axes; modifier-layout relocates the / Alt/Super roles by physical position.
@AppStorage(DefaultsKey.invertScroll) var invertScroll = false
@AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue
#endif
#if DEBUG && !os(tvOS) #if DEBUG && !os(tvOS)
@State var showControllerTest = false @State var showControllerTest = false
#endif #endif
@@ -112,6 +123,7 @@ struct SettingsView: View {
TabView { TabView {
Form { Form {
streamModeSection streamModeSection
inputSection
compositorSection compositorSection
wakeSection wakeSection
} }
@@ -242,6 +254,7 @@ struct SettingsView: View {
Form { Form {
streamModeSection streamModeSection
pointerSection pointerSection
inputSection
compositorSection compositorSection
wakeSection wakeSection
keepAliveSection // iOS-only content; empty on tvOS keepAliveSection // iOS-only content; empty on tvOS
@@ -334,6 +347,10 @@ struct SettingsView: View {
return ScrollView { return ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
TVSelectionRow(
title: "Render scale",
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
selection: $renderScale)
TVSelectionRow( TVSelectionRow(
title: "Bitrate", title: "Bitrate",
options: SettingsOptions.bitrateOptions(current: bitrateKbps), options: SettingsOptions.bitrateOptions(current: bitrateKbps),
@@ -41,6 +41,7 @@ import UIKit
import Foundation import Foundation
import GameController import GameController
import PunktfunkCore import PunktfunkCore
import PunktfunkShared
import os import os
/// Diagnostic logging for the input path. Off by default (input is high-rate); set /// 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 onDisconnect: (() -> Void)?
public var onCycleStats: (() -> 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) #if os(iOS)
/// Windows VKs of the three modifier classes in the Q release chord, both L/R sides: /// 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. /// 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 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 return event
} }
#endif #endif
@@ -318,7 +333,7 @@ public final class InputCapture {
chordModifiersDown.removeAll() chordModifiersDown.removeAll()
suppressedVK = nil suppressedVK = nil
for vk in pressedVKs { for vk in pressedVKs {
connection.send(.key(vk, down: false)) emitKey(vk, down: false)
} }
for button in pressedButtons { for button in pressedButtons {
connection.send(.mouseButton(button, down: false)) connection.send(.mouseButton(button, down: false))
@@ -331,6 +346,15 @@ public final class InputCapture {
residualScrollY = 0 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 /// 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 /// 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 /// handler is gated off (`gcMouseForwarding` is false), so it can't deliver the release
@@ -399,7 +423,7 @@ public final class InputCapture {
inputLog.debug( inputLog.debug(
"key \(vk, privacy: .public) \(down ? "down" : "up", privacy: .public) sent") "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 /// 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 /// Moonlight's convention). Fed by StreamLayerView.scrollWheel the only delivery
/// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them). /// path that covers trackpad/Magic Mouse gestures (GCMouse never reports them).
/// Fractional remainders accumulate so slow two-finger scrolling isn't truncated away. /// 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 } 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 fy = dy + residualScrollY
let fx = dx + residualScrollX let fx = dx + residualScrollX
let iy = fy.rounded(.towardZero) let iy = fy.rounded(.towardZero)
@@ -643,7 +674,7 @@ public final class InputCapture {
} else { } else {
self.pressedVKs.remove(vk) self.pressedVKs.remove(vk)
} }
self.connection.send(.key(vk, down: pressed)) self.emitKey(vk, down: pressed)
} }
#endif #endif
} }
@@ -1,7 +1,28 @@
// InputCapture's static keymap tables: HID usage Windows VK (the GCKeyboard path on all // InputCapture's static keymap tables: HID usage Windows VK (the GCKeyboard path on all
// platforms) and, on macOS, NSEvent.keyCode Windows VK (the NSEvent key path). // platforms) and, on macOS, NSEvent.keyCode Windows VK (the NSEvent key path).
import PunktfunkShared
extension InputCapture { extension InputCapture {
/// Remap one modifier VK for the active location-based [`ModifierLayout`] just before it goes on
/// the wire. `.mac` (and every non-modifier VK) is the identity. `.windows` swaps the Alt vs
/// Super/Windows ROLE between the Option and Command keys while KEEPING the side, so a Windows
/// user's ` = Alt, = Win` muscle memory lands correctly:
/// L Command 0x5B L Alt 0xA4 · R Command 0x5C R Alt 0xA5.
/// It's applied ONLY at the send boundary (`InputCapture.emitKey`) all press/release
/// bookkeeping stays on the physical VK, so modifier direction tracking and the client-local
/// -based shortcuts are untouched. The swap is its own inverse: a key that went down remapped
/// goes up remapped, so the host never sees a stuck modifier.
static func applyModifierLayout(_ vk: UInt32, _ layout: ModifierLayout) -> UInt32 {
guard layout == .windows else { return vk }
switch vk {
case 0x5B: return 0xA4 // L Command L Alt (VK_LWIN VK_LMENU)
case 0x5C: return 0xA5 // R Command R Alt (VK_RWIN VK_RMENU)
case 0xA4: return 0x5B // L Option L Win (VK_LMENU VK_LWIN)
case 0xA5: return 0x5C // R Option R Win (VK_RMENU VK_RWIN)
default: return vk
}
}
/// HID usage (GCKeyCode raw) Windows VK (the host maps VK evdev; every VK emitted /// HID usage (GCKeyCode raw) Windows VK (the host maps VK evdev; every VK emitted
/// here exists in punktfunk-host/src/inject.rs::vk_to_evdev extend the two together). /// here exists in punktfunk-host/src/inject.rs::vk_to_evdev extend the two together).
static let hidToVK: [Int: UInt32] = { static let hidToVK: [Int: UInt32] = {
@@ -15,6 +15,7 @@
// from looping request rollback request. // from looping request rollback request.
import Foundation import Foundation
import PunktfunkShared
/// The pure, side-effect-free core of the Match-window trigger so the normalize/skip discipline /// The pure, side-effect-free core of the Match-window trigger so the normalize/skip discipline
/// is unit-tested without a live connection or a UI (`MatchWindowTests`). /// is unit-tested without a live connection or a UI (`MatchWindowTests`).
@@ -51,6 +52,11 @@ public final class MatchWindowFollower {
private weak var connection: PunktfunkConnection? private weak var connection: PunktfunkConnection?
private let debounce: TimeInterval private let debounce: TimeInterval
private let minSpacing: TimeInterval private let minSpacing: TimeInterval
/// Render-scale multiplier applied to the live window size before it's requested, so the
/// match-window path supersamples/undersamples exactly like the fixed-mode connect. 1.0 = the
/// window's native pixels (the prior behaviour). `maxDimension` is the codec's per-axis ceiling.
private let renderScale: Double
private let maxDimension: Int
private var enabled: Bool private var enabled: Bool
private var work: DispatchWorkItem? private var work: DispatchWorkItem?
@@ -74,15 +80,28 @@ public final class MatchWindowFollower {
public init( public init(
connection: PunktfunkConnection, connection: PunktfunkConnection,
enabled: Bool, enabled: Bool,
renderScale: Double = 1.0,
maxDimension: Int = 8192,
debounce: TimeInterval = 0.4, debounce: TimeInterval = 0.4,
minSpacing: TimeInterval = 1.0 minSpacing: TimeInterval = 1.0
) { ) {
self.connection = connection self.connection = connection
self.enabled = enabled self.enabled = enabled
self.renderScale = RenderScale.sanitize(renderScale)
self.maxDimension = maxDimension
self.debounce = debounce self.debounce = debounce
self.minSpacing = minSpacing self.minSpacing = minSpacing
} }
/// The host-valid mode for a live window size: the window's physical pixels × the render scale,
/// aspect-preserved, even, and clamped to the codec ceiling the match-window twin of
/// ContentView's `scaledMode()`. Reduces to `MatchWindow.normalize` when the scale is 1.0.
private func targetMode(widthPx: Int, heightPx: Int) -> (width: UInt32, height: UInt32) {
RenderScale.apply(
baseWidth: widthPx, baseHeight: heightPx,
scale: renderScale, maxDimension: maxDimension)
}
/// Turn following on/off live (a mid-session settings change; off cancels a pending request). /// Turn following on/off live (a mid-session settings change; off cancels a pending request).
public func setEnabled(_ on: Bool) { public func setEnabled(_ on: Bool) {
enabled = on enabled = on
@@ -109,7 +128,7 @@ public final class MatchWindowFollower {
/// mode yet nothing to compare against, skip. /// mode yet nothing to compare against, skip.
private func reportSteering(widthPx: Int, heightPx: Int) { private func reportSteering(widthPx: Int, heightPx: Int) {
guard let connection else { return } guard let connection else { return }
let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx) let target = targetMode(widthPx: widthPx, heightPx: heightPx)
let mode = connection.currentMode() let mode = connection.currentMode()
guard mode.width > 0, mode.height > 0 else { return } guard mode.width > 0, mode.height > 0 else { return }
if target.width == mode.width, target.height == mode.height { if target.width == mode.width, target.height == mode.height {
@@ -137,7 +156,7 @@ public final class MatchWindowFollower {
schedule() schedule()
return return
} }
let target = MatchWindow.normalize(widthPx: size.width, heightPx: size.height) let target = targetMode(widthPx: size.width, heightPx: size.height)
let mode = connection.currentMode() let mode = connection.currentMode()
pendingSize = nil pendingSize = nil
guard let req = MatchWindow.request( guard let req = MatchWindow.request(
@@ -621,6 +621,12 @@ public final class StreamLayerView: NSView {
guard let self, self.window?.isKeyWindow == true else { return } guard let self, self.window?.isKeyWindow == true else { return }
self.onDisconnectRequest?() self.onDisconnectRequest?()
} }
capture.onToggleFullscreen = { [weak self] in
// App-level window action: post to the key window's FullscreenController (same routing as
// the Stream menu's F item, so captured and released states hit one code path).
guard self?.window?.isKeyWindow == true else { return }
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
}
capture.onCycleStats = { [weak self] in capture.onCycleStats = { [weak self] in
guard self?.window?.isKeyWindow == true else { return } guard self?.window?.isKeyWindow == true else { return }
// Advance the shared tier setting directly every @AppStorage reader (the HUD's // Advance the shared tier setting directly every @AppStorage reader (the HUD's
@@ -671,7 +677,10 @@ public final class StreamLayerView: NSView {
// default keeps the explicit mode. // default keeps the explicit mode.
let follower = MatchWindowFollower( let follower = MatchWindowFollower(
connection: connection, connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
maxDimension: RenderScale.maxDimension(
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower) follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
matchFollower = follower matchFollower = follower
layoutPresenter() layoutPresenter()
@@ -385,7 +385,10 @@ public final class StreamViewController: StreamViewControllerBase {
// default keeps the explicit mode. // default keeps the explicit mode.
let follower = MatchWindowFollower( let follower = MatchWindowFollower(
connection: connection, connection: connection,
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false) enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false,
renderScale: UserDefaults.standard.object(forKey: DefaultsKey.renderScale) as? Double ?? 1.0,
maxDimension: RenderScale.maxDimension(
codec: UserDefaults.standard.string(forKey: DefaultsKey.codec) ?? "auto"))
follower.onResizeTarget = onResizeTarget follower.onResizeTarget = onResizeTarget
matchFollower = follower matchFollower = follower
#endif #endif
@@ -0,0 +1,52 @@
// Location-based modifier mapping which Windows VK each PHYSICAL modifier position forwards to
// the host. A Mac keyboard's bottom row is ` Control / Option / Command / space`; a Windows
// keyboard's is `Ctrl / Super / Alt / space`. So the key NEAREST the space bar is Command on a
// Mac but Alt on Windows, and the next one out is Option on a Mac but the Windows key. A user who
// grew up on Windows reaches for Alt where the Mac has Command; this setting lets them get that
// muscle memory back WITHOUT relabelling keycaps it remaps by physical position, not by a blunt
// "swap these two VKs" that would also drag Control/Shift or lose the left/right distinction.
//
// The model: keep the physical detection (which side, which key) exactly as the OS reports it, and
// swap only the Alt-vs-Super ROLE between the Option and Command keys, per side. So under `.windows`
// the position emits Alt (VK_L/RMENU) and the position emits the Windows key (VK_L/RWIN), while
// left stays left and right stays right. Control and Shift are in the same place on both keyboards,
// so they never move. Lives in PunktfunkShared because both the Settings UI (PunktfunkClient) and
// the wire-boundary remap (`InputCapture.applyModifierLayout`, PunktfunkKit) resolve it.
import Foundation
/// How the physical Option / Command keys map to host modifiers. The raw values are stable on
/// disk rename the cases freely, never the strings.
public enum ModifierLayout: String, CaseIterable, Sendable {
/// Apple positions (default): Option Alt, Command Super/Windows. The current behaviour.
case mac
/// Windows positions: the key nearest the space bar ( Command) Alt, the next one ( Option)
/// the Windows/Super key. Side (left/right) is preserved.
case windows
/// User-facing label (Settings picker).
public var label: String {
switch self {
case .mac: return "Mac (⌥ Alt · ⌘ Super)"
case .windows: return "Windows (⌘ Alt · ⌥ Super)"
}
}
/// A one-line explanation for the setting's footer.
public var detail: String {
switch self {
case .mac:
return "The ⌥ Option key sends Alt and the ⌘ Command key sends the Windows key — the Apple layout."
case .windows:
return "The key nearest the space bar sends Alt and the next one sends the Windows key, matching a PC keyboard. Client shortcuts (⌘⎋ and friends) still use the physical ⌘ key."
}
}
/// The persisted layout (default `.mac` when unset).
public static var current: ModifierLayout {
guard let raw = UserDefaults.standard.string(forKey: DefaultsKey.modifierLayout) else {
return .mac
}
return ModifierLayout(rawValue: raw) ?? .mac
}
}
@@ -0,0 +1,51 @@
import XCTest
import PunktfunkShared
@testable import PunktfunkKit
/// Pins the location-based modifier remap (`InputCapture.applyModifierLayout`) the wire-boundary
/// swap that relocates the Alt/Super role between the physical / keys per side, without touching
/// Control/Shift or the physical detection upstream.
final class ModifierLayoutMappingTests: XCTestCase {
// The four physical modifier VKs the remap can touch, and everything else it must not.
private let lWin: UInt32 = 0x5B, rWin: UInt32 = 0x5C
private let lAlt: UInt32 = 0xA4, rAlt: UInt32 = 0xA5
func testMacLayoutIsIdentity() {
for vk: UInt32 in [lWin, rWin, lAlt, rAlt, 0xA0, 0xA2, 0x41, 0x1B] {
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .mac), vk)
}
}
func testWindowsLayoutSwapsAltAndSuperPerSide() {
XCTAssertEqual(InputCapture.applyModifierLayout(lWin, .windows), lAlt) // L L Alt
XCTAssertEqual(InputCapture.applyModifierLayout(rWin, .windows), rAlt) // R R Alt
XCTAssertEqual(InputCapture.applyModifierLayout(lAlt, .windows), lWin) // L L Win
XCTAssertEqual(InputCapture.applyModifierLayout(rAlt, .windows), rWin) // R R Win
}
func testWindowsLayoutKeepsSideNeverCrossesLeftRight() {
// A left key never becomes a right VK or vice-versa.
XCTAssertNotEqual(InputCapture.applyModifierLayout(lWin, .windows), rAlt)
XCTAssertNotEqual(InputCapture.applyModifierLayout(rWin, .windows), lAlt)
}
func testControlShiftAndRegularKeysNeverMove() {
for vk: UInt32 in [
0xA0, 0xA1, // L/R Shift
0xA2, 0xA3, // L/R Control
0x41, 0x5A, // A, Z
0x1B, 0x0D, // Esc, Return
] {
XCTAssertEqual(InputCapture.applyModifierLayout(vk, .windows), vk)
}
}
func testWindowsRemapIsItsOwnInverse() {
// Down-remapped then the same key up-remapped land on the same host VK no stuck modifier.
for vk: UInt32 in [lWin, rWin, lAlt, rAlt] {
let once = InputCapture.applyModifierLayout(vk, .windows)
XCTAssertEqual(InputCapture.applyModifierLayout(once, .windows), vk)
}
}
}
@@ -0,0 +1,82 @@
import XCTest
import PunktfunkShared
/// Pins the render-scale geometry (`RenderScale`) the multiply aspect-preserve even-floor
/// codec-clamp that turns the `renderScale` setting into a host-valid `Mode`, plus the label/clamp
/// helpers the UI and the connect share.
final class RenderScaleTests: XCTestCase {
func testSanitizeClampsToRangeAndDefaultsMissing() {
XCTAssertEqual(RenderScale.sanitize(0), 1.0) // absent / zero Native
XCTAssertEqual(RenderScale.sanitize(-2), 1.0) // nonsense Native
XCTAssertEqual(RenderScale.sanitize(0.1), 0.5) // below the floor
XCTAssertEqual(RenderScale.sanitize(9), 4.0) // above the ceiling
XCTAssertEqual(RenderScale.sanitize(1.5), 1.5) // in range, untouched
}
func testMaxDimensionIsCodecAware() {
XCTAssertEqual(RenderScale.maxDimension(codec: "h264"), 4096)
XCTAssertEqual(RenderScale.maxDimension(codec: "hevc"), 8192)
XCTAssertEqual(RenderScale.maxDimension(codec: "av1"), 8192)
XCTAssertEqual(RenderScale.maxDimension(codec: "auto"), 8192)
}
func testNativeScaleIsIdentity() {
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 1.0, maxDimension: 8192)
XCTAssertEqual(m.width, 1920)
XCTAssertEqual(m.height, 1080)
}
func testSupersampleDoubles() {
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 2.0, maxDimension: 8192)
XCTAssertEqual(m.width, 3840)
XCTAssertEqual(m.height, 2160)
}
func testUnderRenderHalves() {
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 0.5, maxDimension: 8192)
XCTAssertEqual(m.width, 960)
XCTAssertEqual(m.height, 540)
}
func testResultsAreAlwaysEven() {
// 1280×720 × 1.5 = 1920×1080 (already even); 1366×768 × 1.5 = 2049×1152 2048×1152.
let m = RenderScale.apply(baseWidth: 1366, baseHeight: 768, scale: 1.5, maxDimension: 8192)
XCTAssertEqual(m.width % 2, 0)
XCTAssertEqual(m.height % 2, 0)
XCTAssertEqual(m.width, 2048)
XCTAssertEqual(m.height, 1152)
}
func testOverCeilingClampsUniformlyPreservingAspect() {
// 4K × 4 would be 15360×8640; both axes exceed 8192, so the LARGER (width) lands on the cap
// and the ratio is kept (16:9 8192×4608, even).
let m = RenderScale.apply(baseWidth: 3840, baseHeight: 2160, scale: 4.0, maxDimension: 8192)
XCTAssertLessThanOrEqual(m.width, 8192)
XCTAssertLessThanOrEqual(m.height, 8192)
XCTAssertEqual(m.width, 8192)
XCTAssertEqual(m.height, 4608)
// 16:9 preserved to within the even-floor rounding.
XCTAssertEqual(Double(m.width) / Double(m.height), 16.0 / 9.0, accuracy: 0.01)
}
func testH264CeilingIsTighter() {
// 1080p × 4 = 7680×4320; under H.264's 4096 wall the width clamps to 4096, aspect kept.
let m = RenderScale.apply(baseWidth: 1920, baseHeight: 1080, scale: 4.0, maxDimension: 4096)
XCTAssertEqual(m.width, 4096)
XCTAssertEqual(m.height, 2304)
}
func testMinimumFloorHonoured() {
// A tiny base under a small scale can't fall below 320×200.
let m = RenderScale.apply(baseWidth: 400, baseHeight: 300, scale: 0.5, maxDimension: 8192)
XCTAssertGreaterThanOrEqual(m.width, 320)
XCTAssertGreaterThanOrEqual(m.height, 200)
}
func testLabels() {
XCTAssertEqual(RenderScale.label(1.0), "Native (1×)")
XCTAssertEqual(RenderScale.label(2.0), "2× · supersample")
XCTAssertEqual(RenderScale.label(0.5), "0.5×")
}
}