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."