Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.
25 changes: 15 additions & 10 deletions KVMConsoleiPad/App/KVMConsoleiPadApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
4 changes: 3 additions & 1 deletion KVMConsoleiPad/Input/PhysicalKeyboardObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions KVMConsoleiPad/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>KVM Console connects to your KVM device over the local network.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
Expand Down
29 changes: 29 additions & 0 deletions KVMConsoleiPad/UI/ScenePhasePausePolicy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
25 changes: 25 additions & 0 deletions KVMConsoleiPad/UI/ViewerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions KVMConsoleiPadTests/ScenePhasePausePolicyTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
2 changes: 2 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading