From 5e81a8a1b8f07dd61054d09750e40ca8c7ff9f51 Mon Sep 17 00:00:00 2001 From: Yu-Xi Lim Date: Thu, 25 Jun 2026 23:15:27 +0800 Subject: [PATCH] Support multiple simultaneous KVM connections on iPad Mirror the macOS multi-window model on iPad: a Connections scene plus a value-parameterized Viewer WindowGroup keyed on Device.ID, so devices run side-by-side under Stage Manager. A backgrounded viewer pauses its stream (disconnect) and auto-resumes (reconnect) on return, driven by the pure, unit-tested ScenePhasePausePolicy; .inactive is ignored so true side-by-side windows keep streaming. Also fix PhysicalKeyboardObserver to compile under Swift 6.3's stricter nonisolated-deinit rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +- KVMConsoleiPad/App/KVMConsoleiPadApp.swift | 25 ++++++----- .../Input/PhysicalKeyboardObserver.swift | 4 +- KVMConsoleiPad/Resources/Info.plist | 5 +++ KVMConsoleiPad/UI/ScenePhasePausePolicy.swift | 29 ++++++++++++ KVMConsoleiPad/UI/ViewerView.swift | 25 +++++++++++ .../ScenePhasePausePolicyTests.swift | 44 +++++++++++++++++++ project.yml | 2 + 8 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 KVMConsoleiPad/UI/ScenePhasePausePolicy.swift create mode 100644 KVMConsoleiPadTests/ScenePhasePausePolicyTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 75e277d..3ff7b25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Release pipeline is documented in `DeveloperRelease.md`. One published GitHub Re - `Assets.xcassets/AppIcon` `KVMConsoleiPad/` — iPadOS app target. -- `App/KVMConsoleiPadApp.swift` — single `WindowGroup` with a `NavigationStack`; the Connection list pushes the Viewer (one connection at a time, no multi-scene) +- `App/KVMConsoleiPadApp.swift` — two scenes mirroring macOS: a `WindowGroup` Connections list plus a value-parameterized `WindowGroup("Viewer", for: Device.ID.self)`; connecting opens a separate viewer window so devices run side-by-side under Stage Manager (`UIApplicationSupportsMultipleScenes`) - `UI/` — `ViewerView`, `ViewerHostView`, `ModifierKeyBar` (Ctrl/Alt/Cmd/Shift/Win on-screen bar, tap = momentary, long-press = lock) - `Input/` — `KeyboardCaptureView` (`UIPress`/`UIKey`), `PointerCaptureView` (`UIHover`/`UIPan`/`UITap`) - `Video/VideoRenderView.swift` — `UIViewRepresentable` wrapper over `SampleBufferDisplay` @@ -81,4 +81,4 @@ Release pipeline is documented in `DeveloperRelease.md`. One published GitHub Re - NanoKVM API responses follow NanoKVM's `{code, msg, data}` envelope — see `NanoKVMClient`; GLKVM uses PiKVM-shaped REST and WebSocket messages. - Passwords are stored per-device in the Keychain via `KeychainPasswordStore`, never in the saved-devices JSON. - macOS fullscreen exit is triple-Escape (`FullscreenKeyCapture`). iPad has no fullscreen capture mode. -- iPad supports a single active KVM connection at a time (single-scene NavigationStack); macOS allows multiple viewer windows side-by-side. +- Both platforms support multiple simultaneous viewer windows. On iPad an inactive (backgrounded) viewer pauses its stream via `ScenePhasePausePolicy` (scene-phase-driven `disconnect()`/`reconnect()`) and auto-resumes only the sessions it paused; `.inactive` is ignored so true side-by-side windows keep streaming. diff --git a/KVMConsoleiPad/App/KVMConsoleiPadApp.swift b/KVMConsoleiPad/App/KVMConsoleiPadApp.swift index a6de468..a7021d5 100644 --- a/KVMConsoleiPad/App/KVMConsoleiPadApp.swift +++ b/KVMConsoleiPad/App/KVMConsoleiPadApp.swift @@ -4,20 +4,25 @@ import SwiftUI @main struct KVMConsoleiPadApp: App { @StateObject private var devicesStore = SavedDevicesStore() - @State private var connectedDeviceID: Device.ID? var body: some Scene { - WindowGroup("KVM Console") { + // Connections list. Connecting to a device opens a separate Viewer window + // (ConnectionManagerView falls back to openWindow(value:) when given no + // onConnect callback), so two devices can run side-by-side under Stage Manager. + WindowGroup("KVM Console", id: "connections") { NavigationStack { - ConnectionManagerView { device in - connectedDeviceID = device.id - } - .environmentObject(devicesStore) - .navigationDestination(item: $connectedDeviceID) { deviceID in - ViewerHostView(deviceID: deviceID) - .environmentObject(devicesStore) - } + ConnectionManagerView() } + .environmentObject(devicesStore) + } + + // One Viewer window per Device.ID. Wrapped in its own NavigationStack so the + // viewer's toolbar renders inside the new window rather than the Connections nav. + WindowGroup("Viewer", id: "viewer", for: Device.ID.self) { $deviceID in + NavigationStack { + ViewerHostView(deviceID: deviceID) + } + .environmentObject(devicesStore) } } } diff --git a/KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift b/KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift index 37bf9af..4882f9d 100644 --- a/KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift +++ b/KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift @@ -10,7 +10,9 @@ import GameController final class PhysicalKeyboardObserver: ObservableObject { @Published private(set) var isConnected: Bool - private var observers: [NSObjectProtocol] = [] + // `nonisolated(unsafe)`: this @MainActor class's deinit is nonisolated and must + // remove its observers; deinit has exclusive access, so the unchecked access is safe. + private nonisolated(unsafe) var observers: [NSObjectProtocol] = [] init() { isConnected = GCKeyboard.coalesced != nil diff --git a/KVMConsoleiPad/Resources/Info.plist b/KVMConsoleiPad/Resources/Info.plist index 640b86a..097aa78 100644 --- a/KVMConsoleiPad/Resources/Info.plist +++ b/KVMConsoleiPad/Resources/Info.plist @@ -33,6 +33,11 @@ NSLocalNetworkUsageDescription KVM Console connects to your KVM device over the local network. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UILaunchScreen UISupportedInterfaceOrientations diff --git a/KVMConsoleiPad/UI/ScenePhasePausePolicy.swift b/KVMConsoleiPad/UI/ScenePhasePausePolicy.swift new file mode 100644 index 0000000..491ae2d --- /dev/null +++ b/KVMConsoleiPad/UI/ScenePhasePausePolicy.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// What the viewer should do with its stream in response to a scene-phase change. +enum BackgroundPauseAction: Equatable { + case none + case pause + case resume +} + +/// Pure decision for pausing/resuming a viewer's KVM stream as its window moves +/// between scene phases. Kept separate from the view so the rule is unit-testable. +/// +/// `.inactive` is deliberately ignored: it fires transiently (App Switcher, Control +/// Center, multitasking gestures) and for genuinely side-by-side windows both scenes +/// stay `.active`, so we only pause on a real `.background`. +enum ScenePhasePausePolicy { + static func action(phase: ScenePhase, sessionIsLive: Bool, isPaused: Bool) -> BackgroundPauseAction { + switch phase { + case .background: + return sessionIsLive ? .pause : .none + case .active: + return isPaused ? .resume : .none + case .inactive: + return .none + @unknown default: + return .none + } + } +} diff --git a/KVMConsoleiPad/UI/ViewerView.swift b/KVMConsoleiPad/UI/ViewerView.swift index dc8420d..452810f 100644 --- a/KVMConsoleiPad/UI/ViewerView.swift +++ b/KVMConsoleiPad/UI/ViewerView.swift @@ -34,10 +34,14 @@ struct ViewerHostView: View { struct ViewerView: View { @StateObject private var model: ViewerViewModel @StateObject private var keyboardObserver = PhysicalKeyboardObserver() + @Environment(\.scenePhase) private var scenePhase @State private var modifierState = ModifierKeyState() @State private var showModifierBar = true @State private var keyboardFocusToken = 0 @State private var pendingVirtualKey: VirtualKeyTap? + // True only while a stream was paused because THIS window went to the background, + // so we auto-resume on return without reviving a manually-disconnected session. + @State private var pausedForBackground = false init(device: Device, onConnected: ((Device.ID) -> Void)? = nil) { _model = StateObject(wrappedValue: ViewerViewModel(device: device, onConnected: onConnected)) @@ -66,11 +70,32 @@ struct ViewerView: View { .background(Color.black) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } + .onChange(of: scenePhase) { _, newPhase in + handleScenePhase(newPhase) + } .onDisappear { model.disconnect() } } + private func handleScenePhase(_ phase: ScenePhase) { + let sessionIsLive = model.isStreaming || model.state == .connecting + switch ScenePhasePausePolicy.action( + phase: phase, + sessionIsLive: sessionIsLive, + isPaused: pausedForBackground + ) { + case .pause: + pausedForBackground = true + model.disconnect() + case .resume: + pausedForBackground = false + model.reconnect() + case .none: + break + } + } + private var videoArea: some View { ZStack { Color.black diff --git a/KVMConsoleiPadTests/ScenePhasePausePolicyTests.swift b/KVMConsoleiPadTests/ScenePhasePausePolicyTests.swift new file mode 100644 index 0000000..858e32b --- /dev/null +++ b/KVMConsoleiPadTests/ScenePhasePausePolicyTests.swift @@ -0,0 +1,44 @@ +@testable import KVMConsoleiPad +import SwiftUI +import XCTest + +final class ScenePhasePausePolicyTests: XCTestCase { + func test_backgroundWithLiveSessionPauses() { + XCTAssertEqual( + ScenePhasePausePolicy.action(phase: .background, sessionIsLive: true, isPaused: false), + .pause + ) + } + + func test_backgroundWithIdleSessionDoesNothing() { + XCTAssertEqual( + ScenePhasePausePolicy.action(phase: .background, sessionIsLive: false, isPaused: false), + .none + ) + } + + func test_activeWhilePausedResumes() { + XCTAssertEqual( + ScenePhasePausePolicy.action(phase: .active, sessionIsLive: false, isPaused: true), + .resume + ) + } + + func test_activeWhenNotPausedDoesNothing() { + XCTAssertEqual( + ScenePhasePausePolicy.action(phase: .active, sessionIsLive: true, isPaused: false), + .none + ) + } + + func test_inactiveIsIgnored() { + XCTAssertEqual( + ScenePhasePausePolicy.action(phase: .inactive, sessionIsLive: true, isPaused: false), + .none + ) + XCTAssertEqual( + ScenePhasePausePolicy.action(phase: .inactive, sessionIsLive: false, isPaused: true), + .none + ) + } +} diff --git a/project.yml b/project.yml index bdc1f24..0222292 100644 --- a/project.yml +++ b/project.yml @@ -96,6 +96,8 @@ targets: ITSAppUsesNonExemptEncryption: false GCSupportsControllerUserInteraction: true LSRequiresIPhoneOS: true + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true NSAppTransportSecurity: NSAllowsArbitraryLoads: true NSLocalNetworkUsageDescription: "KVM Console connects to your KVM device over the local network."