From 8277e0d9a8f9ef255f84e023e7e29858aca7882b Mon Sep 17 00:00:00 2001 From: 2extndd Date: Mon, 25 May 2026 23:43:30 +0300 Subject: [PATCH 01/40] Fix trackpad input lag and choppy 2-finger scroll on Magic Keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues, same root cause family — gesture recognizers ingesting Magic Keyboard trackpad events through unsuitable code paths. Bug 1 — Cursor lag / sticky trackpad (issue #47): The main panGestureRecognizer had no allowedTouchTypes filter, so it was also consuming .indirectPointer events (iPadOS 13.4+ trackpad / pointer). UIPanGestureRecognizer has a small recognition threshold before .began and re-arms its state machine between strokes — at the per-frame rate trackpad events arrive, that produces visible stickiness/judder. Other gesture recognizers in the file already filter to [.direct, .pencil] ([0, 2]); the main pan was just missing the same filter. Fix: - Set panGestureRecognizer.allowedTouchTypes = [.direct, .pencil] so it no longer competes for trackpad events. - Override touchesMoved (and touchesBegan to reset accumulators) to handle .indirectPointer touches directly. Uses preciseLocation / precisePreviousLocation deltas with sub-pixel accumulation through the existing accumulatedDeltaX / accumulatedDeltaY fields. Respects cursorMode (.direct → absolute sendMousePosition, otherwise → sendMouseDelta). Bug 2 — Choppy 2-finger scroll: The 2-finger branch of handlePanGesture sent CParsec.sendWheelMsg based on gestureRecognizer.velocity(in:) / 20. Velocity-based scaling against the high-frequency event stream from a trackpad produces large, irregular wheel deltas — perceived as stepped scrolling. Fix: - Add a dedicated UIPanGestureRecognizer with allowedScrollTypesMask = .all and maximumNumberOfTouches = 0 (so it sees scroll-wheel/trackpad-scroll events only, never finger touches). - handleTrackpadScroll uses translation(in:) deltas between callbacks with sub-pixel accumulation (accumulatedScrollX/Y) — same pattern Moonlight iOS uses for its continuous-scroll path. - Keeps the existing velocity-based 2-finger branch in handlePanGesture intact for actual touchscreen 2-finger swipes (those still fire there because the main pan recognizer keeps .direct touches). Regression-safe: - Direct touch mode preserved (touchesMoved still sends sendMousePosition). - External USB/BT mice unaffected — that path is GCMouse-based (Magic Keyboard trackpad does not enumerate as GCMouse on iPadOS, which is why this fix has to live here). - UIScrollView pinch-to-zoom (minimumNumberOfTouches=2) untouched. - 1-/2-finger touchscreen gestures untouched (main pan now restricted to .direct/.pencil; tap recognizers already had [0,2]/[0] filters). - 3-finger tap for virtual keyboard untouched. Deployment target is iOS 14 (project.pbxproj OpenParsec target), so .indirectPointer (13.4+), allowedScrollTypesMask (13.4+), and preciseLocation/precisePreviousLocation (9.1+) are available without #available gates. --- OpenParsec/ParsecViewController.swift | 105 +++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 00c8106..d591644 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -27,6 +27,13 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var accumulatedDeltaY: Float = 0.0 var lastPanLocation: CGPoint = .zero var lastPanTranslation: CGPoint = .zero + + // Trackpad / mouse-wheel scroll accumulators (separate from the touchscreen + // 2-finger pan path, which keeps using velocity-based wheel messages for + // direct-touch swipes). + var accumulatedScrollX: Float = 0.0 + var accumulatedScrollY: Float = 0.0 + var lastScrollTranslation: CGPoint = .zero var mouseSensitivity: Float = Float(SettingsHandler.mouseSensitivity) var activatedPanFingerNumber: Int = 0 @@ -222,8 +229,26 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { // Important: Allow our pan gesture to work alongside scrollview's? // No, we want 1 finger for this pan, 2 fingers for scrollview. // So they are distinct by touch count. + // Exclude .indirectPointer (Magic Keyboard trackpad / iPad pointer) — those + // events are handled directly via touchesMoved below to avoid the latency + // of the gesture-recognizer state machine (issue #47: sticky cursor). + panGestureRecognizer.allowedTouchTypes = [ + NSNumber(value: UITouch.TouchType.direct.rawValue), + NSNumber(value: UITouch.TouchType.pencil.rawValue) + ] view.addGestureRecognizer(panGestureRecognizer) + // Dedicated recognizer for trackpad / mouse-wheel scroll events + // (iPadOS 13.4+, available unconditionally at deployment target 14). + // maximumNumberOfTouches = 0 makes it respond ONLY to scroll-wheel events, + // not to fingers — so touchscreen 2-finger swipes still flow through the + // main pan recognizer with allowedTouchTypes = direct + pencil. + let trackpadScrollRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handleTrackpadScroll(_:))) + trackpadScrollRecognizer.delegate = self + trackpadScrollRecognizer.allowedScrollTypesMask = .all + trackpadScrollRecognizer.maximumNumberOfTouches = 0 + view.addGestureRecognizer(trackpadScrollRecognizer) + // Remove custom Pinch logic, ScrollView handles it. // But we might want to know isPinching status? // Let's rely on ScrollView delegate for updates. @@ -312,12 +337,52 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } + // Direct trackpad pointer handling (issue #47). With prefersPointerLocked = true, + // iPadOS delivers Magic Keyboard trackpad motion as UITouches with + // type == .indirectPointer. Routing those through a UIPanGestureRecognizer + // imposes a recognition threshold and a state-machine churn between strokes, + // which is what users experience as the "sticky / juddery" cursor. + // + // The main pan recognizer's allowedTouchTypes excludes .indirectPointer + // (see viewDidLoad), so those touches reach this override unobstructed. + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + for touch in touches where touch.type == .indirectPointer { + accumulatedDeltaX = 0.0 + accumulatedDeltaY = 0.0 + break + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + for touch in touches where touch.type == .indirectPointer { + if SettingsHandler.cursorMode == .direct { + let pos = touch.preciseLocation(in: view) + let adjusted = contentView.convert(pos, from: view) + CParsec.sendMousePosition(Int32(adjusted.x), Int32(adjusted.y)) + } else { + let prev = touch.precisePreviousLocation(in: view) + let cur = touch.preciseLocation(in: view) + accumulatedDeltaX += Float(cur.x - prev.x) * mouseSensitivity + accumulatedDeltaY += Float(cur.y - prev.y) * mouseSensitivity + let dx = Int32(accumulatedDeltaX) + let dy = Int32(accumulatedDeltaY) + if dx != 0 || dy != 0 { + CParsec.sendMouseDelta(dx, dy) + accumulatedDeltaX -= Float(dx) + accumulatedDeltaY -= Float(dy) + } + } + } + } + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - + for press in presses { CParsec.sendKeyboardMessage(event:KeyBoardKeyEvent(input: press.key, isPressBegin: true) ) } - + } override func pressesEnded (_ presses: Set, with event: UIPressesEvent?) { @@ -469,8 +534,42 @@ extension ParsecViewController : UIGestureRecognizerDelegate { } } + // Trackpad / mouse-wheel scroll handler, separated from handlePanGesture so it + // can use translation deltas (smooth) instead of velocity (rough wheel + // messages). Hooked up by a UIPanGestureRecognizer with + // allowedScrollTypesMask = .all and maximumNumberOfTouches = 0 in viewDidLoad, + // so it only sees scroll-wheel / trackpad-scroll events, never finger touches. + @objc func handleTrackpadScroll(_ gestureRecognizer: UIPanGestureRecognizer) { + switch gestureRecognizer.state { + case .began: + lastScrollTranslation = .zero + accumulatedScrollX = 0.0 + accumulatedScrollY = 0.0 + case .changed: + let translation = gestureRecognizer.translation(in: gestureRecognizer.view) + let deltaX = Float(translation.x - lastScrollTranslation.x) * mouseSensitivity + let deltaY = Float(translation.y - lastScrollTranslation.y) * mouseSensitivity + lastScrollTranslation = translation + accumulatedScrollX += deltaX + accumulatedScrollY += deltaY + let intX = Int32(accumulatedScrollX) + let intY = Int32(accumulatedScrollY) + if intX != 0 || intY != 0 { + CParsec.sendWheelMsg(x: intX, y: intY) + accumulatedScrollX -= Float(intX) + accumulatedScrollY -= Float(intY) + } + case .ended, .cancelled, .failed: + lastScrollTranslation = .zero + accumulatedScrollX = 0.0 + accumulatedScrollY = 0.0 + default: + break + } + } + @objc func handleSingleFingerTap(_ gestureRecognizer: UITapGestureRecognizer) { - + let location = gestureRecognizer.location(in:gestureRecognizer.view) let adjustedLocation = contentView.convert(location, from: view) touchController.onTap(typeOfTap: 1, location: adjustedLocation) From c6f9eb2b94b4575eb22de47db43f2505a66b4002 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 01:47:08 +0300 Subject: [PATCH 02/40] =?UTF-8?q?Add=20Mac=E2=86=94iPad=20keyboard=20layou?= =?UTF-8?q?t=20sync=20via=20host=20hotkey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: switching the iPad's hardware-keyboard input language (Caps Lock / Ctrl+Space on Magic Keyboard) only switches the iPad's keymap. Parsec's iOS SDK sends MESSAGE_KEYBOARD with raw HID scancodes (see ParsecSDKBridge .sendKeyboardMessage), and the host interprets them through its own current layout. After a switch on the iPad the user sees the wrong characters on the host until the host's layout is changed manually. Issues touching the same area: #46 (special chars not passing through), #50 (uppercase letters with digital keyboard), #54 (Cmd+Space at system level can't be intercepted). Parsec's iOS SDK doesn't expose a Unicode / MESSAGE_CHAR path that would let us bypass host layout — the only public keyboard message is scancode-based. So instead of trying to translate characters on the client side, we get the host to switch its layout in lock-step with the iPad. Approach (matches macOS's built-in "select previous input source" workflow): 1. LanguageSyncCoordinator (in ParsecViewController.swift) owns a 1×1 alpha-0 LanguageSyncTextField. The field installs an empty inputView so the soft keyboard never appears, and is made first-responder while the streaming session is on screen. 2. UITextInputMode.currentInputModeDidChangeNotification only fires when a text-input first responder exists — the hidden field satisfies that. The coordinator listens and pulls the new BCP-47 language code ("en-US" / "ru-RU" / "zh-Hans") from the notification's object. 3. On a real change (not a redundant fire), the coordinator calls back into the view controller, which fires a configurable host hotkey via CParsec.sendVirtualKeyboardInput. Default is Ctrl+Space (the macOS built-in "select previous input source" shortcut). Cmd+Space, Alt+Space, and Alt+Shift (Windows convention) are also available. The host needs the matching layouts in matching order — same constraint as the native macOS toggle. 4. Modifier release is delayed (~50 ms) past the normal-key release so the host sees the chord intact (CParsec's sendVirtualKeyboardInput already releases the normal key async at +20 ms, so a synchronous modifier release would race ahead of it). 5. LanguageSyncTextField overrides pressesBegan/Ended/Changed/Cancelled and does NOT call super, so the field never consumes hardware-keyboard events as text input — those continue to flow up the responder chain to ParsecViewController.pressesBegan, where the existing scancode pipeline handles them unchanged. Same trick Moonlight iOS uses in StreamView.m (their comment: "This will prevent the legacy UITextField from receiving the event"). First-responder cooperation: The view controller already conforms to UIKeyInput and takes first-responder status when the soft keyboard is shown (3-finger tap → showKeyboard / setKeyboardVisible(true)). To avoid two competing responders, the coordinator yields its hidden field before the VC becomes FR and reclaims it after the VC resigns FR (in setKeyboardVisible(false)). Settings UI: - SettingsHandler.syncKeyboardLayout (Bool, default true) — kill switch. - SettingsHandler.layoutSyncHotkey (LayoutSyncHotkey, default ctrlSpace) — which combination to send. - Exposed in SettingsView under a new "Keyboard" section. Regression-safe: - syncKeyboardLayout = false → no coordinator, no hidden field, no observer — the file is dormant. Existing behaviour exactly preserved. - Hardware-keyboard scancodes still flow through ParsecViewController .pressesBegan/Ended unchanged (field forwards without consuming). - Soft-keyboard flow (3-finger tap, virtual keyboard, accessory toolbar) untouched — coordinator yields FR before becomeFirstResponder() and reclaims after resignFirstResponder(). - Direct touch mode, GCMouse path, UIScrollView pinch-to-zoom — all untouched. Deployment target is iOS 14, so UITextInputMode (4.2+), currentInputModeDidChangeNotification (4.2+), and primaryLanguage (4.2+) need no #available gates. --- OpenParsec/ParsecSDKBridge.swift | 14 ++ OpenParsec/ParsecViewController.swift | 215 +++++++++++++++++++++++++- OpenParsec/SettingsHandler.swift | 7 + OpenParsec/SettingsView.swift | 22 +++ 4 files changed, 257 insertions(+), 1 deletion(-) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index cc0f23b..e543962 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -20,6 +20,20 @@ enum CursorMode: Int case direct } +// Which hotkey to fire at the host when the iPad's hardware-keyboard input +// language changes. Default: Ctrl+Space, the macOS built-in "select previous +// input source" shortcut — works if the user has the same two layouts on the +// host as on the iPad, in the same order. For other layout sets / OSes the +// user can pick a different combination in Settings. +enum LayoutSyncHotkey: Int, CaseIterable +{ + case none = 0 + case ctrlSpace = 1 + case cmdSpace = 2 + case altSpace = 3 + case altShift = 4 +} + enum RightClickPosition: Int { case firstFinger diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index d591644..7ea69dc 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -34,6 +34,10 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var accumulatedScrollX: Float = 0.0 var accumulatedScrollY: Float = 0.0 var lastScrollTranslation: CGPoint = .zero + + // Layout sync — fires a hotkey at the host when the iPad's hardware-keyboard + // input language changes (e.g. Caps Lock toggle on Magic Keyboard). + var languageSync: LanguageSyncCoordinator? var mouseSensitivity: Float = Float(SettingsHandler.mouseSensitivity) var activatedPanFingerNumber: Int = 0 @@ -324,6 +328,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { becomeFirstResponder() } scrollView.pinchGestureRecognizer?.isEnabled = zoomEnabled + startLanguageSyncIfNeeded() } override func viewWillDisappear(_ animated: Bool) { @@ -334,6 +339,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + stopLanguageSync() } @@ -854,6 +860,9 @@ extension ParsecViewController : UIKeyInput, UITextInputTraits { } @objc func showKeyboard() { + // Yield FR to the VC so the soft keyboard can attach. Symmetric to the + // path in setKeyboardVisible. + languageSync?.yieldFirstResponder() becomeFirstResponder() } @@ -867,6 +876,10 @@ extension ParsecViewController : UIKeyInput, UITextInputTraits { if visible { DispatchQueue.main.async { self.reloadInputViews() + // Cede first-responder so the soft keyboard can attach to the + // view controller (the hidden language-sync field holds it + // otherwise). + self.languageSync?.yieldFirstResponder() let success = self.becomeFirstResponder() if !success { // Fallback: try again? or just log (can't log). @@ -875,7 +888,207 @@ extension ParsecViewController : UIKeyInput, UITextInputTraits { } } else { resignFirstResponder() + // Reclaim so we keep getting currentInputModeDidChangeNotification + // while the soft keyboard is hidden. + languageSync?.reclaimFirstResponder() } } - + +} + +// MARK: - Language sync (Mac ↔ iPad keyboard layout) +// +// Goal: when the user switches the iPad's hardware-keyboard input language +// (Caps Lock toggle on Magic Keyboard / Ctrl+Space / Globe key), fire a hotkey +// at the host so its input source switches in lock-step. +// +// Why a hotkey and not a "real" Unicode-text path: Parsec's iOS SDK only sends +// MESSAGE_KEYBOARD with HID scancodes (see ParsecSDKBridge.sendKeyboardMessage). +// There is no documented MESSAGE_CHAR / UTF-8 path that would let us bypass +// the host layout. So we ask the host to switch its own layout. Default +// hotkey is Ctrl+Space (macOS built-in "select previous input source"); user +// can pick something else if their host config differs. +// +// Detecting the language change requires a UIResponder that accepts text +// input to be first responder (Apple's `currentInputModeDidChangeNotification` +// only fires in that case). We use a 1×1 alpha-0 UITextField with an empty +// inputView so the soft keyboard never appears; the field forwards +// pressesBegan/Ended to the view controller so hardware-keyboard scancodes +// continue to flow through the existing pipeline. +extension ParsecViewController { + func startLanguageSyncIfNeeded() { + guard SettingsHandler.syncKeyboardLayout, languageSync == nil else { return } + let coordinator = LanguageSyncCoordinator(host: self, keyForwardTarget: self) + coordinator.onLanguageChange = { [weak self] _ in + self?.sendLayoutSyncHotkey() + } + coordinator.start() + languageSync = coordinator + } + + func stopLanguageSync() { + languageSync?.stop() + languageSync = nil + } + + func sendLayoutSyncHotkey() { + let hotkey = SettingsHandler.layoutSyncHotkey + switch hotkey { + case .none: + return + case .ctrlSpace: + tapKey(modifierKey: "CONTROL", normalKey: "SPACE") + case .cmdSpace: + tapKey(modifierKey: "LGUI", normalKey: "SPACE") + case .altSpace: + tapKey(modifierKey: "LALT", normalKey: "SPACE") + case .altShift: + tapModifierChord(firstModifier: "LALT", secondModifier: "SHIFT") + } + } + + // Press modifier → press+release normal key → release modifier. The + // release of the normal key is async (+20ms) inside CParsec, so we delay + // the modifier release by ~50ms to keep the chord intact on the host. + private func tapKey(modifierKey: String, normalKey: String) { + CParsec.sendVirtualKeyboardInput(text: modifierKey, isOn: true) + CParsec.sendVirtualKeyboardInput(text: normalKey) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + CParsec.sendVirtualKeyboardInput(text: modifierKey, isOn: false) + } + } + + // Two modifiers held + released as a chord (e.g. Alt+Shift on Windows). + private func tapModifierChord(firstModifier: String, secondModifier: String) { + CParsec.sendVirtualKeyboardInput(text: firstModifier, isOn: true) + CParsec.sendVirtualKeyboardInput(text: secondModifier, isOn: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { + CParsec.sendVirtualKeyboardInput(text: secondModifier, isOn: false) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.07) { + CParsec.sendVirtualKeyboardInput(text: firstModifier, isOn: false) + } + } +} + +// Hidden field that owns first-responder status so that +// UITextInputMode.currentInputModeDidChangeNotification keeps firing. It +// forwards all UIPress events to the host view controller without consuming +// them — that way hardware-keyboard scancodes continue to flow through +// ParsecViewController.pressesBegan/Ended unchanged. +final class LanguageSyncTextField: UITextField { + weak var keyForwardTarget: UIResponder? + + override var canBecomeFirstResponder: Bool { return true } + + // Returning an empty UIView for inputView suppresses the on-screen keyboard + // while the field is first responder, even without a connected hardware + // keyboard. Returning the field's existing inputAccessoryView (none) keeps + // the accessory chrome empty too. + private let _emptyInputView = UIView() + override var inputView: UIView? { + get { return _emptyInputView } + set { /* ignore — we want the soft kb suppressed unconditionally */ } + } + + // By NOT calling super, we prevent UITextField's legacy text-input path + // from consuming printable keys via UIKeyInput.insertText. Same trick + // Moonlight uses in StreamView.m pressesBegan/pressesEnded. + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + keyForwardTarget?.pressesBegan(presses, with: event) + } + override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + keyForwardTarget?.pressesEnded(presses, with: event) + } + override func pressesChanged(_ presses: Set, with event: UIPressesEvent?) { + keyForwardTarget?.pressesChanged(presses, with: event) + } + override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + keyForwardTarget?.pressesCancelled(presses, with: event) + } +} + +// Owns the hidden field, the change-notification observer, and the +// last-seen-language memo. Cooperates with the view controller's +// becomeFirstResponder / resignFirstResponder lifecycle via yield/reclaim. +final class LanguageSyncCoordinator { + private weak var host: UIViewController? + private weak var keyForwardTarget: UIResponder? + private var hiddenField: LanguageSyncTextField? + private var observer: NSObjectProtocol? + private var lastLanguage: String? + var onLanguageChange: ((String?) -> Void)? + + init(host: UIViewController, keyForwardTarget: UIResponder) { + self.host = host + self.keyForwardTarget = keyForwardTarget + } + + func start() { + guard hiddenField == nil, let hostView = host?.view else { return } + + let field = LanguageSyncTextField(frame: CGRect(x: -100, y: -100, width: 1, height: 1)) + field.alpha = 0 + field.autocorrectionType = .no + field.autocapitalizationType = .none + field.spellCheckingType = .no + field.smartDashesType = .no + field.smartQuotesType = .no + field.smartInsertDeleteType = .no + field.keyForwardTarget = keyForwardTarget + hostView.addSubview(field) + field.becomeFirstResponder() + hiddenField = field + + // Seed with current language so we don't fire a redundant hotkey on + // first real change. + lastLanguage = currentLanguage(from: nil) + + observer = NotificationCenter.default.addObserver( + forName: UITextInputMode.currentInputModeDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] note in + self?.handleChange(note: note) + } + } + + func stop() { + if let obs = observer { + NotificationCenter.default.removeObserver(obs) + observer = nil + } + hiddenField?.resignFirstResponder() + hiddenField?.removeFromSuperview() + hiddenField = nil + } + + // Step aside so the host view controller can become first responder + // (e.g. when the soft keyboard is shown via 3-finger tap). + func yieldFirstResponder() { + hiddenField?.resignFirstResponder() + } + + // Re-take first-responder status when the host VC has resigned (typically + // after the soft keyboard is dismissed). + func reclaimFirstResponder() { + hiddenField?.becomeFirstResponder() + } + + private func handleChange(note: Notification) { + let lang = currentLanguage(from: note) + guard lang != lastLanguage else { return } + lastLanguage = lang + onLanguageChange?(lang) + } + + // Prefer the mode advertised by the notification, fall back to whatever + // the hidden field currently reports. Either may be nil if no responder + // accepts text input at the moment. + private func currentLanguage(from note: Notification?) -> String? { + if let mode = note?.object as? UITextInputMode, let lang = mode.primaryLanguage { + return lang + } + return hiddenField?.textInputMode?.primaryLanguage + } } diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index fdfb5ea..811d988 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -16,6 +16,13 @@ struct SettingsHandler { @AppStorage("decoderCompatibility") public static var decoderCompatibility: Bool = false // Enable for stutter issues on some devices @AppStorage("showKeyboardButton") public static var showKeyboardButton: Bool = true + // When the iPad's hardware keyboard layout changes (Caps Lock / Ctrl+Space + // on Magic Keyboard), fire a hotkey at the host so the host's input source + // switches in lock-step. Eliminates the "wrong characters after switching + // language" problem people hit on iPad ↔ Mac Parsec sessions. + @AppStorage("syncKeyboardLayout") public static var syncKeyboardLayout: Bool = true + @AppStorage("layoutSyncHotkey") public static var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace + @AppStorage("saveSessionSettings") public static var saveSessionSettings: Bool = true @AppStorage("savedZoomEnabled") public static var savedZoomEnabled: Bool = false @AppStorage("savedConstantFps") public static var savedConstantFps: Bool = false diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 48d0017..2164ed9 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -17,6 +17,8 @@ struct SettingsView:View @AppStorage("preferredFramesPerSecond") var preferredFramesPerSecond: Int = 60 // 0 = use device max (ProMotion) @AppStorage("decoderCompatibility") var decoderCompatibility: Bool = false // Enable for stutter issues on some devices @AppStorage("showKeyboardButton") var showKeyboardButton: Bool = true + @AppStorage("syncKeyboardLayout") var syncKeyboardLayout: Bool = true + @AppStorage("layoutSyncHotkey") var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace @AppStorage("saveSessionSettings") var saveSessionSettings: Bool = true let resolutionChoices: [Choice] @@ -111,6 +113,26 @@ struct SettingsView:View Text(String(format: "%.1f", mouseSensitivity)) } } + CatTitle("Keyboard") + CatList() + { + CatItem("Sync layout with host") + { + Toggle("", isOn:$syncKeyboardLayout) + .frame(width:80) + } + CatItem("Layout switch hotkey") + { + MultiPicker(selection:$layoutSyncHotkey, options: + [ + Choice("Off", LayoutSyncHotkey.none), + Choice("Ctrl + Space", LayoutSyncHotkey.ctrlSpace), + Choice("Cmd + Space", LayoutSyncHotkey.cmdSpace), + Choice("Alt + Space", LayoutSyncHotkey.altSpace), + Choice("Alt + Shift", LayoutSyncHotkey.altShift) + ]) + } + } CatTitle("Graphics") CatList() { From 96ae81ef49fd7ddc4c653d6467ede42a03f6638f Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 02:12:10 +0300 Subject: [PATCH 03/40] Trackpad polish: natural scroll, inertia, sensitivity + best-effort key capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from real-device feedback. 1) Scroll direction was inverted relative to native iPad / macOS feel. Added `naturalScrolling` toggle (default ON, flips the sign so swipe-down moves content down). Direction is applied to both the live drag and the inertia tail so they stay consistent. 2) 2-finger trackpad scroll stopped dead the moment the fingers left the surface — felt jerky compared to the rest of iPadOS. Added a CADisplayLink- based momentum/inertia tail in handleTrackpadScroll: on .ended we sample `gestureRecognizer.velocity(in:)`, convert pts/sec → pts/frame at 60 Hz, and keep emitting sendWheelMsg with exponential decay until the per-frame delta falls below 0.5 px. Decay multiplier is linearly interpolated from the new "Inertia Strength" slider (0 → 0.80 multiplier ≈ ~150 ms glide, 1 → 0.98 ≈ ~2 s glide). Any new touch — finger, pencil, trackpad pointer — cancels the tail via stopScrollMomentum() in touchesBegan, matching native UIScrollView behaviour. Also tears down on viewWillDisappear. 3) Scroll feel is now configurable via two new sliders / two new toggles in the Interactivity section: - Scroll Sensitivity (decoupled from Mouse Sensitivity) - Natural Scrolling - Scroll Inertia - Inertia Strength 4) Best-effort iPadOS system-shortcut capture. iPadOS shell swallows Cmd+letter combos before they reach any app, which is why Cmd+A doesn't "select all" on the host across a Parsec session. Registered an explicit UIKeyCommand for each (letter|digit|punctuation) × (Cmd / Cmd+Shift / Cmd+Opt / Cmd+Ctrl / Opt / Opt+Shift) so the responder chain routes them to us via handleCapturedKey instead. On iOS 15+, each command sets `wantsPriorityOverSystemBehavior = true` to further suppress system text-input handling for the same chord. handleCapturedKey translates the matched chord into a modifier-press / key-press / key-release / modifier-release scancode sequence on the host. Uses sendVirtualKeyboardInput(text:, isOn:) for each step so the wrapper's auto-Shift logic doesn't double-press Shift (we manage modifiers explicitly from cmd.modifierFlags). Modifier releases are timed +60 ms past key release so the chord stays intact on the host (CParsec releases the normal key async at +20 ms). Honest constraint: Cmd+Space (Spotlight), Cmd+H (Home), Cmd+Tab (app switcher), Globe key shortcuts, and swipe-up-from-bottom gestures are wired below the responder chain in SpringBoard — no sandboxed iPad app can intercept them via public APIs. PR #64's Opt→Cmd remap on the host is the ecosystem-standard workaround for the most common Cmd+Tab case; not duplicated here. Toggle: SettingsHandler.captureSystemKeys (default ON), exposed as "Capture System Shortcuts" in the Keyboard section. Regression-safe: - `scrollMomentum = false` → no display link, ended-gesture path drops back to the original "stop dead" behaviour. - `captureSystemKeys = false` → falls through to `super.keyCommands` (nil by default), no UIKeyCommands registered, behaviour exactly as before. - `naturalScrolling = false` → original direction restored. - Scroll sensitivity defaults to 1.0 → same scaling as the previous shared-with-mouse path. --- OpenParsec/ParsecViewController.swift | 189 +++++++++++++++++++++++++- OpenParsec/SettingsHandler.swift | 18 +++ OpenParsec/SettingsView.swift | 32 +++++ 3 files changed, 236 insertions(+), 3 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 7ea69dc..6b047b4 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -35,6 +35,12 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var accumulatedScrollY: Float = 0.0 var lastScrollTranslation: CGPoint = .zero + // Momentum scrolling state — CADisplayLink keeps firing wheel messages + // with exponential decay after the user's fingers leave the trackpad. + var momentumDisplayLink: CADisplayLink? + var momentumVelocityX: Float = 0.0 + var momentumVelocityY: Float = 0.0 + // Layout sync — fires a hotkey at the host when the iPad's hardware-keyboard // input language changes (e.g. Caps Lock toggle on Magic Keyboard). var languageSync: LanguageSyncCoordinator? @@ -340,6 +346,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) stopLanguageSync() + stopScrollMomentum() } @@ -353,6 +360,9 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { // (see viewDidLoad), so those touches reach this override unobstructed. override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) + // Any new touch (finger, pencil, trackpad click) cancels inertial + // scrolling — matches native iOS UIScrollView behaviour. + stopScrollMomentum() for touch in touches where touch.type == .indirectPointer { accumulatedDeltaX = 0.0 accumulatedDeltaY = 0.0 @@ -546,15 +556,23 @@ extension ParsecViewController : UIGestureRecognizerDelegate { // allowedScrollTypesMask = .all and maximumNumberOfTouches = 0 in viewDidLoad, // so it only sees scroll-wheel / trackpad-scroll events, never finger touches. @objc func handleTrackpadScroll(_ gestureRecognizer: UIPanGestureRecognizer) { + // "Natural scrolling" — swipe-direction follows content, matching iPadOS + // / macOS default. Flip to false (in Settings) for classic mouse-wheel + // direction. Applied to both the live drag and the inertia tail. + let direction: Float = SettingsHandler.naturalScrolling ? -1.0 : 1.0 + let sensitivity = Float(SettingsHandler.scrollSensitivity) + switch gestureRecognizer.state { case .began: + // New scroll gesture aborts any ongoing inertia. + stopScrollMomentum() lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 case .changed: let translation = gestureRecognizer.translation(in: gestureRecognizer.view) - let deltaX = Float(translation.x - lastScrollTranslation.x) * mouseSensitivity - let deltaY = Float(translation.y - lastScrollTranslation.y) * mouseSensitivity + let deltaX = Float(translation.x - lastScrollTranslation.x) * sensitivity * direction + let deltaY = Float(translation.y - lastScrollTranslation.y) * sensitivity * direction lastScrollTranslation = translation accumulatedScrollX += deltaX accumulatedScrollY += deltaY @@ -565,7 +583,16 @@ extension ParsecViewController : UIGestureRecognizerDelegate { accumulatedScrollX -= Float(intX) accumulatedScrollY -= Float(intY) } - case .ended, .cancelled, .failed: + case .ended: + lastScrollTranslation = .zero + if SettingsHandler.scrollMomentum { + // velocity(in:) is in points/sec; convert to points/frame at 60 Hz. + let velocity = gestureRecognizer.velocity(in: gestureRecognizer.view) + momentumVelocityX = Float(velocity.x) / 60.0 * sensitivity * direction + momentumVelocityY = Float(velocity.y) / 60.0 * sensitivity * direction + startScrollMomentum() + } + case .cancelled, .failed: lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 @@ -574,6 +601,47 @@ extension ParsecViewController : UIGestureRecognizerDelegate { } } + // Inertia tail. Per-frame decay multiplier is mapped linearly from the + // user's "Inertia Strength" setting: 0 → 0.80 (snappy, ~150 ms), 1 → 0.98 + // (long glide, ~2 s). + private func startScrollMomentum() { + let threshold: Float = 0.5 + if abs(momentumVelocityX) < threshold && abs(momentumVelocityY) < threshold { + return + } + stopScrollMomentum() + let link = CADisplayLink(target: self, selector: #selector(scrollMomentumTick(_:))) + link.add(to: .main, forMode: .common) + momentumDisplayLink = link + } + + @objc private func scrollMomentumTick(_ link: CADisplayLink) { + accumulatedScrollX += momentumVelocityX + accumulatedScrollY += momentumVelocityY + let intX = Int32(accumulatedScrollX) + let intY = Int32(accumulatedScrollY) + if intX != 0 || intY != 0 { + CParsec.sendWheelMsg(x: intX, y: intY) + accumulatedScrollX -= Float(intX) + accumulatedScrollY -= Float(intY) + } + let strength = Float(SettingsHandler.scrollMomentumStrength) + let decay: Float = 0.80 + 0.18 * strength + momentumVelocityX *= decay + momentumVelocityY *= decay + let threshold: Float = 0.5 + if abs(momentumVelocityX) < threshold && abs(momentumVelocityY) < threshold { + stopScrollMomentum() + } + } + + private func stopScrollMomentum() { + momentumDisplayLink?.invalidate() + momentumDisplayLink = nil + momentumVelocityX = 0.0 + momentumVelocityY = 0.0 + } + @objc func handleSingleFingerTap(_ gestureRecognizer: UITapGestureRecognizer) { let location = gestureRecognizer.location(in:gestureRecognizer.view) @@ -896,6 +964,121 @@ extension ParsecViewController : UIKeyInput, UITextInputTraits { } +// MARK: - System-shortcut capture +// +// iPadOS shell normally swallows Cmd+letter combinations (and many Cmd+modifier +// chords) before they reach any app — that's why Cmd+A inside a Parsec session +// doesn't "select all" on the host, Cmd+S doesn't save, etc. +// +// Registering an explicit UIKeyCommand for each combination tells the responder +// chain to deliver them to us instead. On iOS 15+, setting +// `wantsPriorityOverSystemBehavior = true` further suppresses the system's own +// text-input handling for the same chord. +// +// What we CANNOT capture on iPadOS via public APIs (no app can): +// • Cmd+Space (Spotlight) +// • Cmd+H (Home) +// • Cmd+Tab (app switcher) +// • Globe key shortcut layer +// • Swipe-up-from-bottom (Home gesture) +// +// Those are wired below the responder chain in SpringBoard. The PR #64 +// approach (remap Opt → Cmd on the host) is the workaround the iPad +// ecosystem has settled on for the most common case; we don't duplicate it +// here because that PR is in flight. +extension ParsecViewController { + // Cached because keyCommands is queried each event and we register ~280 + // commands. Rebuilds itself the first time captureSystemKeys flips on + // during a session and the user re-enters the streaming view. + private static let _capturableCharset: String = "abcdefghijklmnopqrstuvwxyz0123456789-=[];',./`\\" + private static let _capturableModifierCombos: [UIKeyModifierFlags] = [ + .command, + [.command, .shift], + [.command, .alternate], + [.command, .control], + .alternate, + [.alternate, .shift] + ] + + func buildSystemCaptureKeyCommands() -> [UIKeyCommand] { + var commands: [UIKeyCommand] = [] + for char in Self._capturableCharset { + for mods in Self._capturableModifierCombos { + let cmd = UIKeyCommand( + input: String(char), + modifierFlags: mods, + action: #selector(handleCapturedKey(_:)) + ) + if #available(iOS 15.0, *) { + cmd.wantsPriorityOverSystemBehavior = true + } + commands.append(cmd) + } + } + // Cmd + special keys. Cmd+Space is registered but iPadOS still wins; + // the host won't see it. Leaving it in so behaviour matches if Apple + // ever loosens this. + for special in ["\t", " ", "\r", "`"] { + let cmd = UIKeyCommand( + input: special, + modifierFlags: .command, + action: #selector(handleCapturedKey(_:)) + ) + if #available(iOS 15.0, *) { + cmd.wantsPriorityOverSystemBehavior = true + } + commands.append(cmd) + } + return commands + } + + override var keyCommands: [UIKeyCommand]? { + guard SettingsHandler.captureSystemKeys else { return super.keyCommands } + return buildSystemCaptureKeyCommands() + } + + // Translates a UIKeyCommand into a modifier-aware scancode sequence for + // the host. We deliberately use sendVirtualKeyboardInput(text:, isOn:) + // instead of (text:) so the wrapper doesn't auto-add Shift — we're + // managing modifiers explicitly. + @objc func handleCapturedKey(_ cmd: UIKeyCommand) { + let mods = cmd.modifierFlags + + if mods.contains(.command) { CParsec.sendVirtualKeyboardInput(text: "LGUI", isOn: true) } + if mods.contains(.shift) { CParsec.sendVirtualKeyboardInput(text: "SHIFT", isOn: true) } + if mods.contains(.control) { CParsec.sendVirtualKeyboardInput(text: "CONTROL", isOn: true) } + if mods.contains(.alternate) { CParsec.sendVirtualKeyboardInput(text: "LALT", isOn: true) } + + guard let raw = cmd.input, !raw.isEmpty else { + releaseHeldModifiers(mods) + return + } + + let keyText: String + switch raw { + case "\t": keyText = "TAB" + case " ": keyText = "SPACE" + case "\r", "\n": keyText = "ENTER" + default: keyText = raw.uppercased() + } + + CParsec.sendVirtualKeyboardInput(text: keyText, isOn: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + CParsec.sendVirtualKeyboardInput(text: keyText, isOn: false) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in + self?.releaseHeldModifiers(mods) + } + } + + private func releaseHeldModifiers(_ mods: UIKeyModifierFlags) { + if mods.contains(.command) { CParsec.sendVirtualKeyboardInput(text: "LGUI", isOn: false) } + if mods.contains(.shift) { CParsec.sendVirtualKeyboardInput(text: "SHIFT", isOn: false) } + if mods.contains(.control) { CParsec.sendVirtualKeyboardInput(text: "CONTROL", isOn: false) } + if mods.contains(.alternate) { CParsec.sendVirtualKeyboardInput(text: "LALT", isOn: false) } + } +} + // MARK: - Language sync (Mac ↔ iPad keyboard layout) // // Goal: when the user switches the iPad's hardware-keyboard input language diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 811d988..bb934dc 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -9,6 +9,24 @@ struct SettingsHandler { @AppStorage("cursorMode") public static var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") public static var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") public static var mouseSensitivity: Double = 1.0 + @AppStorage("scrollSensitivity") public static var scrollSensitivity: Double = 1.0 + // When true (default), trackpad scroll direction matches what iPad/macOS + // call "natural scrolling" — swipe down moves content down. Flip to false + // if you want classic mouse-wheel direction. + @AppStorage("naturalScrolling") public static var naturalScrolling: Bool = true + // Inertia after the finger leaves the trackpad: CADisplayLink keeps + // firing wheel messages with exponential decay, so scrolls don't stop + // dead the moment you let go. + @AppStorage("scrollMomentum") public static var scrollMomentum: Bool = true + // 0.0 ≈ ~150 ms of glide; 1.0 ≈ ~2 s of long glide. Linear mapping into + // a per-frame decay multiplier in startScrollMomentum. + @AppStorage("scrollMomentumStrength") public static var scrollMomentumStrength: Double = 0.5 + // Best-effort iPadOS system-shortcut capture via UIKeyCommand registry + // with .wantsPriorityOverSystemBehavior (iOS 15+). Catches Cmd+letter + // combinations the iPad shell would otherwise eat. Cmd+Space, Cmd+H, + // Globe key, and swipe-up gestures stay system-level — those cannot be + // intercepted from a sandboxed iPad app. + @AppStorage("captureSystemKeys") public static var captureSystemKeys: Bool = true @AppStorage("noOverlay") public static var noOverlay: Bool = false @AppStorage("cursorScale") public static var hideStatusBar: Bool = true @AppStorage("rightClickPosition") public static var rightClickPosition: RightClickPosition = .firstFinger diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 2164ed9..72da275 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -11,6 +11,11 @@ struct SettingsView:View @AppStorage("cursorMode") var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") var mouseSensitivity: Double = 1.0 + @AppStorage("scrollSensitivity") var scrollSensitivity: Double = 1.0 + @AppStorage("naturalScrolling") var naturalScrolling: Bool = true + @AppStorage("scrollMomentum") var scrollMomentum: Bool = true + @AppStorage("scrollMomentumStrength") var scrollMomentumStrength: Double = 0.5 + @AppStorage("captureSystemKeys") var captureSystemKeys: Bool = true @AppStorage("noOverlay") var noOverlay: Bool = false @AppStorage("cursorScale") var hideStatusBar: Bool = true @AppStorage("rightClickPosition") var rightClickPosition: RightClickPosition = .firstFinger @@ -112,6 +117,28 @@ struct SettingsView:View .frame(width: 200) Text(String(format: "%.1f", mouseSensitivity)) } + CatItem("Scroll Sensitivity") + { + Slider(value: $scrollSensitivity, in:0.1...4, step:0.1) + .frame(width: 200) + Text(String(format: "%.1f", scrollSensitivity)) + } + CatItem("Natural Scrolling") + { + Toggle("", isOn:$naturalScrolling) + .frame(width:80) + } + CatItem("Scroll Inertia") + { + Toggle("", isOn:$scrollMomentum) + .frame(width:80) + } + CatItem("Inertia Strength") + { + Slider(value: $scrollMomentumStrength, in:0...1, step:0.05) + .frame(width: 200) + Text(String(format: "%.2f", scrollMomentumStrength)) + } } CatTitle("Keyboard") CatList() @@ -132,6 +159,11 @@ struct SettingsView:View Choice("Alt + Shift", LayoutSyncHotkey.altShift) ]) } + CatItem("Capture System Shortcuts") + { + Toggle("", isOn:$captureSystemKeys) + .frame(width:80) + } } CatTitle("Graphics") CatList() From 5d2bf2dd8419854e2e9f9bebe9ac59be4102cefb Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 13:58:08 +0300 Subject: [PATCH 04/40] Fix keyboard toolbar regression + improve scroll inertia + Mac-first hotkey labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues from real-device testing: 1) Keyboard toolbar appearing by default (regression from language sync). LanguageSyncCoordinator makes a hidden LanguageSyncTextField the first responder so UITextInputMode.currentInputModeDidChangeNotification keeps firing. But iOS walks the responder chain looking for inputAccessoryView — the chain hits ParsecViewController, which provides the OpenParsec keyboard toolbar (⌘ ⌃ ⌥ ⇧ F1-F12). Result: the toolbar showed on every connection without any user action. Fix: LanguageSyncTextField now returns its own non-nil (empty) UIView for inputAccessoryView, halting the chain walk before iOS reaches the VC's toolbar. The toolbar still appears when the user explicitly invokes showKeyboard() (3-finger tap or the on-screen button), via the existing yield/reclaim dance. Also cleared inputAssistantItem.leadingBarButtonGroups/trailingBarButtonGroups to suppress the iPad shortcuts bar (predictive text / cut/copy/paste chevrons) that hardware-keyboard text fields show by default. 2) Scroll inertia not visible even with toggle on. Root cause: iPad's trackpad applies its own short deceleration to scroll events while reporting them, so by the time the pan recognizer transitions to `.ended`, recognizer.velocity(in:) is near zero. The momentum tail was seeded with near-zero velocity → immediate stop. Fix: track translation deltas with timestamps during `.changed` and remember the peak velocity. Seed momentumVelocityX/Y from peak at `.ended` instead of recognizer.velocity. Added a minimum-speed gate (80 pts/s) so a slow drag doesn't trigger an unwanted tail. 3) Layout sync hotkey labels too Windows-centric. Reordered the picker — Ctrl+Space (macOS default) is now first and explicitly labeled as such. Cmd+Space / Opt+Space follow with Mac-style ⌃ ⌥ ⌘ glyphs. Alt+Shift is kept but labeled "(Windows)" so users know when it's relevant. Off moved to the bottom (least-used). Regression-safe: - Hidden field is still first responder during streaming → notification still fires for language changes. - showKeyboard() flow (3-finger tap, on-screen button) untouched — the toolbar still appears when explicitly invoked. - scrollMomentum = false → no peak tracking is consumed, behaviour matches the previous "stop dead" path exactly. --- OpenParsec/ParsecViewController.swift | 76 ++++++++++++++++++++++++--- OpenParsec/SettingsView.swift | 10 ++-- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 6b047b4..b2cc9aa 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -41,6 +41,16 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var momentumVelocityX: Float = 0.0 var momentumVelocityY: Float = 0.0 + // iPad's trackpad already applies its own short deceleration to scroll + // events while reporting them; by the time `.ended` fires, the gesture + // recognizer's `velocity(in:)` is near zero. To produce a noticeable + // macOS-style inertia tail we sample translation deltas during `.changed` + // and remember the PEAK velocity to seed the inertia at `.ended`. + private var lastScrollChangeTime: CFTimeInterval = 0 + private var lastScrollChangeTranslation: CGPoint = .zero + private var peakScrollVelocityX: CGFloat = 0 + private var peakScrollVelocityY: CGFloat = 0 + // Layout sync — fires a hotkey at the host when the iPad's hardware-keyboard // input language changes (e.g. Caps Lock toggle on Magic Keyboard). var languageSync: LanguageSyncCoordinator? @@ -569,10 +579,30 @@ extension ParsecViewController : UIGestureRecognizerDelegate { lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 + lastScrollChangeTime = 0 + peakScrollVelocityX = 0 + peakScrollVelocityY = 0 case .changed: let translation = gestureRecognizer.translation(in: gestureRecognizer.view) let deltaX = Float(translation.x - lastScrollTranslation.x) * sensitivity * direction let deltaY = Float(translation.y - lastScrollTranslation.y) * sensitivity * direction + + // Sample peak velocity for inertia seeding. We can't trust + // gestureRecognizer.velocity(in:) at `.ended` because iPad already + // applies its own deceleration — by then velocity is near zero. + let now = CACurrentMediaTime() + if lastScrollChangeTime > 0 { + let dt = now - lastScrollChangeTime + if dt > 0.001 { + let vX = (translation.x - lastScrollChangeTranslation.x) / CGFloat(dt) + let vY = (translation.y - lastScrollChangeTranslation.y) / CGFloat(dt) + if abs(vX) > abs(peakScrollVelocityX) { peakScrollVelocityX = vX } + if abs(vY) > abs(peakScrollVelocityY) { peakScrollVelocityY = vY } + } + } + lastScrollChangeTime = now + lastScrollChangeTranslation = translation + lastScrollTranslation = translation accumulatedScrollX += deltaX accumulatedScrollY += deltaY @@ -585,17 +615,26 @@ extension ParsecViewController : UIGestureRecognizerDelegate { } case .ended: lastScrollTranslation = .zero - if SettingsHandler.scrollMomentum { - // velocity(in:) is in points/sec; convert to points/frame at 60 Hz. - let velocity = gestureRecognizer.velocity(in: gestureRecognizer.view) - momentumVelocityX = Float(velocity.x) / 60.0 * sensitivity * direction - momentumVelocityY = Float(velocity.y) / 60.0 * sensitivity * direction + // Use peak velocity from .changed phase (not recognizer.velocity at + // .ended — that's already decayed by iPad). Only trigger if user + // scrolled fast enough to expect inertia. + let minSeedSpeed: CGFloat = 80 + if SettingsHandler.scrollMomentum, + abs(peakScrollVelocityX) > minSeedSpeed || abs(peakScrollVelocityY) > minSeedSpeed { + momentumVelocityX = Float(peakScrollVelocityX) / 60.0 * sensitivity * direction + momentumVelocityY = Float(peakScrollVelocityY) / 60.0 * sensitivity * direction startScrollMomentum() } + lastScrollChangeTime = 0 + peakScrollVelocityX = 0 + peakScrollVelocityY = 0 case .cancelled, .failed: lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 + lastScrollChangeTime = 0 + peakScrollVelocityX = 0 + peakScrollVelocityY = 0 default: break } @@ -1166,14 +1205,37 @@ final class LanguageSyncTextField: UITextField { // Returning an empty UIView for inputView suppresses the on-screen keyboard // while the field is first responder, even without a connected hardware - // keyboard. Returning the field's existing inputAccessoryView (none) keeps - // the accessory chrome empty too. + // keyboard. private let _emptyInputView = UIView() override var inputView: UIView? { get { return _emptyInputView } set { /* ignore — we want the soft kb suppressed unconditionally */ } } + // Critical: when this hidden field is first responder, iOS walks the + // responder chain looking for an inputAccessoryView. The chain leads up + // to ParsecViewController, which provides the OpenParsec keyboard + // toolbar (⌘ ⌃ ⌥ ⇧ F1-F12 etc.). Result: the toolbar would appear on + // every connection without any user action. Returning our own non-nil + // (empty) accessory view halts the chain walk and keeps the toolbar + // hidden until the user explicitly invokes showKeyboard(). + private let _emptyAccessoryView = UIView(frame: .zero) + override var inputAccessoryView: UIView? { + return _emptyAccessoryView + } + + override init(frame: CGRect) { + super.init(frame: frame) + // Suppress the iPad shortcuts bar (predictive text / cut/copy/paste + // chevrons that hardware-keyboard text fields show by default). + inputAssistantItem.leadingBarButtonGroups = [] + inputAssistantItem.trailingBarButtonGroups = [] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // By NOT calling super, we prevent UITextField's legacy text-input path // from consuming printable keys via UIKeyInput.insertText. Same trick // Moonlight uses in StreamView.m pressesBegan/pressesEnded. diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 72da275..1d543c0 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -152,11 +152,11 @@ struct SettingsView:View { MultiPicker(selection:$layoutSyncHotkey, options: [ - Choice("Off", LayoutSyncHotkey.none), - Choice("Ctrl + Space", LayoutSyncHotkey.ctrlSpace), - Choice("Cmd + Space", LayoutSyncHotkey.cmdSpace), - Choice("Alt + Space", LayoutSyncHotkey.altSpace), - Choice("Alt + Shift", LayoutSyncHotkey.altShift) + Choice("⌃ + Space (macOS default)", LayoutSyncHotkey.ctrlSpace), + Choice("⌘ + Space", LayoutSyncHotkey.cmdSpace), + Choice("⌥ + Space", LayoutSyncHotkey.altSpace), + Choice("Alt + Shift (Windows)", LayoutSyncHotkey.altShift), + Choice("Off", LayoutSyncHotkey.none) ]) } CatItem("Capture System Shortcuts") From 546462972e97faf0b5d161d8de7e122dc4e266fc Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 14:02:43 +0300 Subject: [PATCH 05/40] Persist last-used display + actually-working in-session resolution change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported issues from device testing. Display selection wasn't persisted. Picker in ParsecView (lines 271-287) listed host displays from DataManager.model.displayConfigs (populated via user-data event 12 from the host) and forwarded the choice via updateHostVideoConfig — but nothing remembered the choice. User had to re-pick on every reconnect. Fix: - SettingsHandler.savedDisplayOutput @AppStorage (String, default ""). - changeDisplay writes the picked id there ("none" is the "Auto" sentinel and isn't worth remembering, so it clears the saved value instead). - In ParsecSDKBridge.handleUserDataEvent case 12 (display list arrival), if the saved id is still in the newly-arrived list, restore it automatically and re-fire updateHostVideoConfig. In-overlay "Resolution" picker had no effect. The "Resolution" menu in the in-stream overlay wrote new dimensions into DataManager.model and called updateHostVideoConfig, which sends setVideoConfig (user-data id 11) to the host. The Parsec host honours bitrate / constantFps / output via that user-data, but the resolution field is advisory — it's only read at ParsecClientConnect time. And ParsecSDKBridge.applyConfig() further hardcoded resolutionX/Y to 0 ("use host default"), which silently overwrote any value connect() had set seconds earlier. Fix (option a from the audit — reconnect on resolution change): - CParsec.lastConnectedPeerID now tracks the peer we last successfully connected to (set in static connect()). - changeResolution updates SettingsHandler.resolution + model dims, then cleanly disconnects and reconnects via the saved peer ID after a 300 ms debounce. applyConfig is re-issued after the reconnect. - ParsecSDKBridge.disconnect() resets didSetResolution = false so the first case-11 echo after the reconnect re-pushes our desired value instead of clobbering it with whatever the host happens to report. - ParsecSDKBridge.applyConfig() now reads SettingsHandler.resolution width/height into both video.0 and video.1 instead of hardcoding 0. Regression-safe: - Fallback in changeResolution if lastConnectedPeerID is nil (shouldn't happen mid-session): falls back to the old updateHostVideoConfig path so at least the bitrate/output side of setVideoConfig still gets pushed. - displayConfigs auto-restore is silent (no UI surprise) — if the saved display id is no longer in the host's list, nothing happens. - ParsecResolution.host (= 0 width/height) is preserved as a valid choice in applyConfig — the user can still pick "Host Resolution" to leave things on host default. --- OpenParsec/CParsec.swift | 11 +++++++++-- OpenParsec/ParsecSDKBridge.swift | 28 +++++++++++++++++++++------ OpenParsec/ParsecView.swift | 33 ++++++++++++++++++++++++++++++-- OpenParsec/SettingsHandler.swift | 3 +++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/OpenParsec/CParsec.swift b/OpenParsec/CParsec.swift index d1fc736..918bf72 100644 --- a/OpenParsec/CParsec.swift +++ b/OpenParsec/CParsec.swift @@ -185,6 +185,12 @@ class CParsec static var parsecImpl: ParsecService! + // Remember the last peer we connected to so callers like changeResolution + // can disconnect + reconnect with new ParsecClientConfig (resolution can + // only be changed via a fresh ParsecClientConnect — the in-session + // setVideoConfig user-data event does not move resolution on the host). + public static var lastConnectedPeerID: String? + static func initialize() { parsecImpl = ParsecSDKBridge() @@ -192,12 +198,13 @@ class CParsec static func destroy() { - + } static func connect(_ peerID: String) -> ParsecStatus { - parsecImpl.connect(peerID) + lastConnectedPeerID = peerID + return parsecImpl.connect(peerID) } static func disconnect() diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index e543962..cac648f 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -138,11 +138,15 @@ class ParsecSDKBridge: ParsecService } func disconnect() { - + audio_clear(&_audio) ParsecClientDisconnect(_parsec) backgroundTaskRunning = false - + // Reset so the next case-11 echo after a reconnect re-pushes our + // desired resolution instead of clobbering it with whatever the host + // happens to advertise. + didSetResolution = false + ParsecBackgroundManager.shared.connectionDidEnd() } @@ -239,6 +243,14 @@ class ParsecSDKBridge: ParsecService let config = try decoder.decode(Array.self, from: Data(bytesNoCopy: pointer!, count: strlen(pointer!), deallocator: .none)) DispatchQueue.main.async { DataManager.model.displayConfigs = config + // If the user picked a display on a previous session and + // the host still has it, restore the selection silently. + let saved = SettingsHandler.savedDisplayOutput + if !saved.isEmpty, saved != "none", + config.contains(where: { $0.id == saved }) { + DataManager.model.output = saved + self.updateHostVideoConfig() + } } } catch { print("error while parsing user data: \(error.localizedDescription)") @@ -298,15 +310,19 @@ class ParsecSDKBridge: ParsecService var parsecClientCfg = ParsecClientConfig() + // Preserve the user's chosen resolution. The previous code hardcoded + // 0/0 (= "use host default"), which silently overwrote whatever + // connect() had set, making it look like the in-overlay Resolution + // picker did nothing. parsecClientCfg.video.0.decoderIndex = 1 - parsecClientCfg.video.0.resolutionX = 0 - parsecClientCfg.video.0.resolutionY = 0 + parsecClientCfg.video.0.resolutionX = Int32(SettingsHandler.resolution.width) + parsecClientCfg.video.0.resolutionY = Int32(SettingsHandler.resolution.height) parsecClientCfg.video.0.decoderCompatibility = SettingsHandler.decoderCompatibility parsecClientCfg.video.0.decoderH265 = SettingsHandler.decoder == .h265 parsecClientCfg.video.1.decoderIndex = 1 - parsecClientCfg.video.1.resolutionX = 0 - parsecClientCfg.video.1.resolutionY = 0 + parsecClientCfg.video.1.resolutionX = Int32(SettingsHandler.resolution.width) + parsecClientCfg.video.1.resolutionY = Int32(SettingsHandler.resolution.height) parsecClientCfg.video.1.decoderCompatibility = SettingsHandler.decoderCompatibility parsecClientCfg.video.1.decoderH265 = SettingsHandler.decoder == .h265 diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index ed17bb9..5da10ee 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -471,11 +471,36 @@ struct ParsecView: View } func changeResolution(res: ParsecResolution) { - SettingsHandler.resolution = res + SettingsHandler.resolution = res DispatchQueue.main.async { DataManager.model.resolutionX = res.width DataManager.model.resolutionY = res.height - CParsec.updateHostVideoConfig() + } + + // Parsec's host honours bitrate / FPS / output via setVideoConfig + // user-data, but NOT resolution — that field is only read at + // ParsecClientConnect time. To actually change the streaming + // resolution we have to disconnect + reconnect with the new + // ParsecClientConfig. If we don't have a peer to reconnect to + // (shouldn't happen mid-session), fall back to just pushing the + // user-data update. + guard let peerID = CParsec.lastConnectedPeerID else { + DispatchQueue.main.async { + CParsec.updateHostVideoConfig() + } + return + } + + DispatchQueue.main.async { + self.parsecViewController.glkView.cleanUp() + CParsec.disconnect() + } + // Brief pause to let the SDK fully tear down before re-issuing + // ParsecClientConnect with the new resolution. 300 ms matches the + // debounce we already use elsewhere. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + _ = CParsec.connect(peerID) + CParsec.applyConfig() } } @@ -515,6 +540,10 @@ struct ParsecView: View func changeDisplay(displayId: String) { DispatchQueue.main.async { DataManager.model.output = displayId + // Persist so the next connect can auto-restore this choice once + // the host enumerates displays (user-data event 12). "none" is + // the "Auto" pseudo-id and isn't worth remembering. + SettingsHandler.savedDisplayOutput = (displayId == "none") ? "" : displayId CParsec.updateHostVideoConfig() } } diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index bb934dc..7310a57 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -45,5 +45,8 @@ struct SettingsHandler { @AppStorage("savedZoomEnabled") public static var savedZoomEnabled: Bool = false @AppStorage("savedConstantFps") public static var savedConstantFps: Bool = false @AppStorage("savedMuted") public static var savedMuted: Bool = false + // Remember which display the user picked last; restored on the next + // connect once the host enumerates its displays (user-data event 12). + @AppStorage("savedDisplayOutput") public static var savedDisplayOutput: String = "" } From 54a96d09c7d48e00d9747eddd213521065539698 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 14:10:41 +0300 Subject: [PATCH 06/40] ci: retrigger workflow From de86645e39778ad3f298585d1feae73b77ee546d Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 16:24:48 +0300 Subject: [PATCH 07/40] Apply audit P0/P1/P2/P3: fix reconnect race, cache keyCommands, gate sends, polish UX, low-latency mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acts on findings from a three-agent audit of network latency, resolution- change UX, and bugs/regressions in the last batch of commits. P0 (blockers — would break audio/cursor after a resolution change): * ParsecSDKBridge.connect() now sets backgroundTaskRunning = true at the top, before startBackgroundTask() spawns the two `while backgroundTaskRunning` poll loops. Previously disconnect() set it false and the next reconnect's freshly-spawned loops read false on entry and exited immediately — leaving the new session with no audio callbacks, no cursor image updates, no user-data events. * keyCommands now caches the 286-element registry in a static var instead of rebuilding it on every iOS query (which happens on every key event and every first-responder change). Comment from the prior commit claimed caching but the implementation did not. * Every input-sending method on ParsecSDKBridge gates on backgroundTaskRunning: sendMouseMessage / sendMouseClickMessage / sendMouseDelta / sendMousePosition / sendMouseRelativeMove / sendKeyboardMessage / sendVirtualKeyboardInput (both variants) / sendGameControllerButtonMessage / sendGameControllerAxisMessage / sendGameControllerUnplugMessage / sendWheelMsg. Otherwise the scroll- momentum CADisplayLink, ongoing touchesMoved, pressesBegan etc. could fire ParsecClientSendMessage against a torn-down session during the 300 ms blackout in changeResolution — at best a wasted message, at worst a NULL deref deep in the SDK. * Also fixed the LanguageSyncTextField.inputAccessoryView override that was declared read-only (UIResponder's property is mutable; Swift won't override mutable with get-only). Added a no-op setter. This actually caused the build that finally ran post-outage to fail. P1 (UX / correctness fixes that ride along with the reconnect path): * ParsecView now owns an `isReconfiguring` @State. While true: - ParsecStatusBar.poll() early-returns (otherwise the 0.2 s timer fires "Disconnected (code X)" alert during the deliberate gap). - A "Switching resolution…" overlay is rendered with zIndex(3). - The GLKViewController is paused (`isPaused = true`) so the framebuffer keeps the last decoded frame instead of going black — the single biggest visual improvement to the gap. * The reconnect debounce dropped from 300 ms to 100 ms (combined with the 20 ms drain sleep inside ParsecSDKBridge.disconnect() this still covers the worst-case poll-loop iteration). Net user-visible gap with the overlay is ~600 ms including first-frame arrival. * Dropped the redundant applyConfig() call right after connect() in changeResolution — connect() already installs the fresh ParsecClientConfig and applyConfig() against a just-negotiated session was racy. * LanguageSyncCoordinator no longer fires a spurious Ctrl+Space on session start. The initial lastLanguage seed is deferred to the next main-runloop tick (textInputMode is sometimes still nil immediately after becomeFirstResponder), and a hasSeenInitialLanguage gate swallows the first observed change without firing the hotkey. Also added a 150 ms debounce so rapid Caps-Lock taps can't interleave modifier press/release on the host. * ParsecSDKBridge.handleUserDataEvent case 12 now restores the saved display only ONCE per session via a didRestoreSavedDisplay flag — the host re-advertises displays on sleep/wake/hot-plug and the previous version would re-fire updateHostVideoConfig each time, causing brief re-encode flicker. P2 (latency reductions): * Default preferredFramesPerSecond changed from 60 to 0 ("device max"). On a 120 Hz ProMotion iPad this halves worst-case present latency (~16 ms → ~8 ms). Mirrors default in SettingsView's @AppStorage. * New "Low Latency Mode" toggle in Settings → Graphics. When on, flips preferredFramesPerSecond = 0, decoder = h265, noOverlay = true. Also gates the captured-key path's artificial 20 ms / 60 ms hold-times: in Low Latency Mode the keyup + modifier release fire synchronously, saving ~80 ms on every Cmd-shortcut. (Outside Low Latency the old asyncAfter path is kept for safety with finicky apps.) * touchesMoved / handlePanGesture mouse-delta accumulator now uses .rounded(.toNearestOrAwayFromZero) before truncating to Int32. Previously Int32() silently truncated, so slow finger movements at low sensitivity swallowed events until a whole pixel accumulated ("stickiness" in text selection / fine UI). Sub-pixel residual is still carried. * startBackgroundTask now scales the SDK poll timeout to the configured FPS (8 ms at 120 Hz, 16 ms at 60 Hz) and runs on DispatchQueue.global at .userInteractive QoS instead of default — appropriate for the audio-callback / cursor-update / event hot path. * ParsecGLKRenderer.glkView(_:drawIn:) now gates the PictureInPictureManager.captureFrame call on isPiPActive || isStarting. Previously it ran on every frame even with PiP inactive, wasting a glReadPixels stall on the critical render path. P3 (polish): * CParsec.disconnect() clears lastConnectedPeerID. Doesn't break the reconnect dance (changeResolution captures the peer locally before calling disconnect), but stops stale state from leaking into future flows. * handleTrackpadScroll early-returns at scroll-pan .changed when scrollView.zoomScale > 1.0 — otherwise zoomed-in trackpad scrolling scrolled BOTH the local view and the host, double-panning. * Peak velocity for scroll inertia is reset if there's been a >200 ms pause since the last .changed (handles "lift, pause, drag again" patterns so a slow second drag doesn't inherit the peak from the first one). * Added touchesEnded / touchesCancelled overrides that zero accumulatedDeltaX/Y for .indirectPointer touches — prevents residual drift accumulating across gestures at low sensitivity. * disconnect() now sleeps 20 ms after ParsecClientDisconnect so the old poll loops definitely exit before connect() spawns new ones, eliminating a brief window of 2× poll rate. --- OpenParsec/CParsec.swift | 5 ++ OpenParsec/ParsecGLKRenderer.swift | 6 +- OpenParsec/ParsecSDKBridge.swift | 111 +++++++++++++++++------ OpenParsec/ParsecView.swift | 73 +++++++++++++-- OpenParsec/ParsecViewController.swift | 122 +++++++++++++++++++++++--- OpenParsec/SettingsHandler.swift | 7 +- OpenParsec/SettingsView.swift | 17 +++- 7 files changed, 288 insertions(+), 53 deletions(-) diff --git a/OpenParsec/CParsec.swift b/OpenParsec/CParsec.swift index 918bf72..f5b33a7 100644 --- a/OpenParsec/CParsec.swift +++ b/OpenParsec/CParsec.swift @@ -209,6 +209,11 @@ class CParsec static func disconnect() { + // Clear the peer-ID memo on any disconnect path; changeResolution sets + // it again right before calling connect(), so the reconnect dance is + // unaffected — but a user-initiated disconnect (close stream button, + // app background, etc.) should leave us with no stale peer. + lastConnectedPeerID = nil parsecImpl.disconnect() } diff --git a/OpenParsec/ParsecGLKRenderer.swift b/OpenParsec/ParsecGLKRenderer.swift index 5a76fb6..4d1ea69 100644 --- a/OpenParsec/ParsecGLKRenderer.swift +++ b/OpenParsec/ParsecGLKRenderer.swift @@ -48,7 +48,11 @@ class ParsecGLKRenderer:NSObject, GLKViewDelegate, GLKViewControllerDelegate CParsec.renderGLFrame(timeout: timeout) - if #available(iOS 15.0, *) { + // Only run PiP frame capture while PiP is actually active (or about + // to be). Previously this ran on every frame even with PiP off, + // wasting a glReadPixels stall on the critical render path. + if #available(iOS 15.0, *), + PictureInPictureManager.shared.isPiPActive || PictureInPictureManager.shared.isStarting { PictureInPictureManager.shared.captureFrame( viewWidth: GLsizei(view.drawableWidth), viewHeight: GLsizei(view.drawableHeight), diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index cac648f..e64dd9c 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -60,15 +60,23 @@ class ParsecSDKBridge: ParsecService private let _audioPtr: UnsafeRawPointer private var isVirtualShiftOn = false - + public var clientWidth: Float = 1920 public var clientHeight: Float = 1080 - + public var netProtocol: Int32 = 1 public var mediaContainer: Int32 = 0 public var pngCursor: Bool = false + // Doubles as a gate for outgoing input messages: while false (between an + // explicit disconnect and the next connect, including the brief gap in + // changeResolution), every send* method below early-returns so we don't + // fire ParsecClientSendMessage into a disconnected client. var backgroundTaskRunning = true var didSetResolution = false + // Restored once per session in handleUserDataEvent case 12 so display + // hot-plug / sleep-wake echoes don't keep re-firing updateHostVideoConfig + // and causing momentary re-encode flicker. + var didRestoreSavedDisplay = false public var mouseInfo = MouseInfo() @@ -106,6 +114,14 @@ class ParsecSDKBridge: ParsecService } func connect(_ peerID: String) -> ParsecStatus { + // CRITICAL: disconnect() set this to false to drain the poll loops. + // If we don't flip it back to true here, the new poll loops spawned + // by startBackgroundTask() below will read false on their first + // iteration and exit immediately — leaving the session with no audio + // callbacks, no cursor updates, and no user-data events for the rest + // of its lifetime. Also acts as the "sending allowed" gate for input + // messages, so flip it before any input could possibly fire. + backgroundTaskRunning = true var parsecClientCfg = ParsecClientConfig() parsecClientCfg.video.0.decoderIndex = 1 @@ -146,6 +162,15 @@ class ParsecSDKBridge: ParsecService // desired resolution instead of clobbering it with whatever the host // happens to advertise. didSetResolution = false + didRestoreSavedDisplay = false + + // Give the two `while backgroundTaskRunning` loops in + // startBackgroundTask() one full poll-timeout to notice the flag + // flip and exit. Without this drain, a fast reconnect() can spawn + // fresh loops while the old ones are still inside ParsecClientPollAudio + // / ParsecClientPollEvents, briefly doubling the poll rate and + // causing audio glitches. + Thread.sleep(forTimeInterval: 0.02) ParsecBackgroundManager.shared.connectionDidEnd() } @@ -243,13 +268,19 @@ class ParsecSDKBridge: ParsecService let config = try decoder.decode(Array.self, from: Data(bytesNoCopy: pointer!, count: strlen(pointer!), deallocator: .none)) DispatchQueue.main.async { DataManager.model.displayConfigs = config - // If the user picked a display on a previous session and - // the host still has it, restore the selection silently. - let saved = SettingsHandler.savedDisplayOutput - if !saved.isEmpty, saved != "none", - config.contains(where: { $0.id == saved }) { - DataManager.model.output = saved - self.updateHostVideoConfig() + // Restore the saved display ONCE per session. The host + // can re-advertise its display list multiple times mid- + // stream (sleep/wake, display hot-plug); without this + // gate, every echo would re-fire updateHostVideoConfig + // and cause a brief re-encode flicker. + if !self.didRestoreSavedDisplay { + self.didRestoreSavedDisplay = true + let saved = SettingsHandler.savedDisplayOutput + if !saved.isEmpty, saved != "none", + config.contains(where: { $0.id == saved }) { + DataManager.model.output = saved + self.updateHostVideoConfig() + } } } } catch { @@ -334,11 +365,18 @@ class ParsecSDKBridge: ParsecService ParsecClientSetConfig(_parsec, &parsecClientCfg); } + // All outgoing-input methods gate on backgroundTaskRunning. False means + // we're between an explicit disconnect and the next connect (including + // the gap inside changeResolution's reconnect dance) — sending into a + // disconnected SDK is at best a wasted message and at worst a NULL deref + // inside ParsecClientSendMessage. + func sendMouseMessage(_ button:ParsecMouseButton, _ x:Int32, _ y:Int32, _ pressed: Bool) { + guard backgroundTaskRunning else { return } // Send the mouse position sendMousePosition(x, y) - + // Send the mouse button state var buttonMessage = ParsecMessage() buttonMessage.type = MESSAGE_MOUSE_BUTTON @@ -346,22 +384,24 @@ class ParsecSDKBridge: ParsecService buttonMessage.mouseButton.pressed = pressed ParsecClientSendMessage(_parsec, &buttonMessage) } - + func sendMouseClickMessage(_ button:ParsecMouseButton, _ pressed: Bool) { + guard backgroundTaskRunning else { return } var buttonMessage = ParsecMessage() buttonMessage.type = MESSAGE_MOUSE_BUTTON buttonMessage.mouseButton.button = button buttonMessage.mouseButton.pressed = pressed ParsecClientSendMessage(_parsec, &buttonMessage) } - + func sendMouseDelta(_ dx: Int32, _ dy: Int32) { + guard backgroundTaskRunning else { return } if mouseInfo.mousePositionRelative { sendMouseRelativeMove(dx, dy) } else { sendMousePosition(mouseInfo.mouseX + dx, mouseInfo.mouseY + dy) } - + } static func clamp(_ value: T, minValue: T, maxValue: T) -> T where T : Comparable { return min(max(value, minValue), maxValue) @@ -369,6 +409,7 @@ class ParsecSDKBridge: ParsecService func sendMousePosition(_ x:Int32, _ y:Int32) { + guard backgroundTaskRunning else { return } mouseInfo.mouseX = ParsecSDKBridge.clamp(x, minValue: 0, maxValue: Int32(self.clientWidth)) mouseInfo.mouseY = ParsecSDKBridge.clamp(y, minValue: 0, maxValue: Int32(self.clientHeight)) var motionMessage = ParsecMessage() @@ -377,9 +418,10 @@ class ParsecSDKBridge: ParsecService motionMessage.mouseMotion.y = y ParsecClientSendMessage(_parsec, &motionMessage) } - + func sendMouseRelativeMove(_ dx:Int32, _ dy:Int32) { + guard backgroundTaskRunning else { return } var motionMessage = ParsecMessage() motionMessage.type = MESSAGE_MOUSE_MOTION motionMessage.mouseMotion.x = dx @@ -419,8 +461,9 @@ class ParsecSDKBridge: ParsecService } func sendVirtualKeyboardInput(text: String) { + guard backgroundTaskRunning else { return } let (keyCode, useShift) = getKeyCodeByText(text: text) - + guard let keyCode else { return } @@ -444,8 +487,9 @@ class ParsecSDKBridge: ParsecService } func sendVirtualKeyboardInput(text: String, isOn: Bool) { + guard backgroundTaskRunning else { return } let (keyCode, _) = getKeyCodeByText(text: text) - + guard let keyCode else { return } @@ -464,10 +508,11 @@ class ParsecSDKBridge: ParsecService func sendKeyboardMessage(event:KeyBoardKeyEvent) { + guard backgroundTaskRunning else { return } if event.input == nil { return } - + var keyboardMessagePress = ParsecMessage() keyboardMessagePress.type = MESSAGE_KEYBOARD keyboardMessagePress.keyboard.code = ParsecKeycode(UInt32(KeyCodeTranslators.uiKeyCodeToInt(key: event.input?.keyCode ?? UIKeyboardHIDUsage.keyboardErrorUndefined))) @@ -477,6 +522,7 @@ class ParsecSDKBridge: ParsecService func sendGameControllerButtonMessage(controllerId: UInt32, _ button:ParsecGamepadButton, pressed: Bool) { + guard backgroundTaskRunning else { return } var pmsg = ParsecMessage() pmsg.type = MESSAGE_GAMEPAD_BUTTON pmsg.gamepadButton.id = controllerId @@ -497,6 +543,7 @@ class ParsecSDKBridge: ParsecService func sendGameControllerAxisMessage(controllerId: UInt32, _ button:ParsecGamepadAxis, _ value: Int16) { + guard backgroundTaskRunning else { return } var pmsg = ParsecMessage() pmsg.type = MESSAGE_GAMEPAD_AXIS pmsg.gamepadAxis.id = controllerId @@ -507,6 +554,7 @@ class ParsecSDKBridge: ParsecService func sendGameControllerUnplugMessage(controllerId: UInt32) { + guard backgroundTaskRunning else { return } var pmsg = ParsecMessage() pmsg.type = MESSAGE_GAMEPAD_UNPLUG; pmsg.gamepadUnplug.id = controllerId; @@ -514,6 +562,7 @@ class ParsecSDKBridge: ParsecService } func sendWheelMsg(x: Int32, y: Int32) { + guard backgroundTaskRunning else { return } var pmsg = ParsecMessage() pmsg.type = MESSAGE_MOUSE_WHEEL; pmsg.mouseWheel.x = x @@ -522,26 +571,32 @@ class ParsecSDKBridge: ParsecService } func startBackgroundTask(){ - - + // Scale poll timeout to the configured render fps so the audio and + // event threads don't sit blocked inside the SDK longer than a + // frame budget. On 120 Hz iPads that's 8 ms; on 60 Hz, 16 ms. + let fps = SettingsHandler.preferredFramesPerSecond == 0 + ? UIScreen.main.maximumFramesPerSecond + : SettingsHandler.preferredFramesPerSecond + let pollTimeout = UInt32(max(1000 / fps, 8)) + let item1 = DispatchWorkItem { while self.backgroundTaskRunning { - self.pollAudio() + self.pollAudio(timeout: pollTimeout) } - } let item2 = DispatchWorkItem { while self.backgroundTaskRunning { - self.pollEvent() - - + self.pollEvent(timeout: pollTimeout) } - } - let mainQueue = DispatchQueue.global() - mainQueue.async(execute: item1) - mainQueue.async(execute: item2) + // .userInteractive is the right QoS for remote-desktop input/event + // dispatch — these threads gate audio callbacks and cursor updates. + // Previously used unspecified (.default) which sometimes coalesces + // under system load. + let pollQueue = DispatchQueue.global(qos: .userInteractive) + pollQueue.async(execute: item1) + pollQueue.async(execute: item2) } func sendUserData(type: ParsecUserDataType, message: Data) { diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 5da10ee..1e993c4 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -4,6 +4,7 @@ import Foundation import AVFoundation struct ParsecStatusBar : View { + @Binding var isReconfiguring : Bool @Binding var showMenu : Bool @State var metricInfo: String = "Loading..." @Binding var showDCAlert: Bool @@ -43,10 +44,42 @@ struct ParsecStatusBar : View { .onReceive(timer) { p in poll() } + + // Reconnecting overlay — only visible during changeResolution's + // disconnect→reconnect dance. Mirrors MainView's connecting overlay. + if isReconfiguring { + ZStack { + Rectangle() + .fill(Color.black.opacity(0.45)) + .edgesIgnoringSafeArea(.all) + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.4) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + Text("Switching resolution…") + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color("BackgroundPrompt").opacity(0.85)) + ) + } + .zIndex(3) + } } - + func poll() { + // While we're deliberately disconnecting/reconnecting for a resolution + // change, getStatusEx will briefly report a non-OK status. Don't pop + // the "Disconnected" alert during that window — it's an intentional + // gap with an overlay in front of it. + if isReconfiguring + { + return + } if showDCAlert { return // no need to poll if we aren't connected anymore @@ -113,6 +146,10 @@ struct ParsecView: View @State var showKeyboard: Bool = false @State var zoomEnabled: Bool = false + // True while changeResolution is in its disconnect→reconnect dance. + // Suppresses the status-bar disconnect alert and shows a small + // "Switching resolution…" overlay so the user knows what's happening. + @State var isReconfiguring: Bool = false @State var muted: Bool = false @State var preferH265: Bool = true @@ -162,7 +199,7 @@ struct ParsecView: View .zIndex(1) .prefersPersistentSystemOverlaysHidden() - ParsecStatusBar(showMenu: $showMenu, showDCAlert: $showDCAlert, DCAlertText: $DCAlertText, parsecViewController: parsecViewController) + ParsecStatusBar(isReconfiguring: $isReconfiguring, showMenu: $showMenu, showDCAlert: $showDCAlert, DCAlertText: $DCAlertText, parsecViewController: parsecViewController) VStack() { @@ -492,15 +529,35 @@ struct ParsecView: View } DispatchQueue.main.async { - self.parsecViewController.glkView.cleanUp() + // Suppress disconnect alert + show the "Switching resolution…" + // overlay during the gap. + self.isReconfiguring = true + // Freeze the last decoded frame on screen instead of going black: + // pausing the GLKViewController stops `glkView(_:drawIn:)` from + // being called, so the framebuffer keeps its current contents. + if let parsecGLK = self.parsecViewController.glkView as? ParsecGLKViewController { + parsecGLK.glkViewController.isPaused = true + } CParsec.disconnect() } - // Brief pause to let the SDK fully tear down before re-issuing - // ParsecClientConnect with the new resolution. 300 ms matches the - // debounce we already use elsewhere. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // 100 ms is enough to let the two `while backgroundTaskRunning` poll + // loops in ParsecSDKBridge exit (worst case = one ~16 ms iteration + // of their SDK poll). The 20 ms drain sleep inside disconnect() + // covers the same race; this just adds a little headroom. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { _ = CParsec.connect(peerID) - CParsec.applyConfig() + // connect() already installs the fresh ParsecClientConfig with the + // new resolution; calling applyConfig() right after would issue a + // redundant ParsecClientSetConfig against a just-negotiated + // session (sometimes racy). Skip it. + if let parsecGLK = self.parsecViewController.glkView as? ParsecGLKViewController { + parsecGLK.glkViewController.isPaused = false + } + // Drop the overlay a beat later — give the first new frame time + // to arrive so the user sees content, not the spinner-over-stale. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.isReconfiguring = false + } } } diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index b2cc9aa..4c6fb64 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -392,8 +392,12 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { let cur = touch.preciseLocation(in: view) accumulatedDeltaX += Float(cur.x - prev.x) * mouseSensitivity accumulatedDeltaY += Float(cur.y - prev.y) * mouseSensitivity - let dx = Int32(accumulatedDeltaX) - let dy = Int32(accumulatedDeltaY) + // Round-half-away-from-zero (not Int32 truncation) so that + // sub-pixel ticks like 0.4 still emit a 1-pixel send. The + // truncation behaviour effectively coalesced events on slow + // drags or low sensitivity — perceived as "stickiness". + let dx = Int32(accumulatedDeltaX.rounded(.toNearestOrAwayFromZero)) + let dy = Int32(accumulatedDeltaY.rounded(.toNearestOrAwayFromZero)) if dx != 0 || dy != 0 { CParsec.sendMouseDelta(dx, dy) accumulatedDeltaX -= Float(dx) @@ -403,6 +407,27 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } } + // Cleared sub-pixel residue so the next gesture starts from zero. Without + // this the residue carries across strokes at low sensitivity and creates + // systematic drift. + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + for touch in touches where touch.type == .indirectPointer { + accumulatedDeltaX = 0.0 + accumulatedDeltaY = 0.0 + break + } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + for touch in touches where touch.type == .indirectPointer { + accumulatedDeltaX = 0.0 + accumulatedDeltaY = 0.0 + break + } + } + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { for press in presses { @@ -537,13 +562,14 @@ extension ParsecViewController : UIGestureRecognizerDelegate { lastPanTranslation = currentTranslation - // Accumulate for sub-pixel precision + // Accumulate for sub-pixel precision, then round-half-away- + // from-zero so 0.4-px ticks still emit a 1-pixel message + // (Int32 truncation made slow drags feel sticky). accumulatedDeltaX += deltaX accumulatedDeltaY += deltaY - // Send movement when we have at least 1 pixel - let intDeltaX = Int32(accumulatedDeltaX) - let intDeltaY = Int32(accumulatedDeltaY) + let intDeltaX = Int32(accumulatedDeltaX.rounded(.toNearestOrAwayFromZero)) + let intDeltaY = Int32(accumulatedDeltaY.rounded(.toNearestOrAwayFromZero)) if intDeltaX != 0 || intDeltaY != 0 { CParsec.sendMouseDelta(intDeltaX, intDeltaY) @@ -583,6 +609,13 @@ extension ParsecViewController : UIGestureRecognizerDelegate { peakScrollVelocityX = 0 peakScrollVelocityY = 0 case .changed: + // When the user is zoomed in, let UIScrollView's own pan handle + // the scroll locally — otherwise we'd both scroll the local view + // AND send wheel messages to the host (double-pan). + if scrollView.zoomScale > 1.0 { + return + } + let translation = gestureRecognizer.translation(in: gestureRecognizer.view) let deltaX = Float(translation.x - lastScrollTranslation.x) * sensitivity * direction let deltaY = Float(translation.y - lastScrollTranslation.y) * sensitivity * direction @@ -593,6 +626,13 @@ extension ParsecViewController : UIGestureRecognizerDelegate { let now = CACurrentMediaTime() if lastScrollChangeTime > 0 { let dt = now - lastScrollChangeTime + // If there's been a long pause since the last .changed (>200 ms, + // e.g. user paused mid-scroll), forget the peak so the new + // segment doesn't inherit yesterday's velocity for its inertia. + if dt > 0.2 { + peakScrollVelocityX = 0 + peakScrollVelocityY = 0 + } if dt > 0.001 { let vX = (translation.x - lastScrollChangeTranslation.x) / CGFloat(dt) let vY = (translation.y - lastScrollChangeTranslation.y) / CGFloat(dt) @@ -1039,7 +1079,19 @@ extension ParsecViewController { [.alternate, .shift] ] + // iOS queries `keyCommands` on every key event and on every first-responder + // change. Rebuilding 286 UIKeyCommand objects each time would jank typing + // on older iPads. Cache once per type (UIKeyCommand objects are stateless + // modulo their action selector, so sharing across instances is safe — the + // system dispatches the action to whichever responder is first). + // If `captureSystemKeys` flips at runtime the user has to leave + re-enter + // the streaming view to pick up the change. + private static var _cachedKeyCommands: [UIKeyCommand]? + func buildSystemCaptureKeyCommands() -> [UIKeyCommand] { + if let cached = Self._cachedKeyCommands { + return cached + } var commands: [UIKeyCommand] = [] for char in Self._capturableCharset { for mods in Self._capturableModifierCombos { @@ -1068,6 +1120,7 @@ extension ParsecViewController { } commands.append(cmd) } + Self._cachedKeyCommands = commands return commands } @@ -1102,11 +1155,20 @@ extension ParsecViewController { } CParsec.sendVirtualKeyboardInput(text: keyText, isOn: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + // In Low Latency Mode we send keyup + modifier-release synchronously, + // avoiding ~80 ms of artificial hold time on every Cmd-shortcut. The + // old asyncAfter path was originally there as a defensive hold-time + // for games that misread instant releases — not needed on macOS. + if SettingsHandler.lowLatencyMode { CParsec.sendVirtualKeyboardInput(text: keyText, isOn: false) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in - self?.releaseHeldModifiers(mods) + releaseHeldModifiers(mods) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + CParsec.sendVirtualKeyboardInput(text: keyText, isOn: false) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { [weak self] in + self?.releaseHeldModifiers(mods) + } } } @@ -1221,7 +1283,10 @@ final class LanguageSyncTextField: UITextField { // hidden until the user explicitly invokes showKeyboard(). private let _emptyAccessoryView = UIView(frame: .zero) override var inputAccessoryView: UIView? { - return _emptyAccessoryView + // Explicit getter + no-op setter because UIResponder.inputAccessoryView + // is a mutable property — overriding with read-only fails compile. + get { return _emptyAccessoryView } + set { /* ignore */ } } override init(frame: CGRect) { @@ -1262,6 +1327,18 @@ final class LanguageSyncCoordinator { private var hiddenField: LanguageSyncTextField? private var observer: NSObjectProtocol? private var lastLanguage: String? + // True once we've observed at least one notification (or seeded a + // non-nil value asynchronously). Until then, treat any change as the + // initial state and don't fire the host hotkey — otherwise every + // session start spuriously sends Ctrl+Space because `lastLanguage` + // reads as nil immediately after becomeFirstResponder and the first + // real notification looks like "nil → en-US". + private var hasSeenInitialLanguage = false + // Debounce: rapid Caps-Lock taps can fire the notification twice on + // some iPadOS versions, which would interleave modifier press/release + // chords on the host. 150 ms is comfortably below typical typing + // rhythm but above iPadOS double-fire window. + private var lastHotkeyAt: CFTimeInterval = 0 var onLanguageChange: ((String?) -> Void)? init(host: UIViewController, keyForwardTarget: UIResponder) { @@ -1285,9 +1362,16 @@ final class LanguageSyncCoordinator { field.becomeFirstResponder() hiddenField = field - // Seed with current language so we don't fire a redundant hotkey on - // first real change. - lastLanguage = currentLanguage(from: nil) + // Defer seeding: textInputMode is sometimes still nil right after + // becomeFirstResponder. Asynchronously read on the next run-loop + // tick, by which point iOS has updated it. + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if !self.hasSeenInitialLanguage { + self.lastLanguage = self.currentLanguage(from: nil) + self.hasSeenInitialLanguage = true + } + } observer = NotificationCenter.default.addObserver( forName: UITextInputMode.currentInputModeDidChangeNotification, @@ -1322,8 +1406,18 @@ final class LanguageSyncCoordinator { private func handleChange(note: Notification) { let lang = currentLanguage(from: note) + // First observed value (could be the async seed not having fired yet) + // — just store, don't fire the hotkey. + if !hasSeenInitialLanguage { + hasSeenInitialLanguage = true + lastLanguage = lang + return + } guard lang != lastLanguage else { return } lastLanguage = lang + let now = CACurrentMediaTime() + guard now - lastHotkeyAt > 0.15 else { return } + lastHotkeyAt = now onLanguageChange?(lang) } diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 7310a57..35b50a6 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -30,8 +30,13 @@ struct SettingsHandler { @AppStorage("noOverlay") public static var noOverlay: Bool = false @AppStorage("cursorScale") public static var hideStatusBar: Bool = true @AppStorage("rightClickPosition") public static var rightClickPosition: RightClickPosition = .firstFinger - @AppStorage("preferredFramesPerSecond") public static var preferredFramesPerSecond: Int = 60 // 0 = use device max (ProMotion) + @AppStorage("preferredFramesPerSecond") public static var preferredFramesPerSecond: Int = 0 // 0 = use device max (ProMotion). Default was 60 — that capped 120 Hz iPads at half their refresh, doubling glass-to-glass present latency. @AppStorage("decoderCompatibility") public static var decoderCompatibility: Bool = false // Enable for stutter issues on some devices + // Umbrella switch — when true, suppresses the artificial 20 ms / 60 ms + // holds on the captured-key path and the unconditional PiP capture in + // the render loop. Surfaced as a single Settings toggle that also flips + // FPS / decoder / overlay / momentum defaults via onChange. + @AppStorage("lowLatencyMode") public static var lowLatencyMode: Bool = false @AppStorage("showKeyboardButton") public static var showKeyboardButton: Bool = true // When the iPad's hardware keyboard layout changes (Caps Lock / Ctrl+Space diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 1d543c0..396768b 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -19,8 +19,9 @@ struct SettingsView:View @AppStorage("noOverlay") var noOverlay: Bool = false @AppStorage("cursorScale") var hideStatusBar: Bool = true @AppStorage("rightClickPosition") var rightClickPosition: RightClickPosition = .firstFinger - @AppStorage("preferredFramesPerSecond") var preferredFramesPerSecond: Int = 60 // 0 = use device max (ProMotion) + @AppStorage("preferredFramesPerSecond") var preferredFramesPerSecond: Int = 0 // 0 = use device max (ProMotion) @AppStorage("decoderCompatibility") var decoderCompatibility: Bool = false // Enable for stutter issues on some devices + @AppStorage("lowLatencyMode") var lowLatencyMode: Bool = false @AppStorage("showKeyboardButton") var showKeyboardButton: Bool = true @AppStorage("syncKeyboardLayout") var syncKeyboardLayout: Bool = true @AppStorage("layoutSyncHotkey") var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace @@ -204,6 +205,20 @@ struct SettingsView:View Toggle("", isOn:$decoderCompatibility) .frame(width:80) } + CatItem("Low Latency Mode") + { + Toggle("", isOn:$lowLatencyMode) + .frame(width:80) + .onChange(of: lowLatencyMode) { newValue in + if newValue { + // Flip the most impactful knobs in one shot. + // Users can still override individually after. + preferredFramesPerSecond = 0 + decoder = .h265 + noOverlay = true + } + } + } } CatTitle("Misc") CatList() From 4ad1d42777a7008a992773de67a3efd0fd74b41a Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 16:32:50 +0300 Subject: [PATCH 08/40] Fix build: ParsecStatusBar custom init + add local cursor overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1) Build break: the explicit `init(showMenu:showDCAlert:DCAlertText:parsecViewController:)` on ParsecStatusBar shadowed the memberwise init, so the new `isReconfiguring` binding was rejected at the call site even though the @Binding was declared. Updated the init to take and assign isReconfiguring. 2) Local cursor overlay (user request: host-streamed cursor lags by network RTT; want an immediate client-side fallback). - New @AppStorage `localCursorOverlay` (default off) — exposed under Interactivity → "Local Cursor Overlay". - When on, `ParsecViewController.updateImage` hides the host cursor UIImageView (`u`) and shows a local UIImageView using SF Symbol `cursorarrow` with white tint + drop shadow so it reads against any background. - Position is tracked client-side from input events (touchesMoved indirectPointer + handlePanGesture, both direct and touchpad branches). Seeded from the host's last known position so toggling mid-session doesn't jump. - Clamped to contentView bounds so it can't escape the streaming view. - When off, behaviour is identical to before — the tracker still updates so flipping the setting lands on a sane position, but the overlay UIImageView is hidden. Caveat: in touchpad (delta) mode the local cursor is a pure client-side prediction. If the user clicks while the prediction hasn't caught up with the host, the host clicks at its own current position, not the local cursor's. Cmd-shortcuts and direct mode are unaffected. --- OpenParsec/ParsecView.swift | 3 +- OpenParsec/ParsecViewController.swift | 66 ++++++++++++++++++++++++++- OpenParsec/SettingsHandler.swift | 4 ++ OpenParsec/SettingsView.swift | 6 +++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 1e993c4..58606d0 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -13,7 +13,8 @@ struct ParsecStatusBar : View { @State var wasDisconnected: Bool = true let timer = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() - init(showMenu: Binding, showDCAlert: Binding, DCAlertText: Binding, parsecViewController: ParsecViewController) { + init(isReconfiguring: Binding, showMenu: Binding, showDCAlert: Binding, DCAlertText: Binding, parsecViewController: ParsecViewController) { + _isReconfiguring = isReconfiguring _showMenu = showMenu _showDCAlert = showDCAlert _DCAlertText = DCAlertText diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 4c6fb64..4f257c8 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -16,6 +16,11 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var gamePadController: GamepadController! var touchController: TouchController! var u: UIImageView? + // Locally-rendered arrow cursor that follows input immediately, skipping + // the host RTT that the host-streamed cursor (`u`) inherits. Visibility + // is toggled in updateImage based on SettingsHandler.localCursorOverlay. + var localCursorImageView: UIImageView? + var localCursorPosition: CGPoint = .zero var lastImg: CGImage? var lastMouseX: Int32 = -1 var lastMouseY: Int32 = -1 @@ -92,7 +97,21 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { let currentMouseY = CParsec.mouseInfo.mouseY let currentHidden = CParsec.mouseInfo.cursorHidden let currentImg = CParsec.mouseInfo.cursorImg - + + // Toggle host vs local cursor visibility from the same place so they + // stay mutually exclusive without scattered if-statements. + let useLocalOverlay = SettingsHandler.localCursorOverlay + localCursorImageView?.isHidden = !useLocalOverlay + if useLocalOverlay { + // Suppress the host-rendered cursor image — we're drawing our + // own. Early-return to skip the position math below since `u` + // isn't being displayed. + u?.isHidden = true + return + } else { + u?.isHidden = false + } + // Skip if nothing changed if currentMouseX == lastMouseX && currentMouseY == lastMouseY && @@ -235,6 +254,26 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { u = UIImageView(frame: CGRect(x: 0,y: 0,width: 100, height: 100)) contentView.addSubview(u!) // Add Cursor to ContentView + + // Local overlay cursor — same parent so it scrolls with the content + // when zoomed. Uses the system pointer SF Symbol so it looks native; + // kept small and bright-white-on-shadow so it reads against any + // background. Position is tracked client-side from input. + let localCursor = UIImageView(frame: CGRect(x: 0, y: 0, width: 20, height: 20)) + let cursorConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .bold) + localCursor.image = UIImage(systemName: "cursorarrow", withConfiguration: cursorConfig) + localCursor.tintColor = .white + localCursor.layer.shadowColor = UIColor.black.cgColor + localCursor.layer.shadowOpacity = 0.65 + localCursor.layer.shadowRadius = 1.5 + localCursor.layer.shadowOffset = CGSize(width: 0.5, height: 1) + localCursor.isHidden = !SettingsHandler.localCursorOverlay + contentView.addSubview(localCursor) + localCursorImageView = localCursor + // Seed from whatever the host last said (usually centre of stream + // after setFrame). Updated in real time from input events. + localCursorPosition = CGPoint(x: CGFloat(CParsec.mouseInfo.mouseX), y: CGFloat(CParsec.mouseInfo.mouseY)) + localCursor.center = localCursorPosition setNeedsUpdateOfPrefersPointerLocked() @@ -387,6 +426,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { let pos = touch.preciseLocation(in: view) let adjusted = contentView.convert(pos, from: view) CParsec.sendMousePosition(Int32(adjusted.x), Int32(adjusted.y)) + moveLocalCursor(to: adjusted) } else { let prev = touch.precisePreviousLocation(in: view) let cur = touch.preciseLocation(in: view) @@ -400,6 +440,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { let dy = Int32(accumulatedDeltaY.rounded(.toNearestOrAwayFromZero)) if dx != 0 || dy != 0 { CParsec.sendMouseDelta(dx, dy) + moveLocalCursor(byX: CGFloat(dx), y: CGFloat(dy)) accumulatedDeltaX -= Float(dx) accumulatedDeltaY -= Float(dy) } @@ -407,6 +448,27 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } } + // Move the local cursor overlay (no-op if it's hidden — we just keep + // the tracker fresh so toggling the setting mid-session lands on a + // sensible position). + func moveLocalCursor(to position: CGPoint) { + localCursorPosition = position + clampAndApplyLocalCursor() + } + + func moveLocalCursor(byX dx: CGFloat, y dy: CGFloat) { + localCursorPosition.x += dx + localCursorPosition.y += dy + clampAndApplyLocalCursor() + } + + private func clampAndApplyLocalCursor() { + let bounds = contentView.bounds + localCursorPosition.x = max(0, min(localCursorPosition.x, bounds.width)) + localCursorPosition.y = max(0, min(localCursorPosition.y, bounds.height)) + localCursorImageView?.center = localCursorPosition + } + // Cleared sub-pixel residue so the next gesture starts from zero. Without // this the residue carries across strokes at low sensitivity and creates // systematic drift. @@ -546,6 +608,7 @@ extension ParsecViewController : UIGestureRecognizerDelegate { // Convert to content coordinates let adjustedPosition = contentView.convert(position, from: view) CParsec.sendMousePosition(Int32(adjustedPosition.x), Int32(adjustedPosition.y)) + moveLocalCursor(to: adjustedPosition) } else { // Simple translation-based movement with sub-pixel accumulation let currentTranslation = gestureRecognizer.translation(in: gestureRecognizer.view) @@ -573,6 +636,7 @@ extension ParsecViewController : UIGestureRecognizerDelegate { if intDeltaX != 0 || intDeltaY != 0 { CParsec.sendMouseDelta(intDeltaX, intDeltaY) + moveLocalCursor(byX: CGFloat(intDeltaX), y: CGFloat(intDeltaY)) accumulatedDeltaX -= Float(intDeltaX) accumulatedDeltaY -= Float(intDeltaY) } diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 35b50a6..bd35314 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -9,6 +9,10 @@ struct SettingsHandler { @AppStorage("cursorMode") public static var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") public static var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") public static var mouseSensitivity: Double = 1.0 + // Draw a local arrow cursor on top of the streamed video and skip + // rendering the host's own cursor — the local one tracks input + // immediately while the host's cursor visually lags by the network RTT. + @AppStorage("localCursorOverlay") public static var localCursorOverlay: Bool = false @AppStorage("scrollSensitivity") public static var scrollSensitivity: Double = 1.0 // When true (default), trackpad scroll direction matches what iPad/macOS // call "natural scrolling" — swipe down moves content down. Flip to false diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 396768b..90bed45 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -11,6 +11,7 @@ struct SettingsView:View @AppStorage("cursorMode") var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") var mouseSensitivity: Double = 1.0 + @AppStorage("localCursorOverlay") var localCursorOverlay: Bool = false @AppStorage("scrollSensitivity") var scrollSensitivity: Double = 1.0 @AppStorage("naturalScrolling") var naturalScrolling: Bool = true @AppStorage("scrollMomentum") var scrollMomentum: Bool = true @@ -111,6 +112,11 @@ struct SettingsView:View Slider(value: $cursorScale, in:0.1...4, step:0.1) .frame(width: 200) Text(String(format: "%.1f", cursorScale)) + } + CatItem("Local Cursor Overlay") + { + Toggle("", isOn:$localCursorOverlay) + .frame(width:80) } CatItem("Mouse Sensitivity") { From 0bc675ded1dd8bf4d071b93ace3c57ac98ced55c Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 16:52:48 +0300 Subject: [PATCH 09/40] Windows host key remap + 4 audit-found bugfixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of 4ad1d42 found 4 real issues; this commit fixes them and adds the user's Windows-host key-remap toggle. User request — Windows host modifier remap. A user streaming TO a Windows host from a Mac-style iPad keyboard wants Cmd+C to "just work" (= Ctrl+C on Windows). Implemented as a low-level scan-code swap in ParsecSDKBridge so every consumer (UIKey hardware- keyboard path, virtual on-screen keyboard, UIKeyCommand-captured Cmd- shortcuts) gets the swap with no per-caller awareness. Mapping when SettingsHandler.windowsHostKeyboardRemap == true: 227 (LGUI / Cmd) ↔ 224 (LCTRL / Ctrl) 231 (RGUI / RCmd) ↔ 228 (RCTRL / RCtrl) 226 (LALT / Opt) unchanged — same physical key + same Windows mapping 230 (RALT) unchanged 225 (LSHIFT) / 229 (RSHIFT) / printable keys unchanged Surfaced as "Windows Host Remap" toggle under Settings → Keyboard (default off). Off = identity, behaviour preserved. H1. changeResolution swallowed connect() failures. If the host went offline during the 100 ms gap, connect() returned non- PARSEC_OK but changeResolution dropped the result via `_ =` and the isReconfiguring overlay timed out 500 ms later onto a frozen frame. Now: capture the status, and on error short-circuit the overlay and surface a "Reconnect failed (code N)" disconnect alert through the existing showDCAlert/DCAlertText pipeline. H2. Two-resolution spam-tap race. If the user picked a second resolution inside the 600 ms reconfigure window, two disconnect→connect cycles overlapped — the SDK state machine doesn't like that. Added `guard !isReconfiguring else { return }` at the entry of changeResolution. H3. Async keyup after disconnect in sendVirtualKeyboardInput(text:). The 20 ms-delayed keyup release block on DispatchQueue.global() didn't re-check `backgroundTaskRunning`, and the gap exactly matches the new drain sleep in disconnect() — so half the time a disconnect that lands right after a keydown would fire a `ParsecClientSendMessage` into a torn-down client. Added `guard self.backgroundTaskRunning else { return }` inside the asyncAfter closure. M3. Local cursor first-input jump from (1, 1). localCursorPosition was seeded from CParsec.mouseInfo.mouseX/Y at viewDidLoad time, but the host hasn't echoed a cursor event yet so those default to MouseInfo's initial (1, 1) — top-left corner. The first trackpad input would jump the cursor from the corner to wherever the input landed. Now seeded to contentView.center instead. Notable non-finding — host cursor visibility on the Mac itself. User asked: when local overlay is enabled, also hide the cursor on the Mac's physical display. Investigation: the Parsec SDK has no client→host "hide cursor" message — every outbound message type is keyboard/mouse-button/mouse-motion/mouse-wheel/gamepad, no cursor. ParsecClientConfig has no cursor-visibility field. The cursor on the Mac is drawn by macOS itself before Parsec captures the framebuffer. Hiding it requires a host-side helper (Hammerspoon hotkey calling CGDisplayHideCursor) that the iPad would invoke through a regular keyboard message — not something we can do purely client-side. Left client behaviour unchanged (already hides the streamed host cursor image when overlay is on). --- OpenParsec/ParsecSDKBridge.swift | 42 ++++++++++++++++++++++----- OpenParsec/ParsecView.swift | 19 +++++++++++- OpenParsec/ParsecViewController.swift | 8 +++-- OpenParsec/SettingsHandler.swift | 5 ++++ OpenParsec/SettingsView.swift | 6 ++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index e64dd9c..e45dade 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -406,6 +406,24 @@ class ParsecSDKBridge: ParsecService static func clamp(_ value: T, minValue: T, maxValue: T) -> T where T : Comparable { return min(max(value, minValue), maxValue) } + + // Swap GUI ↔ Ctrl scan codes when the user has flagged the host as + // Windows. Mac-keyboard layout calls the modifier-row keys, left-to-right, + // Control / Option / Cmd. On a Windows host the equivalents are + // Ctrl / Alt / Win — but Win+C does nothing useful and Ctrl+C is copy. + // Remapping at the scan-code layer means every consumer (UIKey path, + // virtual keyboard, UIKeyCommand captured shortcuts) gets the swap with + // no per-caller awareness. + static func remapKeyForHostIfNeeded(_ code: ParsecKeycode) -> ParsecKeycode { + guard SettingsHandler.windowsHostKeyboardRemap else { return code } + switch code.rawValue { + case 227: return ParsecKeycode(224) // LGUI → LCTRL (Cmd → Ctrl) + case 224: return ParsecKeycode(227) // LCTRL → LGUI (Ctrl → Win) + case 231: return ParsecKeycode(228) // RGUI → RCTRL + case 228: return ParsecKeycode(231) // RCTRL → RGUI + default: return code // Shift / Alt / printable keys unchanged + } + } func sendMousePosition(_ x:Int32, _ y:Int32) { @@ -467,18 +485,24 @@ class ParsecSDKBridge: ParsecService guard let keyCode else { return } + let remapped = ParsecSDKBridge.remapKeyForHostIfNeeded(keyCode) var keyboardMessagePress = ParsecMessage() keyboardMessagePress.type = MESSAGE_KEYBOARD if !isVirtualShiftOn && useShift { keyboardMessagePress.keyboard = ParsecKeyboardMessage(code: KEY_LSHIFT, mod: MOD_NONE, pressed: true, __pad: (0,0,0)) ParsecClientSendMessage(_parsec, &keyboardMessagePress) } - keyboardMessagePress.keyboard = ParsecKeyboardMessage(code: keyCode, mod: MOD_NONE, pressed: true, __pad: (0,0,0)) + keyboardMessagePress.keyboard = ParsecKeyboardMessage(code: remapped, mod: MOD_NONE, pressed: true, __pad: (0,0,0)) ParsecClientSendMessage(_parsec, &keyboardMessagePress) - + // add release delay in case some games ignore instant key release DispatchQueue.global().asyncAfter(deadline: .now() + 0.02) { - keyboardMessagePress.keyboard = ParsecKeyboardMessage(code: keyCode, mod: MOD_NONE, pressed: false, __pad: (0,0,0)) + // Re-check the gate inside the closure: a disconnect can land in + // these 20 ms (matches the drain sleep in disconnect() exactly), + // in which case we would otherwise fire ParsecClientSendMessage + // against a torn-down client. + guard self.backgroundTaskRunning else { return } + keyboardMessagePress.keyboard = ParsecKeyboardMessage(code: remapped, mod: MOD_NONE, pressed: false, __pad: (0,0,0)) if !self.isVirtualShiftOn && useShift { keyboardMessagePress.keyboard = ParsecKeyboardMessage(code: KEY_LSHIFT, mod: MOD_NONE, pressed: false, __pad: (0,0,0)) } @@ -493,17 +517,18 @@ class ParsecSDKBridge: ParsecService guard let keyCode else { return } - + if keyCode.rawValue == 225 { isVirtualShiftOn = isOn } - + + let remapped = ParsecSDKBridge.remapKeyForHostIfNeeded(keyCode) var keyboardMessagePress = ParsecMessage() keyboardMessagePress.type = MESSAGE_KEYBOARD keyboardMessagePress.keyboard.pressed = isOn - keyboardMessagePress.keyboard.code = keyCode + keyboardMessagePress.keyboard.code = remapped ParsecClientSendMessage(_parsec, &keyboardMessagePress) - + } func sendKeyboardMessage(event:KeyBoardKeyEvent) @@ -513,9 +538,10 @@ class ParsecSDKBridge: ParsecService return } + let rawCode = ParsecKeycode(UInt32(KeyCodeTranslators.uiKeyCodeToInt(key: event.input?.keyCode ?? UIKeyboardHIDUsage.keyboardErrorUndefined))) var keyboardMessagePress = ParsecMessage() keyboardMessagePress.type = MESSAGE_KEYBOARD - keyboardMessagePress.keyboard.code = ParsecKeycode(UInt32(KeyCodeTranslators.uiKeyCodeToInt(key: event.input?.keyCode ?? UIKeyboardHIDUsage.keyboardErrorUndefined))) + keyboardMessagePress.keyboard.code = ParsecSDKBridge.remapKeyForHostIfNeeded(rawCode) keyboardMessagePress.keyboard.pressed = event.isPressBegin ParsecClientSendMessage(_parsec, &keyboardMessagePress) } diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 58606d0..0ed344c 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -509,6 +509,12 @@ struct ParsecView: View } func changeResolution(res: ParsecResolution) { + // Guard against spam-tap: if a previous changeResolution is still in + // its disconnect→reconnect dance, ignore further selections until it + // finishes. Otherwise two overlapping connect/disconnect cycles can + // race the SDK state machine. + if isReconfiguring { return } + SettingsHandler.resolution = res DispatchQueue.main.async { DataManager.model.resolutionX = res.width @@ -546,7 +552,7 @@ struct ParsecView: View // of their SDK poll). The 20 ms drain sleep inside disconnect() // covers the same race; this just adds a little headroom. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - _ = CParsec.connect(peerID) + let status = CParsec.connect(peerID) // connect() already installs the fresh ParsecClientConfig with the // new resolution; calling applyConfig() right after would issue a // redundant ParsecClientSetConfig against a just-negotiated @@ -554,6 +560,17 @@ struct ParsecView: View if let parsecGLK = self.parsecViewController.glkView as? ParsecGLKViewController { parsecGLK.glkViewController.isPaused = false } + + // If the reconnect didn't take (host offline, network blip), + // surface a real error instead of leaving the user staring at + // a frozen frame behind the spinner. + if status != PARSEC_OK && status != PARSEC_CONNECTING { + self.isReconfiguring = false + self.DCAlertText = "Reconnect failed (code \(status.rawValue))" + self.showDCAlert = true + return + } + // Drop the overlay a beat later — give the first new frame time // to arrive so the user sees content, not the spinner-over-stale. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 4f257c8..1011a80 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -270,9 +270,11 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { localCursor.isHidden = !SettingsHandler.localCursorOverlay contentView.addSubview(localCursor) localCursorImageView = localCursor - // Seed from whatever the host last said (usually centre of stream - // after setFrame). Updated in real time from input events. - localCursorPosition = CGPoint(x: CGFloat(CParsec.mouseInfo.mouseX), y: CGFloat(CParsec.mouseInfo.mouseY)) + // `mouseInfo.mouseX/Y` defaults to (1, 1) before the host echoes the + // first cursor event, so seeding from it would put the overlay at + // the top-left until the first input. Centre of the content view is + // a less-jarring starting point. + localCursorPosition = CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY) localCursor.center = localCursorPosition setNeedsUpdateOfPrefersPointerLocked() diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index bd35314..78ac893 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -31,6 +31,11 @@ struct SettingsHandler { // Globe key, and swipe-up gestures stay system-level — those cannot be // intercepted from a sandboxed iPad app. @AppStorage("captureSystemKeys") public static var captureSystemKeys: Bool = true + // When streaming TO a Windows host from a Mac-style iPad keyboard, swap + // the GUI ↔ Ctrl scan codes so Cmd+C (the iPad user's expectation) + // arrives as Ctrl+C on the host, Ctrl+anything arrives as Win+anything, + // and Opt stays Alt (it's the same physical key + same Windows mapping). + @AppStorage("windowsHostKeyboardRemap") public static var windowsHostKeyboardRemap: Bool = false @AppStorage("noOverlay") public static var noOverlay: Bool = false @AppStorage("cursorScale") public static var hideStatusBar: Bool = true @AppStorage("rightClickPosition") public static var rightClickPosition: RightClickPosition = .firstFinger diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 90bed45..f059f10 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -17,6 +17,7 @@ struct SettingsView:View @AppStorage("scrollMomentum") var scrollMomentum: Bool = true @AppStorage("scrollMomentumStrength") var scrollMomentumStrength: Double = 0.5 @AppStorage("captureSystemKeys") var captureSystemKeys: Bool = true + @AppStorage("windowsHostKeyboardRemap") var windowsHostKeyboardRemap: Bool = false @AppStorage("noOverlay") var noOverlay: Bool = false @AppStorage("cursorScale") var hideStatusBar: Bool = true @AppStorage("rightClickPosition") var rightClickPosition: RightClickPosition = .firstFinger @@ -171,6 +172,11 @@ struct SettingsView:View Toggle("", isOn:$captureSystemKeys) .frame(width:80) } + CatItem("Windows Host Remap") + { + Toggle("", isOn:$windowsHostKeyboardRemap) + .frame(width:80) + } } CatTitle("Graphics") CatList() From 2b57e2469b766a8d4c5c8e94fe3a15ba6fff28dd Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 19:17:16 +0300 Subject: [PATCH 10/40] =?UTF-8?q?v4=20=E2=80=94=20real=20bugfixes=20from?= =?UTF-8?q?=20user=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reports of v3 failures, all real: 1) Natural Scrolling toggle was backwards. With Mac on its default Natural Scrolling = ON, we want translation deltas forwarded WITHOUT inversion (host does the inversion itself). I had the sign flipped: ON meant client-side invert, which double-inverted with the host → inverted feel. OFF meant no client invert, which on a Natural-host reads as "OK as classic" but felt "broken" because the user's input expectation was natural. Flipped: toggle ON → direction = +1 (no client-side invert, host does natural). Toggle OFF → direction = -1 (classic wheel feel). 2) Scroll accumulator used Int32() truncation instead of rounded (.toNearestOrAwayFromZero), same regression we already fixed on the mouse path. Slow trackpad scrolls vanished into sub-pixel accumulation until enough whole pixels piled up. Applied rounding to both handleTrackpadScroll's .changed branch and the scrollMomentumTick. 3) Local cursor looked wrong (Mac-style arrow via SF Symbol `cursorarrow`) and didn't appear to move. Two fixes: - Replaced the UIImageView+SF-Symbol with a UIView circle dot: 13 pt diameter, light-gray fill (0.92 alpha 0.85), 0.5 pt dark border, soft 1 pt shadow. Matches iPadOS native pointer style. - Seeded position from `contentView.bounds.midX/midY` inside `viewDidLayoutSubviews`, gated by a `hasSeededLocalCursor` flag. viewDidLoad read midX/Y as 0 because the view hadn't laid out yet, parking the cursor at the corner where it visually appeared "stuck". - In touchesMoved + handlePanGesture (touchpad branch) the cursor position now updates at full CGFloat precision regardless of whether the rounded host delta produced a non-zero Int. The cursor glides smoothly even when individual events don't produce a whole-pixel host send. 4) Resolution change → "Disconnected 20". The 100 ms reconnect gap wasn't enough for the SDK to fully tear down (audio queue, poll loops, internal session state) before connect(). Bumped to 600 ms. Status-check branch (added in v3) already surfaces the alert if it still fails. 5) Display selection didn't survive between sessions. Parsec regenerates display ids across some session boundaries, so the `config.contains(where: { $0.id == saved })` check would miss the physically-same display under a new id. Added a name+adapter fallback: SettingsHandler.savedDisplayName persists the human-readable label, case-12 restore tries id first, then name. On successful name-match it re-syncs the new id so future restores are fast. Not fixed in this commit — explained to user: * Streaming resolution requested via parsecClientCfg.video.0.resolutionX/Y is mostly advisory on a macOS host with a real display. Parsec on Mac captures the physical display at its native resolution and ignores small client requests like 1920x1080 (no virtual-display driver involved). The honest knobs for bandwidth reduction are bitrate and H.265. * Hiding the cursor on the Mac's own physical display is not expressible through the Parsec SDK (no client→host cursor message, no config field). Requires host-side helper. --- OpenParsec/ParsecSDKBridge.swift | 17 +++- OpenParsec/ParsecView.swift | 22 +++-- OpenParsec/ParsecViewController.swift | 115 +++++++++++++++++--------- OpenParsec/SettingsHandler.swift | 4 + 4 files changed, 107 insertions(+), 51 deletions(-) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index e45dade..529ab34 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -275,10 +275,19 @@ class ParsecSDKBridge: ParsecService // and cause a brief re-encode flicker. if !self.didRestoreSavedDisplay { self.didRestoreSavedDisplay = true - let saved = SettingsHandler.savedDisplayOutput - if !saved.isEmpty, saved != "none", - config.contains(where: { $0.id == saved }) { - DataManager.model.output = saved + let savedId = SettingsHandler.savedDisplayOutput + let savedName = SettingsHandler.savedDisplayName + guard !savedId.isEmpty || !savedName.isEmpty else { return } + // Match by id first (stable when the host reports + // consistent display ids across sessions). Fall + // back to name+adapter match so a display that + // changed id between connects (Parsec sometimes + // regenerates them) is still found. + let match = config.first(where: { $0.id == savedId }) + ?? config.first(where: { !savedName.isEmpty && "\($0.name) \($0.adapterName)" == savedName }) + if let match = match { + DataManager.model.output = match.id + SettingsHandler.savedDisplayOutput = match.id // re-sync if id rolled self.updateHostVideoConfig() } } diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 0ed344c..d568d39 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -547,11 +547,11 @@ struct ParsecView: View } CParsec.disconnect() } - // 100 ms is enough to let the two `while backgroundTaskRunning` poll - // loops in ParsecSDKBridge exit (worst case = one ~16 ms iteration - // of their SDK poll). The 20 ms drain sleep inside disconnect() - // covers the same race; this just adds a little headroom. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // 600 ms gives the SDK enough time to fully tear down the session + // (audio queue, poll loops, internal state) before we reconnect. + // The user previously saw "Disconnected 20" with a 100 ms gap — + // turns out the SDK isn't truly ready for connect() that fast. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { let status = CParsec.connect(peerID) // connect() already installs the fresh ParsecClientConfig with the // new resolution; calling applyConfig() right after would issue a @@ -618,7 +618,17 @@ struct ParsecView: View // Persist so the next connect can auto-restore this choice once // the host enumerates displays (user-data event 12). "none" is // the "Auto" pseudo-id and isn't worth remembering. - SettingsHandler.savedDisplayOutput = (displayId == "none") ? "" : displayId + if displayId == "none" { + SettingsHandler.savedDisplayOutput = "" + SettingsHandler.savedDisplayName = "" + } else { + SettingsHandler.savedDisplayOutput = displayId + // Also persist the human-readable name so a regenerated id + // next session can be matched by name. + if let cfg = DataManager.model.displayConfigs.first(where: { $0.id == displayId }) { + SettingsHandler.savedDisplayName = "\(cfg.name) \(cfg.adapterName)" + } + } CParsec.updateHostVideoConfig() } } diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 1011a80..e299f6d 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -16,11 +16,16 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var gamePadController: GamepadController! var touchController: TouchController! var u: UIImageView? - // Locally-rendered arrow cursor that follows input immediately, skipping - // the host RTT that the host-streamed cursor (`u`) inherits. Visibility - // is toggled in updateImage based on SettingsHandler.localCursorOverlay. - var localCursorImageView: UIImageView? + // Locally-rendered iPadOS-style pointer dot that follows input immediately, + // skipping the host RTT that the host-streamed cursor (`u`) inherits. + // Visibility is toggled in updateImage based on + // SettingsHandler.localCursorOverlay. + var localCursorImageView: UIView? var localCursorPosition: CGPoint = .zero + // viewDidLoad runs before the contentView has a non-zero size, so seeding + // the cursor there reads midX/Y as 0 and parks it at the corner. Re-seed + // once layout produces real bounds. One-shot via this flag. + var hasSeededLocalCursor: Bool = false var lastImg: CGImage? var lastMouseX: Int32 = -1 var lastMouseY: Int32 = -1 @@ -255,27 +260,30 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { u = UIImageView(frame: CGRect(x: 0,y: 0,width: 100, height: 100)) contentView.addSubview(u!) // Add Cursor to ContentView - // Local overlay cursor — same parent so it scrolls with the content - // when zoomed. Uses the system pointer SF Symbol so it looks native; - // kept small and bright-white-on-shadow so it reads against any - // background. Position is tracked client-side from input. - let localCursor = UIImageView(frame: CGRect(x: 0, y: 0, width: 20, height: 20)) - let cursorConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .bold) - localCursor.image = UIImage(systemName: "cursorarrow", withConfiguration: cursorConfig) - localCursor.tintColor = .white + // Local overlay cursor — iPadOS-style pointer dot drawn programmatically + // so it actually looks native (the SF Symbol cursorarrow we tried + // before was a Mac-style arrow that didn't match iPad muscle memory). + // Light gray with a darker border for contrast on any background, + // soft shadow for legibility. Same parent (contentView) so it scrolls + // with the streamed content when the user is zoomed in. + let dotSize: CGFloat = 13 + let localCursor = UIView(frame: CGRect(x: 0, y: 0, width: dotSize, height: dotSize)) + localCursor.backgroundColor = UIColor(white: 0.92, alpha: 0.85) + localCursor.layer.cornerRadius = dotSize / 2 + localCursor.layer.borderWidth = 0.5 + localCursor.layer.borderColor = UIColor(white: 0.15, alpha: 0.45).cgColor localCursor.layer.shadowColor = UIColor.black.cgColor - localCursor.layer.shadowOpacity = 0.65 - localCursor.layer.shadowRadius = 1.5 - localCursor.layer.shadowOffset = CGSize(width: 0.5, height: 1) + localCursor.layer.shadowOpacity = 0.35 + localCursor.layer.shadowRadius = 2.5 + localCursor.layer.shadowOffset = CGSize(width: 0, height: 1) + localCursor.isUserInteractionEnabled = false localCursor.isHidden = !SettingsHandler.localCursorOverlay contentView.addSubview(localCursor) localCursorImageView = localCursor - // `mouseInfo.mouseX/Y` defaults to (1, 1) before the host echoes the - // first cursor event, so seeding from it would put the overlay at - // the top-left until the first input. Centre of the content view is - // a less-jarring starting point. - localCursorPosition = CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY) - localCursor.center = localCursorPosition + // The real seeding happens in viewDidLayoutSubviews — contentView's + // bounds aren't valid until layout has run, so reading midX/Y here + // would give (0, 0). Park at (0, 0) for now; the first layout pass + // will move it to the centre. setNeedsUpdateOfPrefersPointerLocked() @@ -354,6 +362,18 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + // One-shot seed of the local cursor at the content view's centre + // after layout has produced real bounds. viewDidLoad reads zero + // bounds and would park the cursor at the corner. + if !hasSeededLocalCursor && contentView.bounds.width > 0 && contentView.bounds.height > 0 { + localCursorPosition = CGPoint(x: contentView.bounds.midX, y: contentView.bounds.midY) + localCursorImageView?.center = localCursorPosition + hasSeededLocalCursor = true + } + } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) @@ -432,17 +452,21 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } else { let prev = touch.precisePreviousLocation(in: view) let cur = touch.preciseLocation(in: view) - accumulatedDeltaX += Float(cur.x - prev.x) * mouseSensitivity - accumulatedDeltaY += Float(cur.y - prev.y) * mouseSensitivity + let preciseDX = (cur.x - prev.x) * CGFloat(mouseSensitivity) + let preciseDY = (cur.y - prev.y) * CGFloat(mouseSensitivity) + + // Move the LOCAL cursor at full sub-pixel precision so it + // glides smoothly even when the rounded host-delta is zero. + moveLocalCursor(byX: preciseDX, y: preciseDY) + + accumulatedDeltaX += Float(preciseDX) + accumulatedDeltaY += Float(preciseDY) // Round-half-away-from-zero (not Int32 truncation) so that - // sub-pixel ticks like 0.4 still emit a 1-pixel send. The - // truncation behaviour effectively coalesced events on slow - // drags or low sensitivity — perceived as "stickiness". + // sub-pixel ticks like 0.4 still emit a 1-pixel send to host. let dx = Int32(accumulatedDeltaX.rounded(.toNearestOrAwayFromZero)) let dy = Int32(accumulatedDeltaY.rounded(.toNearestOrAwayFromZero)) if dx != 0 || dy != 0 { CParsec.sendMouseDelta(dx, dy) - moveLocalCursor(byX: CGFloat(dx), y: CGFloat(dy)) accumulatedDeltaX -= Float(dx) accumulatedDeltaY -= Float(dy) } @@ -621,24 +645,27 @@ extension ParsecViewController : UIGestureRecognizerDelegate { accumulatedDeltaY = 0.0 } - // Calculate delta since last update - let deltaX = Float(currentTranslation.x - lastPanTranslation.x) * mouseSensitivity - let deltaY = Float(currentTranslation.y - lastPanTranslation.y) * mouseSensitivity + // Calculate delta since last update at full precision. + let preciseDX = (currentTranslation.x - lastPanTranslation.x) * CGFloat(mouseSensitivity) + let preciseDY = (currentTranslation.y - lastPanTranslation.y) * CGFloat(mouseSensitivity) lastPanTranslation = currentTranslation + // Move the LOCAL cursor at full sub-pixel precision so its + // motion stays smooth even between whole-pixel host deltas. + moveLocalCursor(byX: preciseDX, y: preciseDY) + // Accumulate for sub-pixel precision, then round-half-away- // from-zero so 0.4-px ticks still emit a 1-pixel message // (Int32 truncation made slow drags feel sticky). - accumulatedDeltaX += deltaX - accumulatedDeltaY += deltaY + accumulatedDeltaX += Float(preciseDX) + accumulatedDeltaY += Float(preciseDY) let intDeltaX = Int32(accumulatedDeltaX.rounded(.toNearestOrAwayFromZero)) let intDeltaY = Int32(accumulatedDeltaY.rounded(.toNearestOrAwayFromZero)) if intDeltaX != 0 || intDeltaY != 0 { CParsec.sendMouseDelta(intDeltaX, intDeltaY) - moveLocalCursor(byX: CGFloat(intDeltaX), y: CGFloat(intDeltaY)) accumulatedDeltaX -= Float(intDeltaX) accumulatedDeltaY -= Float(intDeltaY) } @@ -658,10 +685,13 @@ extension ParsecViewController : UIGestureRecognizerDelegate { // allowedScrollTypesMask = .all and maximumNumberOfTouches = 0 in viewDidLoad, // so it only sees scroll-wheel / trackpad-scroll events, never finger touches. @objc func handleTrackpadScroll(_ gestureRecognizer: UIPanGestureRecognizer) { - // "Natural scrolling" — swipe-direction follows content, matching iPadOS - // / macOS default. Flip to false (in Settings) for classic mouse-wheel - // direction. Applied to both the live drag and the inertia tail. - let direction: Float = SettingsHandler.naturalScrolling ? -1.0 : 1.0 + // "Natural scrolling" toggle ON = swipe-direction follows content, which + // is the macOS default ("Natural" in System Settings). With the user's + // Mac on its default Natural Scrolling = ON, we want to forward + // translation deltas without flipping — host applies its own + // inversion. Flip only when toggle is OFF (= user wants classic + // mouse-wheel feel). + let direction: Float = SettingsHandler.naturalScrolling ? 1.0 : -1.0 let sensitivity = Float(SettingsHandler.scrollSensitivity) switch gestureRecognizer.state { @@ -712,8 +742,11 @@ extension ParsecViewController : UIGestureRecognizerDelegate { lastScrollTranslation = translation accumulatedScrollX += deltaX accumulatedScrollY += deltaY - let intX = Int32(accumulatedScrollX) - let intY = Int32(accumulatedScrollY) + // Same rounding trick as the mouse-delta path — Int32 truncation + // previously swallowed sub-pixel ticks so slow trackpad scrolls + // felt completely dead until enough whole pixels piled up. + let intX = Int32(accumulatedScrollX.rounded(.toNearestOrAwayFromZero)) + let intY = Int32(accumulatedScrollY.rounded(.toNearestOrAwayFromZero)) if intX != 0 || intY != 0 { CParsec.sendWheelMsg(x: intX, y: intY) accumulatedScrollX -= Float(intX) @@ -763,8 +796,8 @@ extension ParsecViewController : UIGestureRecognizerDelegate { @objc private func scrollMomentumTick(_ link: CADisplayLink) { accumulatedScrollX += momentumVelocityX accumulatedScrollY += momentumVelocityY - let intX = Int32(accumulatedScrollX) - let intY = Int32(accumulatedScrollY) + let intX = Int32(accumulatedScrollX.rounded(.toNearestOrAwayFromZero)) + let intY = Int32(accumulatedScrollY.rounded(.toNearestOrAwayFromZero)) if intX != 0 || intY != 0 { CParsec.sendWheelMsg(x: intX, y: intY) accumulatedScrollX -= Float(intX) diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 78ac893..8e8c144 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -61,6 +61,10 @@ struct SettingsHandler { @AppStorage("savedMuted") public static var savedMuted: Bool = false // Remember which display the user picked last; restored on the next // connect once the host enumerates its displays (user-data event 12). + // The id is the primary key; the name (e.g. "Built-in Retina Display ...") + // is a fallback because Parsec sometimes regenerates display ids between + // connects for the same physical display. @AppStorage("savedDisplayOutput") public static var savedDisplayOutput: String = "" + @AppStorage("savedDisplayName") public static var savedDisplayName: String = "" } From af5261b9fb7c75ea8eb0529b805638646f1c7bda Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 19:25:15 +0300 Subject: [PATCH 11/40] v4 follow-up: loosen language-sync gates + lower inertia threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User on v3 reports two regressions my "polish" introduced — both my own fault. Loosening overly-defensive gates. Language sync wasn't firing. The initial-seed + 150 ms debounce gates I added (defensive against spurious-at-startup and rapid-double-fire) were swallowing legitimate notifications. On user's setup the very first Caps-Lock toggle after session start fired the notification, hit the `!hasSeenInitialLanguage` branch, stored the new value, returned without dispatching the hotkey. The next toggle would compare against the value we stored on the first one — same value, no diff, no fire. Loosened to: any genuine `lang != lastLanguage` transition dispatches. A 50 ms debounce stays in for OS-level double-fire coalescing only. First fire might now sometimes be redundant — harmless trade for actually working. Scroll inertia toggle was on but no inertia. Two problems: - The 200 ms peak-velocity reset (P3 polish to handle "lift, pause, scroll again" patterns) was zeroing the peak DURING iPadOS's own post-lift deceleration tail. Those tail-end .changed events arrive further apart as the tail fades; by the time .ended fires the peak read as zero. Raised to 1.0 s. - The 80 pts/s minimum-seed-speed threshold rejected most natural-feel scrolls — only a hard flick exceeded it. Lowered to 20 so gentle scrolls also get a short tail. --- OpenParsec/ParsecViewController.swift | 38 ++++++++++++++++----------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index e299f6d..69b78bc 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -722,10 +722,15 @@ extension ParsecViewController : UIGestureRecognizerDelegate { let now = CACurrentMediaTime() if lastScrollChangeTime > 0 { let dt = now - lastScrollChangeTime - // If there's been a long pause since the last .changed (>200 ms, - // e.g. user paused mid-scroll), forget the peak so the new - // segment doesn't inherit yesterday's velocity for its inertia. - if dt > 0.2 { + // iPadOS sends a deceleration tail of `.changed` events after + // the user lifts their fingers, and those events arrive + // further apart as the deceleration fades. The previous + // 0.2 s peak-reset was hitting during that tail and zeroing + // the peak right before `.ended` fired — that's why inertia + // silently didn't trigger. Raised to 1.0 s, which is much + // longer than any natural same-gesture pause but still + // catches "scroll, idle, scroll" patterns. + if dt > 1.0 { peakScrollVelocityX = 0 peakScrollVelocityY = 0 } @@ -755,9 +760,10 @@ extension ParsecViewController : UIGestureRecognizerDelegate { case .ended: lastScrollTranslation = .zero // Use peak velocity from .changed phase (not recognizer.velocity at - // .ended — that's already decayed by iPad). Only trigger if user - // scrolled fast enough to expect inertia. - let minSeedSpeed: CGFloat = 80 + // .ended — that's already decayed by iPad). The previous 80 pts/s + // threshold rejected most natural-feel scrolls; lowered to 20 so + // even gentle drags still get a short inertia tail. + let minSeedSpeed: CGFloat = 20 if SettingsHandler.scrollMomentum, abs(peakScrollVelocityX) > minSeedSpeed || abs(peakScrollVelocityY) > minSeedSpeed { momentumVelocityX = Float(peakScrollVelocityX) / 60.0 * sensitivity * direction @@ -1505,17 +1511,19 @@ final class LanguageSyncCoordinator { private func handleChange(note: Notification) { let lang = currentLanguage(from: note) - // First observed value (could be the async seed not having fired yet) - // — just store, don't fire the hotkey. - if !hasSeenInitialLanguage { - hasSeenInitialLanguage = true - lastLanguage = lang - return - } + // On user reports the previous initial-seed + 150 ms debounce gates + // were swallowing legitimate hotkey fires (the very first iPad layout + // switch after a session start in particular). Loosened: only skip if + // the language string actually hasn't changed. A stray duplicate + // Ctrl+Space at session start is harmless; missing every real switch + // is not. guard lang != lastLanguage else { return } lastLanguage = lang + hasSeenInitialLanguage = true + // Short debounce ONLY to coalesce iPadOS double-notification glitches + // — 50 ms is well under any human switching cadence. let now = CACurrentMediaTime() - guard now - lastHotkeyAt > 0.15 else { return } + guard now - lastHotkeyAt > 0.05 else { return } lastHotkeyAt = now onLanguageChange?(lang) } From aca422bd7135e6d57d40dbd0e45dc59d9c311262 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 21:23:13 +0300 Subject: [PATCH 12/40] Add Mac Ctrl+Shift hotkey + adjustable mouse acceleration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User requests + groundwork while audit subagents investigate v4 regressions: * Layout sync hotkey: added ⌃⇧ option (Ctrl+Shift). Some macOS users prefer this binding for "Select previous input source". Surfaced as "⌃ + ⇧ (macOS alt)" in the picker. * Mouse acceleration: new `mouseAcceleration` AppStorage setting (0..1.5, default 0 = pure linear). When > 0, per-event delta gets a speed-proportional boost: `effective = sensitivity + accel × (|delta|/5)`, so fast flicks travel further than slow drags — mirrors macOS pointer acceleration without copying its full curve. Slider in Settings → Interactivity between Mouse Sensitivity and Scroll Sensitivity. * Both `touchesMoved` (.indirectPointer / trackpad) and `handlePanGesture` (touchscreen) paths now route through a shared `effectiveDeltaScale` helper so behaviour stays consistent. Note — three subagents still running on bigger issues the user flagged: no inertia / "block" feel, resolution-menu crash, display persistence. Their fixes will follow in a separate commit. --- OpenParsec/ParsecSDKBridge.swift | 1 + OpenParsec/ParsecViewController.swift | 33 +++++++++++++++++++++++---- OpenParsec/SettingsView.swift | 7 ++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 529ab34..f221af3 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -32,6 +32,7 @@ enum LayoutSyncHotkey: Int, CaseIterable case cmdSpace = 2 case altSpace = 3 case altShift = 4 + case ctrlShift = 5 // ⌃⇧ — another common macOS layout-toggle binding } enum RightClickPosition: Int diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 69b78bc..dd6ea86 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -452,8 +452,11 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } else { let prev = touch.precisePreviousLocation(in: view) let cur = touch.preciseLocation(in: view) - let preciseDX = (cur.x - prev.x) * CGFloat(mouseSensitivity) - let preciseDY = (cur.y - prev.y) * CGFloat(mouseSensitivity) + let rawDX = cur.x - prev.x + let rawDY = cur.y - prev.y + let scale = effectiveDeltaScale(rawDX: rawDX, rawDY: rawDY) + let preciseDX = rawDX * scale + let preciseDY = rawDY * scale // Move the LOCAL cursor at full sub-pixel precision so it // glides smoothly even when the rounded host-delta is zero. @@ -474,6 +477,20 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } } + // Combined sensitivity × acceleration. Acceleration adds a per-event + // gain proportional to the per-event speed: bigger movements travel + // proportionally further, mimicking macOS pointer acceleration. Falls + // back to pure linear sensitivity when mouseAcceleration == 0. + private func effectiveDeltaScale(rawDX: CGFloat, rawDY: CGFloat) -> CGFloat { + let sens = CGFloat(SettingsHandler.mouseSensitivity) + let accel = CGFloat(SettingsHandler.mouseAcceleration) + guard accel > 0 else { return sens } + // |raw_delta| is in points per input event (typically 0..15). + // Dividing by 5 normalizes so 5 px/event ≈ 1× boost at accel=1. + let speed = sqrt(rawDX * rawDX + rawDY * rawDY) / 5.0 + return sens + accel * speed + } + // Move the local cursor overlay (no-op if it's hidden — we just keep // the tracker fresh so toggling the setting mid-session lands on a // sensible position). @@ -645,9 +662,13 @@ extension ParsecViewController : UIGestureRecognizerDelegate { accumulatedDeltaY = 0.0 } - // Calculate delta since last update at full precision. - let preciseDX = (currentTranslation.x - lastPanTranslation.x) * CGFloat(mouseSensitivity) - let preciseDY = (currentTranslation.y - lastPanTranslation.y) * CGFloat(mouseSensitivity) + // Calculate delta since last update at full precision, with + // the combined sensitivity × acceleration curve. + let rawDX = currentTranslation.x - lastPanTranslation.x + let rawDY = currentTranslation.y - lastPanTranslation.y + let scale = effectiveDeltaScale(rawDX: rawDX, rawDY: rawDY) + let preciseDX = rawDX * scale + let preciseDY = rawDY * scale lastPanTranslation = currentTranslation @@ -1333,6 +1354,8 @@ extension ParsecViewController { tapKey(modifierKey: "LALT", normalKey: "SPACE") case .altShift: tapModifierChord(firstModifier: "LALT", secondModifier: "SHIFT") + case .ctrlShift: + tapModifierChord(firstModifier: "CONTROL", secondModifier: "SHIFT") } } diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index f059f10..f8b5302 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -125,6 +125,12 @@ struct SettingsView:View .frame(width: 200) Text(String(format: "%.1f", mouseSensitivity)) } + CatItem("Mouse Acceleration") + { + Slider(value: $mouseAcceleration, in:0...1.5, step:0.05) + .frame(width: 200) + Text(String(format: "%.2f", mouseAcceleration)) + } CatItem("Scroll Sensitivity") { Slider(value: $scrollSensitivity, in:0.1...4, step:0.1) @@ -161,6 +167,7 @@ struct SettingsView:View MultiPicker(selection:$layoutSyncHotkey, options: [ Choice("⌃ + Space (macOS default)", LayoutSyncHotkey.ctrlSpace), + Choice("⌃ + ⇧ (macOS alt)", LayoutSyncHotkey.ctrlShift), Choice("⌘ + Space", LayoutSyncHotkey.cmdSpace), Choice("⌥ + Space", LayoutSyncHotkey.altSpace), Choice("Alt + Shift (Windows)", LayoutSyncHotkey.altShift), From fc8357f2f0e1b64dd8649161d65d0ea89e5729ac Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 21:27:56 +0300 Subject: [PATCH 13/40] =?UTF-8?q?Fix=20scroll=20inertia=20tail=20=E2=80=94?= =?UTF-8?q?=20stop=20threshold=20+=20decay=20range=20+=20touchesBegan=20ga?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic from a deep subagent audit: the inertia path was wired and firing, the user just couldn't see the result because the decay loop killed itself almost instantly. Root cause #1 — stop threshold 0.5 pts/frame was an inertia executioner. After seeding `momentumVelocity = peak / 60` (pts/frame), default sensitivity, a normal 200 pts/s scroll seeded ≈ 3.3 pts/frame. With decay 0.89 (strength 0.5 → `0.80 + 0.18 × 0.5`) the loop hit the 0.5 floor in `log(0.5/3.3) / log(0.89) ≈ 17` frames ≈ 270 ms — felt exactly like "no inertia" because iPadOS's own pre-`.ended` deceleration tail had already consumed most of the visible motion. Floor lowered to 0.05 pts/frame — same 200 pts/s scroll now runs ≈ 40 frames ≈ 660 ms of visible glide, which is the macOS-trackpad feel the user was expecting. Root cause #2 — decay range was too tight. 0.80–0.98 compressed the strength slider into "snappy vs slightly more snappy". Widened to 0.90–0.995 so the bottom is still ~10 frames (snappy, no glide) and the top is multi-second glide. Default strength 0.5 lands at 0.9475 (~30-frame half-life ≈ 500 ms). Root cause #3 — touchesBegan killed momentum on .indirectPointer. Every time the iPadOS trackpad cursor crossed a region boundary, or the user lightly rested their palm on the trackpad, the `touchesBegan` override fired `stopScrollMomentum()` regardless of touch type. Gated to fire only on `.direct` and `.pencil` touches. Indirect-pointer touches still reset the mouse accumulator (which is desired) but no longer kill scroll inertia. This also explains why the user complained the scroll felt "ragged with fingers on the trackpad": the wheel-tick output during iPadOS's deceleration tail (where per-event delta is fractional) had Int32 truncation patterns like 1,0,0,1,0,1,1,0,0,1 — bursty at the host. The accumulator-rounding fix (.toNearestOrAwayFromZero) we shipped earlier helps; the touchesBegan gate removes the additional random momentum cancellations that were stacking on top. --- OpenParsec/ParsecViewController.swift | 48 ++++++++++++++++++--------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index dd6ea86..ea54575 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -431,13 +431,22 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { // (see viewDidLoad), so those touches reach this override unobstructed. override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) - // Any new touch (finger, pencil, trackpad click) cancels inertial - // scrolling — matches native iOS UIScrollView behaviour. - stopScrollMomentum() - for touch in touches where touch.type == .indirectPointer { - accumulatedDeltaX = 0.0 - accumulatedDeltaY = 0.0 - break + // Only direct finger / pencil touches should kill scroll inertia — + // .indirectPointer touches (trackpad cursor crossing a region, palm + // rest, etc.) would otherwise cancel momentum mid-glide for no + // user-visible reason. + var killMomentum = false + for touch in touches { + if touch.type == .direct || touch.type == .pencil { + killMomentum = true + } + if touch.type == .indirectPointer { + accumulatedDeltaX = 0.0 + accumulatedDeltaY = 0.0 + } + } + if killMomentum { + stopScrollMomentum() } } @@ -806,12 +815,19 @@ extension ParsecViewController : UIGestureRecognizerDelegate { } } - // Inertia tail. Per-frame decay multiplier is mapped linearly from the - // user's "Inertia Strength" setting: 0 → 0.80 (snappy, ~150 ms), 1 → 0.98 - // (long glide, ~2 s). + // Inertia tail. iPadOS hands us its own deceleration before `.ended` + // fires, so by the time we seed momentum the peak has already been + // partly converted into wheel ticks during `.changed`. The 0.5 pts/frame + // stop floor we used to have killed any seed under ~5 frames of glide — + // that's why "no inertia at all" was the dominant feedback even with + // the toggle on. Floor lowered to 0.05, decay range widened so the + // "Inertia Strength" slider has a clearly distinguishable bottom + // (snappy, ~10 frames) and top (long glide, multi-second). + private static let momentumStopThreshold: Float = 0.05 + private func startScrollMomentum() { - let threshold: Float = 0.5 - if abs(momentumVelocityX) < threshold && abs(momentumVelocityY) < threshold { + if abs(momentumVelocityX) < Self.momentumStopThreshold && + abs(momentumVelocityY) < Self.momentumStopThreshold { return } stopScrollMomentum() @@ -830,12 +846,14 @@ extension ParsecViewController : UIGestureRecognizerDelegate { accumulatedScrollX -= Float(intX) accumulatedScrollY -= Float(intY) } + // Decay map: strength 0 → 0.90 (snappy), strength 1 → 0.995 (long). + // Default strength 0.5 → 0.9475, ~30-frame half-life ≈ 500 ms. let strength = Float(SettingsHandler.scrollMomentumStrength) - let decay: Float = 0.80 + 0.18 * strength + let decay: Float = 0.90 + 0.095 * strength momentumVelocityX *= decay momentumVelocityY *= decay - let threshold: Float = 0.5 - if abs(momentumVelocityX) < threshold && abs(momentumVelocityY) < threshold { + if abs(momentumVelocityX) < Self.momentumStopThreshold && + abs(momentumVelocityY) < Self.momentumStopThreshold { stopScrollMomentum() } } From 5d31035dfd235605c9e282537346ce5046f02732 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 21:29:44 +0300 Subject: [PATCH 14/40] Fix Resolution-menu crash, display persistence, display-switch debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent subagent audits pointed at the same v3/v4 release weeks — all now fixed. Resolution menu crash (subagent #2). On iOS 14.0–14.4 the UIMenu bridge crashes when SwiftUI lowers a `Menu { Button { if Label else Text } }` body to `_ConditionalContent` — the bridge dereferences a nil SF-Symbol glyph view when materialising menu items. Deployment target is iOS 14, so any user on 14.0–14.4 (still common) hits this every time they tap the Resolution menu. Replaced both the Resolution and Bitrate menu rows with a homogeneous `HStack { Text(...) if current { Spacer; Image("checkmark") } }` inside the Button — visually identical, no conditional view wrapper, safe across all iOS 14 dot releases. Display persistence (subagent #3). `didRestoreSavedDisplay` was reset only in `ParsecSDKBridge.disconnect()`. But the dominant reconnect path is "stream errored → user dismisses alert → reconnects" which does NOT route through `disconnect()` — `ParsecSDKBridge` is a singleton, so the flag stayed `true` across sessions and case-12 silently never re-fired the saved-display restore. Moved the reset into `connect()` (alongside `backgroundTaskRunning` and `didSetResolution`). Every fresh connect now attempts restore when case-12 arrives. Display switch debounce (same subagent). User reported needing to tap a display twice or three times for the switch to land. `setVideoConfig` is fire-and-forget at the iOS layer; the host's encoder can be in the middle of a reset triggered by a prior config change and drop the next message that arrives. `updateHostVideoConfig` now resends the same payload after 250 ms (idempotent — re-applying the same output is a no-op on the host when it's idle, but recovers when the first message was dropped). A follow-up `getVideoConfig` at +450 ms asks the host to echo back its current state, so case-11 confirms the switch landed. Both resends gate on `backgroundTaskRunning` to avoid firing into a torn-down session. --- OpenParsec/ParsecSDKBridge.swift | 24 ++++++++++++++++++++++++ OpenParsec/ParsecView.swift | 27 +++++++++++++++++++-------- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index f221af3..7c72cb4 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -123,6 +123,14 @@ class ParsecSDKBridge: ParsecService // of its lifetime. Also acts as the "sending allowed" gate for input // messages, so flip it before any input could possibly fire. backgroundTaskRunning = true + // Every fresh connect() should attempt to restore the saved display + // when case-12 arrives. Resetting here (not only in disconnect()) is + // load-bearing — common reconnect paths (alert dismiss → reconnect, + // background → resume) skip disconnect() entirely, so without this + // the flag stayed `true` from a prior session and the restore was + // silently never re-run. + didRestoreSavedDisplay = false + didSetResolution = false var parsecClientCfg = ParsecClientConfig() parsecClientCfg.video.0.decoderIndex = 1 @@ -654,5 +662,21 @@ class ParsecSDKBridge: ParsecService let encoder = JSONEncoder() let data = try! encoder.encode(videoConfig) CParsec.sendUserData(type: .setVideoConfig, message: data) + // User reports: display switches needed two or three taps to actually + // take effect. setVideoConfig is fire-and-forget; the host can drop + // the message if its encoder is in the middle of a reset triggered + // by a previous request. Re-send the same payload after 250 ms + // (idempotent — same output reapplied is a no-op on the host). + // Then ask the host to echo back its current config so case-11 + // confirms the switch landed. + DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self = self, self.backgroundTaskRunning else { return } + CParsec.sendUserData(type: .setVideoConfig, message: data) + } + DispatchQueue.global().asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self = self, self.backgroundTaskRunning else { return } + let empty = "".data(using: .utf8)! + CParsec.sendUserData(type: .getVideoConfig, message: empty) + } } } diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index d568d39..9160be1 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -272,13 +272,21 @@ struct ParsecView: View } Menu() { ForEach(resolutions, id: \.self) { resolution in + let isCurrent = resolution.width == dataModel.resolutionX && resolution.height == dataModel.resolutionY Button(action: { changeResolution(res: resolution) }) { - if resolution.width == dataModel.resolutionX && resolution.height == dataModel.resolutionY { - Label(resolution.desc, systemImage: "checkmark") - } else { + // iOS 14.0–14.4's UIMenu bridge crashes when SwiftUI lowers a + // `if Label else Text` body inside a Menu's Button to + // `_ConditionalContent`. A single homogeneous + // HStack with a conditional Image is safe and renders the + // same checkmark visual. + HStack { Text(resolution.desc) + if isCurrent { + Spacer(minLength: 8) + Image(systemName: "checkmark") + } } } } @@ -290,14 +298,17 @@ struct ParsecView: View } Menu() { ForEach(bitrates, id: \.self) { bitrate in + let isCurrent = bitrate == dataModel.bitrate Button(action: { changeBitRate(bitrate: bitrate) }) { - if bitrate == dataModel.bitrate { - Label("\(bitrate) Mbps", systemImage: "checkmark") - } else { - Text("\(bitrate) Mbps") - } + HStack { + Text("\(bitrate) Mbps") + if isCurrent { + Spacer(minLength: 8) + Image(systemName: "checkmark") + } + } } } } label: { From 041c7898aa2756a7bfb968e4d85a39d326d7f8e7 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 21:36:18 +0300 Subject: [PATCH 15/40] Fix build: actually add @AppStorage mouseAcceleration declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commits referenced SettingsHandler.mouseAcceleration / without the underlying @AppStorage being declared — Edit operations on the expected adjacent-line position failed silently because localCursorOverlay sits between mouseSensitivity and scrollSensitivity. Three subsequent CI runs (aca422b, fc8357f, 5d31035) were red because of this. Declared in both SettingsHandler (static source of truth) and SettingsView (local view binding for the slider). --- OpenParsec/SettingsHandler.swift | 5 +++++ OpenParsec/SettingsView.swift | 1 + 2 files changed, 6 insertions(+) diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 8e8c144..0e700c9 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -9,6 +9,11 @@ struct SettingsHandler { @AppStorage("cursorMode") public static var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") public static var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") public static var mouseSensitivity: Double = 1.0 + // Non-linear acceleration applied on top of mouseSensitivity. 0 = pure + // linear (fastest gestures travel the same per-pixel as slow ones); up + // to 1.5 = strong macOS-style acceleration where fast flicks travel + // further. Surfaced as a slider in Settings → Interactivity. + @AppStorage("mouseAcceleration") public static var mouseAcceleration: Double = 0.0 // Draw a local arrow cursor on top of the streamed video and skip // rendering the host's own cursor — the local one tracks input // immediately while the host's cursor visually lags by the network RTT. diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index f8b5302..3fadc1a 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -11,6 +11,7 @@ struct SettingsView:View @AppStorage("cursorMode") var cursorMode: CursorMode = .touchpad @AppStorage("cursorScale") var cursorScale: Double = 0.5 @AppStorage("mouseSensitivity") var mouseSensitivity: Double = 1.0 + @AppStorage("mouseAcceleration") var mouseAcceleration: Double = 0.0 @AppStorage("localCursorOverlay") var localCursorOverlay: Bool = false @AppStorage("scrollSensitivity") var scrollSensitivity: Double = 1.0 @AppStorage("naturalScrolling") var naturalScrolling: Bool = true From e92447352d04971905329e60ea1fdefca540ca75 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Tue, 26 May 2026 21:42:22 +0300 Subject: [PATCH 16/40] GCMouse: move local cursor, fix wheel direction, fix x/y wheel swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reports plugging in an external mouse breaks the on-screen cursor in three ways. All three turned out to be in GameController's GCMouse handler — pre-existing bugs plus missing integration with the new local cursor overlay. 1. **Cursor disappeared when external mouse connected.** The mouseMovedHandler called `CParsec.sendMouseDelta` but never updated the local overlay. With Local Cursor Overlay enabled, the host-streamed cursor (`u` UIImageView) was hidden in favour of our local view — but our local view never moved because GCMouse input bypassed `moveLocalCursor`. Visually: no cursor at all. Fixed: the handler now also calls `viewController.moveLocalCursor` if it's a ParsecViewController. Cursor follows GCMouse identically to how it follows the Magic Keyboard trackpad. 2. **Mouse wheel ignored naturalScrolling toggle and scrollSensitivity.** Pre-existing: the GCMouse scroll handlers used raw `value` directly and didn't consult any setting. User reported needing to flip the wheel direction depending on host OS. Fixed: both yAxis and xAxis handlers now multiply by `naturalScrolling ? +1 : -1` and `Float(SettingsHandler.scrollSensitivity)`, matching the trackpad scroll path. 3. **Pre-existing x/y wheel swap.** `mouseInput.scroll.yAxis` (vertical wheel) was sent as `sendWheelMsg(x: value, y: 0)` — i.e. horizontal. `xAxis` was sent as `y`. Vertical scroll showed up as horizontal on the host. Fixed: yAxis now sends as `y`, xAxis as `x`. (This bug existed from the original code and has nothing to do with my recent edits; surfaced because the user finally tried wheel input.) Note on Windows hosts without external mouse: a separate user report ("на Винде, без подключенной мышки - не отображается ни в каком виде курсор") is a Windows-host policy — Windows Parsec hosts often set `cursor.hidden = true` and don't stream a cursor image, leaving the client with nothing to draw. The workaround is to enable `Settings → Interactivity → Local Cursor Overlay` so OpenParsec draws a local cursor regardless of host state. Trackpad input already drives the overlay; with this commit, GCMouse input does too. --- OpenParsec/GameController.swift | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/OpenParsec/GameController.swift b/OpenParsec/GameController.swift index d8e44a7..79cfa23 100644 --- a/OpenParsec/GameController.swift +++ b/OpenParsec/GameController.swift @@ -91,8 +91,9 @@ class GamepadController { func registerMouseHandler() { for mouse in GCMouse.mice() { mice.insert(mouse) - mouse.mouseInput?.leftButton.pressedChangedHandler = {(input: GCControllerButtonInput, v: Float, pressed: Bool) in + mouse.mouseInput?.leftButton.pressedChangedHandler = {[weak self] (input: GCControllerButtonInput, v: Float, pressed: Bool) in CParsec.sendMouseClickMessage(MOUSE_L, pressed) + _ = self // keep reference path symmetrical } mouse.mouseInput?.rightButton?.pressedChangedHandler = {(input: GCControllerButtonInput, v: Float, pressed: Bool) in CParsec.sendMouseClickMessage(MOUSE_R, pressed) @@ -100,14 +101,32 @@ class GamepadController { mouse.mouseInput?.middleButton?.pressedChangedHandler = {(input: GCControllerButtonInput, v: Float, pressed: Bool) in CParsec.sendMouseClickMessage(MOUSE_MIDDLE, pressed) } - mouse.mouseInput?.mouseMovedHandler={(input: GCMouseInput, v: Float, v2: Float) in - CParsec.sendMouseDelta(Int32(v/1.25 * Float(SettingsHandler.mouseSensitivity)), Int32(-v2/1.25 * Float(SettingsHandler.mouseSensitivity))) + mouse.mouseInput?.mouseMovedHandler = {[weak self] (input: GCMouseInput, v: Float, v2: Float) in + let sens = Float(SettingsHandler.mouseSensitivity) + let dx = v / 1.25 * sens + let dy = -v2 / 1.25 * sens // GCMouse Y is inverted vs screen + CParsec.sendMouseDelta(Int32(dx), Int32(dy)) + // Also drive the local cursor overlay — without this, plugging + // an external mouse would freeze the overlay at its last spot + // while the host cursor moved elsewhere (effectively "no + // cursor on the iPad screen"). + if let vc = self?.viewController as? ParsecViewController { + vc.moveLocalCursor(byX: CGFloat(dx), y: CGFloat(dy)) } + } + // Scroll wheel: yAxis = vertical (send as y), xAxis = horizontal + // (send as x). Previously these were swapped — pre-existing bug. + // Also apply naturalScrolling + scrollSensitivity so the wheel + // matches the trackpad's direction setting. mouse.mouseInput?.scroll.yAxis.valueChangedHandler = {(axis: GCControllerAxisInput, value: Float) in - CParsec.sendWheelMsg(x: Int32(value), y: 0) + let dir: Float = SettingsHandler.naturalScrolling ? 1.0 : -1.0 + let sens = Float(SettingsHandler.scrollSensitivity) + CParsec.sendWheelMsg(x: 0, y: Int32(value * sens * dir)) } mouse.mouseInput?.scroll.xAxis.valueChangedHandler = {(axis: GCControllerAxisInput, value: Float) in - CParsec.sendWheelMsg(x: 0, y: Int32(value)) + let dir: Float = SettingsHandler.naturalScrolling ? 1.0 : -1.0 + let sens = Float(SettingsHandler.scrollSensitivity) + CParsec.sendWheelMsg(x: Int32(value * sens * dir), y: 0) } } } From fc6776c609434a8aa1ab6d06da2f78b94ca20fbe Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 00:07:05 +0300 Subject: [PATCH 17/40] Fix GCMouse off-main-thread crash + add crash reporter + CADisplayLink deinit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crash the user has been hitting, plus a way to capture future ones. 1. GCMouse moved the local cursor off the main thread (CRASH). e924473 added `vc.moveLocalCursor(...)` inside `mouseMovedHandler`, but GameController input handlers run on GC's private background queue (no handlerQueue=.main is set). moveLocalCursor → clampAndApplyLocalCursor → `localCursorImageView?.center = ...` is a UIView mutation, which traps when off-main. Moving an external mouse with Local Cursor Overlay enabled would crash. Fixed: the overlay update is now dispatched to the main queue; the CParsec send stays inline (thread-safe, no latency hit). Also gated on `localCursorOverlay` so we don't dispatch needless main-queue work when the overlay is off. 2. CADisplayLink deinit backstop. ParsecViewController now invalidates momentumDisplayLink and stops languageSync in `deinit`. CADisplayLink retains its target; a glide still in flight at an unusual teardown would otherwise keep the controller alive and ticking. 3. Crash reporter (answers the user's "can you send crash reports somewhere" question). AppDelegate installs NSSetUncaughtExceptionHandler + handlers for SIGABRT/SEGV/BUS/ILL/FPE/TRAP. On crash it writes Documents/last_crash.log with the backtrace. On the next launch the log is copied to UIPasteboard (so it syncs to a Mac on the same Apple ID via Universal Clipboard, or can be pasted into a chat) and the file is left in Documents. Info.plist gains UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace so Documents/last_crash.log is browsable in the Files app under "On My iPad → OpenParsec". --- OpenParsec/AppDelegate.swift | 59 +++++++++++++++++++++++++++ OpenParsec/GameController.swift | 18 +++++--- OpenParsec/Info.plist | 4 ++ OpenParsec/ParsecViewController.swift | 12 +++++- 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/OpenParsec/AppDelegate.swift b/OpenParsec/AppDelegate.swift index 2eb006f..a64cd38 100644 --- a/OpenParsec/AppDelegate.swift +++ b/OpenParsec/AppDelegate.swift @@ -1,10 +1,69 @@ import UIKit +// Lightweight crash reporter. Writes the last uncaught exception / fatal +// signal (with a backtrace) to Documents/last_crash.log. On the next launch +// the log is copied to the system pasteboard (so it can be pasted straight +// into a chat, or synced to a Mac via Universal Clipboard) and left in the +// app's Documents folder, which is exposed in the Files app via +// UIFileSharingEnabled. Zero infrastructure required. +enum CrashReporter { + static var logURL: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return docs.appendingPathComponent("last_crash.log") + } + + // MUST stay free of captured context — signal trampolines are C function + // pointers. Only touches statics / the file system / Thread API. + static func record(_ header: String) { + let stamp = ISO8601DateFormatter().string(from: Date()) + var text = "=== OpenParsec crash @ \(stamp) ===\n" + text += header + "\n\nBacktrace:\n" + text += Thread.callStackSymbols.joined(separator: "\n") + text += "\n" + try? text.write(to: logURL, atomically: true, encoding: .utf8) + } + + static func install() { + NSSetUncaughtExceptionHandler { exception in + CrashReporter.record( + "Uncaught NSException: \(exception.name.rawValue)\n" + + "Reason: \(exception.reason ?? "(nil)")\n" + + "User stack:\n" + exception.callStackSymbols.joined(separator: "\n") + ) + } + for sig in [SIGABRT, SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP] { + signal(sig) { s in + CrashReporter.record("Fatal signal: \(s)") + signal(s, SIG_DFL) + raise(s) + } + } + } + + // Returns the pending crash log (if any) and removes it. + static func consumePending() -> String? { + guard let text = try? String(contentsOf: logURL, encoding: .utf8) else { return nil } + try? FileManager.default.removeItem(at: logURL) + return text + } +} + @main class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Install the crash reporter as early as possible so it catches + // failures during the rest of launch too. + CrashReporter.install() + if let crash = CrashReporter.consumePending() { + // Make the previous crash trivially retrievable: copy to the + // pasteboard (syncs to a Mac on the same Apple ID via Universal + // Clipboard) and print it for any attached console. + UIPasteboard.general.string = crash + print("[OpenParsec] Recovered crash log from previous run:\n\(crash)") + } + // Override point for customization after application launch. UTMViewControllerPatches.patchAll() return true diff --git a/OpenParsec/GameController.swift b/OpenParsec/GameController.swift index 79cfa23..f1ea060 100644 --- a/OpenParsec/GameController.swift +++ b/OpenParsec/GameController.swift @@ -105,13 +105,19 @@ class GamepadController { let sens = Float(SettingsHandler.mouseSensitivity) let dx = v / 1.25 * sens let dy = -v2 / 1.25 * sens // GCMouse Y is inverted vs screen + // GCMouse handlers fire on GameController's private background + // queue (no handlerQueue is set to main). CParsec sends are + // thread-safe, but the local-cursor overlay touches UIView + // geometry — that MUST happen on the main thread or UIKit + // traps. Dispatch the overlay update to main; keep the send + // inline so input latency isn't affected. CParsec.sendMouseDelta(Int32(dx), Int32(dy)) - // Also drive the local cursor overlay — without this, plugging - // an external mouse would freeze the overlay at its last spot - // while the host cursor moved elsewhere (effectively "no - // cursor on the iPad screen"). - if let vc = self?.viewController as? ParsecViewController { - vc.moveLocalCursor(byX: CGFloat(dx), y: CGFloat(dy)) + if SettingsHandler.localCursorOverlay { + DispatchQueue.main.async { + if let vc = self?.viewController as? ParsecViewController { + vc.moveLocalCursor(byX: CGFloat(dx), y: CGFloat(dy)) + } + } } } // Scroll wheel: yAxis = vertical (send as y), xAxis = horizontal diff --git a/OpenParsec/Info.plist b/OpenParsec/Info.plist index bc661cd..b7144a1 100644 --- a/OpenParsec/Info.plist +++ b/OpenParsec/Info.plist @@ -39,6 +39,10 @@ LSRequiresIPhoneOS + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index ea54575..7408afc 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -95,7 +95,17 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + deinit { + // CADisplayLink retains its target; without an explicit invalidate + // here a momentum glide still in flight at teardown would keep this + // controller alive and keep ticking. stopScrollMomentum is also + // called from viewWillDisappear, but deinit is the backstop. + momentumDisplayLink?.invalidate() + momentumDisplayLink = nil + languageSync?.stop() + } + func updateImage() { // Optimization: Snap current valus let currentMouseX = CParsec.mouseInfo.mouseX From bff3c9b7ed6e634873f319f5c1a157bbff63da18 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 00:10:09 +0300 Subject: [PATCH 18/40] =?UTF-8?q?Add=20HANDOFF.md=20=E2=80=94=20comprehens?= =?UTF-8?q?ive=20engineering=20handoff=20for=20review=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained document covering every change on fix/trackpad-input: project context, commit timeline, per-subsystem change log, all new settings, user-feedback log, outstanding code-review findings, host-side hard limits, files map, CI/release pipeline, test plan, and recommended next steps. Ready to hand to another AI agent for full review. --- HANDOFF.md | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..02808fb --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,303 @@ +# OpenParsec — Trackpad / Keyboard / Input Overhaul — Engineering Handoff + +> Hand-off document for a reviewing AI agent. Self-contained. Covers every change on +> branch `fix/trackpad-input`, the rationale, the current state, user-reported issues, +> outstanding bugs, and host-side limitations that cannot be fixed client-side. +> Last updated at commit `fc6776c`. + +--- + +## 0. TL;DR for the reviewing agent + +- **Repo:** `2extndd/OpenParsec` (fork of `hugeBlack/OpenParsec`). iPad app, Swift/SwiftUI + UIKit, Parsec SDK (binary `ParsecSDK.framework`, vendored, no headers in tree). Deployment target **iOS 14.0**. +- **Branch:** `fix/trackpad-input`, 17 commits ahead of `cd155f8` (fork's `main`). HEAD = `fc6776c`. +- **What it does:** fixes Magic Keyboard trackpad input (cursor lag + choppy scroll), adds Mac↔iPad keyboard-layout sync, local cursor overlay, mouse acceleration, Windows-host key remap, in-session resolution change, display persistence, a Low-Latency mode, and a crash reporter. +- **Build/CI:** GitHub Actions (`.github/workflows/build.yml`) on `macos-latest`, Xcode 26.2, produces `OpenParsec.ipa`. CI is currently green at HEAD. +- **The user tests on a real iPad M4 + Magic Keyboard, streaming to an M3 MacBook Pro (and sometimes a Windows host).** No Mac/Xcode locally → all builds go through CI; .ipa is sideloaded via Scarlet/eSign (unsigned variant) or AltStore (ad-hoc). +- **Your job (suggested):** verify correctness of the input pipeline, concurrency safety, and the host-protocol assumptions. Highest-risk areas are flagged in §6. + +--- + +## 1. Project & repo context + +OpenParsec is an open-source Parsec client for iPad. Parsec streams a host desktop (Mac/Windows/Linux) to the client over a low-latency UDP protocol (BUD). The client sends input (mouse/keyboard/gamepad) via `ParsecClientSendMessage` and receives video frames + a separately-streamed cursor image + user-data events. + +Key architectural facts the reviewer must hold: + +- **`CParsec`** (`CParsec.swift`) is a static facade over **`ParsecSDKBridge`** (`ParsecSDKBridge.swift`), a singleton (`CParsec.parsecImpl`) that lives for the whole process. State on the bridge (`backgroundTaskRunning`, `didSetResolution`, `didRestoreSavedDisplay`, `mouseInfo`) persists across connect/disconnect cycles. +- **`ParsecViewController`** (`ParsecViewController.swift`, ~1500 lines now) owns the GLKView render surface, all gesture recognizers, the keyboard pipeline, the local cursor overlay, and language sync. It is **persisted across SwiftUI updates** by `ParsecSession` (an `ObservableObject` holding the VC) — see the comment in `ParsecView.swift`. Do not assume it is recreated per-connection. +- **`ParsecView`** (`ParsecView.swift`) is the SwiftUI layer: the in-stream overlay menu (Resolution / Bitrate / Display / Mute / Keyboard / Zoom), the status bar poller (`ParsecStatusBar`), and the connection lifecycle. +- **Input only flows on the main thread** for touch/gesture paths. The **GCMouse / GCController** path (`GameController.swift`) fires on GameController's *private background queue* — this is the source of a class of threading bugs (see §3.8, §6). +- Parsec's iOS SDK keyboard input is **scancode-only** (`MESSAGE_KEYBOARD` + `ParsecKeycode`). There is **no Unicode/text message**. This drove the design of language sync (§3.3) and Windows remap (§3.5). + +--- + +## 2. Branch state & commit timeline + +`git log --oneline cd155f8..HEAD` (oldest → newest): + +| SHA | Title | Theme | +|-----|-------|-------| +| `8277e0d` | Fix trackpad input lag and choppy 2-finger scroll | Bug 1 + Bug 2 baseline | +| `c6f9eb2` | Add Mac↔iPad keyboard layout sync via host hotkey | Language sync | +| `96ae81e` | Trackpad polish: natural scroll, inertia, sensitivity + key capture | Scroll polish + UIKeyCommand | +| `5d2bf2d` | Fix keyboard toolbar regression + scroll inertia + Mac-first labels | Regression fix | +| `5464629` | Persist last-used display + in-session resolution change | Display + resolution | +| `54a96d0` | ci: retrigger workflow | empty commit | +| `de86645` | Apply audit P0/P1/P2/P3 | Big audit batch | +| `4ad1d42` | Fix build: ParsecStatusBar init + local cursor overlay | Build fix + overlay | +| `0bc675d` | Windows host key remap + 4 audit-found bugfixes | Windows remap | +| `2b57e24` | v4 — real bugfixes from user testing | Scroll dir, cursor, reconnect, display | +| `af5261b` | v4 follow-up: loosen language-sync gates + lower inertia threshold | Tuning | +| `aca422b` | Add Mac Ctrl+Shift hotkey + adjustable mouse acceleration | Features (introduced a build break) | +| `fc8357f` | Fix scroll inertia tail — threshold + decay + touchesBegan gate | Inertia tuning | +| `5d31035` | Fix Resolution-menu crash, display persistence, display-switch debounce | Crash + display | +| `041c789` | Fix build: actually add @AppStorage mouseAcceleration | Build fix | +| `e924473` | GCMouse: move local cursor, fix wheel direction, fix x/y wheel swap | External mouse | +| `fc6776c` | Fix GCMouse off-main crash + crash reporter + CADisplayLink deinit | Crash fixes | + +**8 files changed, ~1316 insertions / 72 deletions.** Heaviest: `ParsecViewController.swift` (~827 lines added). + +> Note for the reviewer: several commits fixed build breaks introduced by earlier commits in the same series (`aca422b`→`041c789`, `5d2bf2d`'s `inputAccessoryView` read-only override→`4ad1d42`). The series was authored without a local compiler — only CI validated. Treat the *final* state at `fc6776c` as the truth; intermediate commits may contain code that was later corrected. + +--- + +## 3. Every change, by subsystem + +### 3.1 Trackpad cursor lag — Bug 1 (issue #47) + +**Symptom:** cursor in the stream lagged/juddered while moving a finger on the Magic Keyboard trackpad. + +**Root cause:** the main `panGestureRecognizer` had no `allowedTouchTypes` filter, so it ingested `.indirectPointer` UITouches (iPad trackpad/pointer, raw type 3). `UIPanGestureRecognizer` imposes a small movement threshold before `.began` and re-arms its state machine between strokes; at the per-frame trackpad event rate that produces visible stickiness. + +**Fix (`ParsecViewController.swift`, `viewDidLoad`):** +- `panGestureRecognizer.allowedTouchTypes = [NSNumber(.direct.rawValue), NSNumber(.pencil.rawValue)]` — excludes `.indirectPointer`. +- Added `override func touchesMoved(...)` handling `.indirectPointer` touches directly via `preciseLocation(in:) - precisePreviousLocation(in:)`, sub-pixel accumulation through `accumulatedDeltaX/Y`. `cursorMode == .direct` → `sendMousePosition`; otherwise `sendMouseDelta`. +- `touchesBegan` resets accumulators on `.indirectPointer`; `touchesEnded`/`touchesCancelled` too. +- `prefersPointerLocked = true` (pre-existing) is what makes iPad deliver trackpad motion as `.indirectPointer` touches. + +### 3.2 Trackpad 2-finger scroll + inertia — Bug 2 + +**Symptom:** choppy, stepped 2-finger scroll; later "no inertia at all". + +**Root cause(s):** +- Original 2-finger branch used `velocity(in:)/20` → large irregular wheel deltas. +- Later inertia attempt had a stop threshold (`0.5` pts/frame) that killed the glide in ~270 ms. +- Scroll accumulator used `Int32()` truncation → sub-pixel ticks swallowed. +- `touchesBegan` killed momentum on every touch incl. `.indirectPointer`. + +**Fix (`ParsecViewController.swift`):** +- Dedicated `UIPanGestureRecognizer` with `allowedScrollTypesMask = .all`, `maximumNumberOfTouches = 0` → only scroll-wheel/trackpad-scroll events. Handler `handleTrackpadScroll` uses `translation(in:)` deltas. +- Scroll accumulator now `.rounded(.toNearestOrAwayFromZero)`. +- Peak-velocity tracking during `.changed` (the recognizer's `velocity` is decayed to ~0 by iPad's own deceleration before `.ended`); peak reset only after a >1.0 s gap. +- Momentum via `CADisplayLink`: stop threshold `0.05` pts/frame, decay `0.90 + 0.095*strength` (strength 0..1 from `scrollMomentumStrength`). +- `touchesBegan` kills momentum only on `.direct`/`.pencil`. +- `naturalScrolling ? +1 : -1` direction sign (ON = no client-side invert, matching macOS default Natural Scrolling). + +> **Reviewer caution (§6):** the "best-practices" research concluded iPadOS auto-synthesizes momentum phases on the pan recognizer, and apps like Moonlight do **not** implement client-side inertia. The current code does client-side `CADisplayLink` inertia. This works but is non-canonical; a future refactor may remove it. Verify the current decay parameters don't double-apply on top of iPad's own deceleration tail. + +### 3.3 Mac↔iPad keyboard layout sync + +**Goal:** when the user toggles the iPad's hardware-keyboard input language (Caps Lock / Ctrl+Space), the host's input source should follow. + +**Constraint:** Parsec iOS SDK is scancode-only — no way to send composed Unicode. So we cannot bypass host layout; instead we fire a configurable hotkey at the host to make *it* switch. + +**Mechanics (`ParsecViewController.swift`, `LanguageSyncCoordinator` + `LanguageSyncTextField`):** +- A 1×1, alpha-0 `LanguageSyncTextField` (UITextField subclass) is added to the view and made first responder. Needed because `UITextInputMode.currentInputModeDidChangeNotification` only fires when a text-input first responder exists. +- The field installs an empty `inputView` (suppress soft keyboard) and an empty non-nil `inputAccessoryView` (halt the responder-chain walk so the VC's keyboard toolbar does NOT appear by default — this was a regression fixed in `5d2bf2d`). +- The field forwards `pressesBegan/Ended/Changed/Cancelled` to the VC **without calling `super`** — hardware-keyboard scancodes keep flowing through the existing `pressesBegan` pipeline (same trick Moonlight uses). +- On a real language change, `sendLayoutSyncHotkey()` fires the configured chord via `CParsec.sendVirtualKeyboardInput`. Options: Ctrl+Space (default, macOS), Ctrl+Shift, Cmd+Space, Opt+Space, Alt+Shift (Windows), Off. +- Coordinator yields FR before the VC becomes FR (soft keyboard via 3-finger tap / button) and reclaims after. + +> **Reviewer caution (§6):** the host only switches if it has the matching shortcut bound (macOS Sequoia default for "Select previous input source" is NOT Ctrl+Space). This is the dominant reason the user perceives "sync doesn't work" — it is a host-config issue, not necessarily a client bug. Also verify: (a) hidden field reliably reclaims FR after the soft keyboard is dismissed via OS routes that bypass `setKeyboardVisible(false)`; (b) the initial-seed logic doesn't fire a spurious hotkey at session start for users with 3+ layouts. + +### 3.4 System-shortcut capture (UIKeyCommand registry) + +**Goal:** let Cmd+letter shortcuts (Cmd+A/C/V/Z/S…) reach the host instead of being eaten by the iPad shell. + +**Mechanics:** `override var keyCommands` returns a cached (`static var _cachedKeyCommands`) list of ~286 `UIKeyCommand`s — `(a–z, 0–9, punctuation) × (Cmd, Cmd+Shift, Cmd+Opt, Cmd+Ctrl, Opt, Opt+Shift)` + Cmd+(Tab/Space/Enter/`). On iOS 15+, `wantsPriorityOverSystemBehavior = true`. `handleCapturedKey` translates to a modifier-press / key / release scancode sequence (synchronous in Low-Latency mode, async +20/+60 ms otherwise). + +**Hard limit:** Cmd+Space (Spotlight), Cmd+H, Cmd+Tab, Globe key, swipe-up — wired below the responder chain in SpringBoard; **no sandboxed app can intercept them**. + +### 3.5 Windows host key remap + +`SettingsHandler.windowsHostKeyboardRemap` (default off). When on, `ParsecSDKBridge.remapKeyForHostIfNeeded` swaps scancodes at the lowest layer (so every input path inherits it): `227 LGUI ↔ 224 LCTRL`, `231 RGUI ↔ 228 RCTRL`. Opt (226/230) and Shift (225/229) untouched. So Cmd+C on the iPad arrives as Ctrl+C on Windows. + +### 3.6 Local cursor overlay + +`SettingsHandler.localCursorOverlay` (default off). Draws a 13 pt iPadOS-style gray dot (`UIView` with cornerRadius/border/shadow) on `contentView`, tracked client-side from input deltas (no host RTT). When on, `updateImage` hides the host-streamed cursor (`u`). Seeded at `contentView.center` in `viewDidLayoutSubviews` (one-shot via `hasSeededLocalCursor`, because `viewDidLoad` sees zero bounds). Also useful as a workaround when a Windows host doesn't stream a cursor image at all. + +### 3.7 Mouse acceleration + +`SettingsHandler.mouseAcceleration` (0…1.5, default 0 = linear). `effectiveDeltaScale(rawDX:rawDY:)` returns `sensitivity + accel × (|delta|/5)` — fast flicks travel further. Applied in both `touchesMoved` (.indirectPointer) and `handlePanGesture` (touchscreen) touchpad branches. + +> **Reviewer note:** best-practices research recommends linear + let the host apply its own curve. The acceleration here is a client-side curve stacked on top of the host's. Default 0 keeps it off; only opt-in users get the stacked curve. + +### 3.8 External mouse (GCMouse) — `GameController.swift` + +- `mouseMovedHandler` sends `sendMouseDelta`; **the local-cursor overlay update is dispatched to `DispatchQueue.main`** because GCMouse handlers run on GC's private background queue (touching `UIView.center` off-main traps — this was the crash fixed in `fc6776c`). +- Scroll: `yAxis`→y, `xAxis`→x (fixed a pre-existing x/y swap), with `naturalScrolling` sign + `scrollSensitivity`. +- Magic Keyboard trackpad does **not** enumerate as GCMouse — this path is only for external USB/BT mice. + +### 3.9 In-session resolution change — `ParsecView.changeResolution` + +**Constraint discovered:** Parsec host honours bitrate / FPS / output via `setVideoConfig` user-data, but **not resolution** — resolution is only read at `ParsecClientConnect`. So `changeResolution` does a clean disconnect + 600 ms gap + reconnect with new `ParsecClientConfig`. +- `isReconfiguring` guard prevents re-entry (spam-tap). +- Suppresses the status-bar disconnect alert during the gap; shows a "Switching resolution…" overlay; pauses GLKViewController (`isPaused = true`) so the last frame stays on screen instead of going black. +- Branches on `connect()` status — surfaces a real "Reconnect failed" alert if the host went away. + +> **Reviewer caution (§7):** even at connect, a macOS host with a single physical display ignores small resolution requests (no virtual display). The user reported 1920×1080 in Settings is ignored — this is host behavior, not a client bug. Bitrate/H.265 are the working bandwidth levers. + +### 3.10 Display selection persistence — `ParsecView.changeDisplay` + `ParsecSDKBridge` case 12 + +- `SettingsHandler.savedDisplayOutput` (id) + `savedDisplayName` (name+adapter fallback, because Parsec regenerates display ids across sessions). +- `handleUserDataEvent case 12` restores once per session (`didRestoreSavedDisplay`, reset in **both** `connect()` and `disconnect()` — the connect() reset was the key fix because reconnect paths bypass disconnect). +- `updateHostVideoConfig` resends the payload at +250 ms and a `getVideoConfig` at +450 ms — the host can drop a `setVideoConfig` mid-encoder-reset, which is why display switches needed multiple taps. + +### 3.11 Low Latency Mode + latency reductions + +`SettingsHandler.lowLatencyMode` toggle flips `preferredFramesPerSecond = 0` (device max), `decoder = h265`, `noOverlay = true`, and gates the 20/60 ms captured-key holds. Independently: +- `preferredFramesPerSecond` default changed 60 → **0** (was capping 120 Hz iPads at 60 → doubled present latency). +- `startBackgroundTask` poll timeout scales with FPS; QoS raised to `.userInteractive`. +- `ParsecGLKRenderer` gates PiP `captureFrame` on `isPiPActive || isStarting`. +- Mouse accumulator rounding (no event coalescing on slow drags). + +### 3.12 Reconnect UX + send gating + +- All `ParsecSDKBridge` send methods early-return on `!backgroundTaskRunning` (the gate is set true in `connect()`, false in `disconnect()`). Prevents `ParsecClientSendMessage` into a torn-down client during the reconnect gap. +- `disconnect()` sleeps 20 ms to drain the two poll loops before a fast reconnect spawns new ones. + +### 3.13 Crash reporter — `AppDelegate.swift` + +`CrashReporter.install()` sets `NSSetUncaughtExceptionHandler` + signal handlers (SIGABRT/SEGV/BUS/ILL/FPE/TRAP). Writes `Documents/last_crash.log` with a backtrace. On next launch: copies the log to `UIPasteboard` (syncs to a Mac via Universal Clipboard) and leaves the file in Documents (browsable via Files app — `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` added to Info.plist). + +### 3.14 Concurrency / lifecycle hardening + +- `ParsecViewController.deinit` invalidates `momentumDisplayLink` and stops `languageSync`. +- `CADisplayLink` always invalidated before a new one is created in `startScrollMomentum`. +- `keyCommands` cached (was rebuilding 286 objects per query). + +--- + +## 4. New settings (`SettingsHandler.swift` @AppStorage keys) + +| Key | Type | Default | Surfaced in SettingsView | +|-----|------|---------|--------------------------| +| `mouseAcceleration` | Double | 0.0 | Interactivity | +| `localCursorOverlay` | Bool | false | Interactivity | +| `scrollSensitivity` | Double | 1.0 | Interactivity | +| `naturalScrolling` | Bool | true | Interactivity | +| `scrollMomentum` | Bool | true | Interactivity | +| `scrollMomentumStrength` | Double | 0.5 | Interactivity | +| `captureSystemKeys` | Bool | true | Keyboard | +| `windowsHostKeyboardRemap` | Bool | false | Keyboard | +| `syncKeyboardLayout` | Bool | true | Keyboard | +| `layoutSyncHotkey` | LayoutSyncHotkey | .ctrlSpace | Keyboard | +| `lowLatencyMode` | Bool | false | Graphics | +| `preferredFramesPerSecond` | Int | **0** (was 60) | Graphics | +| `savedDisplayOutput` | String | "" | (internal) | +| `savedDisplayName` | String | "" | (internal) | + +> **Pre-existing bug, NOT introduced here, but worth flagging:** `SettingsHandler.swift` reuses the key `"cursorScale"` for both `cursorScale: Double` AND `hideStatusBar: Bool`. They collide in UserDefaults. Left untouched to keep the diff scoped, but a reviewer may want to fix it (`hideStatusBar` should have its own key). + +--- + +## 5. User feedback log (chronological, paraphrased) + +1. ✅ Trackpad cursor lag fixed (confirmed by user). +2. "Screen moves when scrolling" → was the keyboard accessory toolbar showing by default (language-sync FR regression). Fixed (`5d2bf2d`). +3. Couldn't install .ipa ("integrity") → sideloader cert / bundle-id; solved by unsigned + unique-bundle-id variants. +4. Natural-scroll toggle inverted / didn't work → sign flipped (`2b57e24`); scroll accumulator rounding (`fc8357f`). +5. Local cursor "crooked, doesn't move" → SF-Symbol arrow → UIView dot, seed in `viewDidLayoutSubviews` (`2b57e24`). +6. Display selection not remembered → `didRestoreSavedDisplay` reset in connect (`5d31035`). +7. Resolution change → "Disconnected 20" → 600 ms reconnect gap (`2b57e24`/`5d31035`). +8. Resolution in Settings ignored → **host-side limitation** (§7), not fixed client-side. +9. Language switch doesn't work → loosened gates (`af5261b`); likely also host-shortcut config (§7). +10. Scroll "no inertia at all", "ragged with fingers on trackpad" → threshold/decay/touchesBegan fixes (`fc8357f`). +11. Resolution menu **crashes** → iOS 14.0–14.4 UIMenu `_ConditionalContent` bug; fixed with HStack (`5d31035`). +12. Add Ctrl+Shift hotkey ✅ (`aca422b`). Add mouse acceleration ✅ (`aca422b`/`041c789`). +13. External mouse → cursor disappears / wrong wheel direction / no cursor on Windows → GCMouse fixes (`e924473`), off-main crash fix (`fc6776c`). +14. "App crashes during scroll / in general" → most likely the GCMouse off-main UIView mutation (`fc6776c`) and/or the iOS-14 Resolution-menu crash (`5d31035`). **Awaiting a crash log** from the new crash reporter to confirm. + +--- + +## 6. Outstanding bugs / open code-review findings + +A 5-angle code review was started but the finder sub-agents hit a session limit before completing. The reviewer-author's own confirmed/plausible findings: + +1. **(FIXED `fc6776c`) [HIGH]** GCMouse `moveLocalCursor` off-main → UIView mutation crash. +2. **[MED, OPEN]** `updateHostVideoConfig` schedules 2 async resends per call. Rapid bitrate-slider drags stack many resends + `getVideoConfig` echoes; the case-11 echo writes `DataManager.model.bitrate` back to the host's reported value, which could snap the slider mid-drag. *Suggested fix:* debounce `updateHostVideoConfig` with a token/timestamp; skip resend if superseded. +3. **[MED, OPEN]** Language-sync hidden field may not reclaim first responder if the soft keyboard is dismissed via an OS route that doesn't call `setKeyboardVisible(false)`. *Verify:* does `keyboardWillHide` → `onKeyboardVisibilityChanged` → `setKeyboardVisible(false)` cover swipe-to-dismiss and hardware Esc? +4. **[LOW, OPEN]** Two identical external monitors collide on the name fallback (`changeDisplay`/case 12 name match picks first). Id-match runs first, so only matters when ids roll AND monitors are identical. +5. **[LOW, OPEN]** `scrollMomentumTick` and `handleTrackpadScroll` both touch `momentumVelocity*` — both on main, so not a true race, but confirm no path schedules the tick off-main. +6. **[LOW, OPEN]** Spurious Ctrl+Space at session start possible for 3+ layout users (initial-seed nil → first notification fires). Accepted trade-off; revisit if reported. +7. **[INFO]** Client-side scroll inertia is non-canonical (iPadOS provides momentum phases). Consider removing in favor of forwarding native momentum events (Moonlight approach). Would also remove the `scrollMomentumStrength` tuning surface. +8. **[INFO]** Mouse acceleration stacks a client curve on the host's curve. Default off mitigates. + +--- + +## 7. Hard constraints — cannot be fixed client-side + +1. **Resolution downscale on macOS hosts.** Parsec captures the physical display at native resolution; `parsecClientCfg.video.0.resolutionX/Y` is advisory and ignored without a virtual-display driver (BetterDummy etc.). Bitrate + H.265 are the real bandwidth levers. +2. **Hiding the cursor on the host's own physical display.** No client→host cursor message exists in the Parsec SDK; macOS draws the cursor before Parsec captures the frame. Requires a host-side helper (e.g. Hammerspoon `CGDisplayHideCursor` bound to a hotkey the iPad sends). +3. **System shortcuts** Cmd+Space / Cmd+H / Cmd+Tab / Globe / swipe-up — SpringBoard-level, not interceptable. Workaround: Windows-host remap, or Opt-based combos. +4. **Layout sync requires host config.** The host must have the chosen hotkey bound to "Select previous input source". macOS Sequoia does not bind Ctrl+Space by default. + +--- + +## 8. Files reference map + +| File | What changed | +|------|--------------| +| `OpenParsec/ParsecViewController.swift` | Trackpad cursor (`touchesMoved`), scroll + inertia (`handleTrackpadScroll`, momentum CADisplayLink), language sync (`LanguageSyncCoordinator`, `LanguageSyncTextField`), key capture (`keyCommands`, `handleCapturedKey`), local cursor overlay, mouse acceleration (`effectiveDeltaScale`), layout-sync hotkey sender, deinit. | +| `OpenParsec/ParsecSDKBridge.swift` | Send gates (`backgroundTaskRunning`), `remapKeyForHostIfNeeded`, `didRestoreSavedDisplay`, case-12 restore, `updateHostVideoConfig` resend, `connect`/`disconnect` state resets, poll-thread QoS/timeout, `applyConfig` resolution. | +| `OpenParsec/ParsecView.swift` | Resolution/Bitrate menu HStack fix, `changeResolution` reconnect flow, `changeDisplay` persist, `ParsecStatusBar` `isReconfiguring` gating + overlay. | +| `OpenParsec/SettingsHandler.swift` | All new @AppStorage keys. | +| `OpenParsec/SettingsView.swift` | UI for all new settings. | +| `OpenParsec/CParsec.swift` | `lastConnectedPeerID` lifecycle. | +| `OpenParsec/GameController.swift` | GCMouse: local-cursor (main-dispatched), wheel direction/sensitivity, x/y swap fix. | +| `OpenParsec/ParsecGLKRenderer.swift` | PiP captureFrame gate. | +| `OpenParsec/AppDelegate.swift` | `CrashReporter`. | +| `OpenParsec/Info.plist` | `UIFileSharingEnabled`, `LSSupportsOpeningDocumentsInPlace`. | + +--- + +## 9. Build, CI, signing, release + +- **CI:** `.github/workflows/build.yml`, `macos-latest`, Xcode 26.2 → `xcodebuild archive ... CODE_SIGNING_ALLOWED=NO` → fake-sign with `ldid` on Ubuntu → `OpenParsec.ipa` artifact. (Fork required Actions to be manually enabled once.) +- **Releases** are produced manually by the author: download the CI `.ipa`, then for sideloader compatibility produce four variants per release tag: + - `OpenParsec-vN.ipa` — CI original (Linux `ldid` fake-sign, bundle `com.aigch.OpenParsec`). + - `OpenParsec-vN-unsigned.ipa` — signature stripped, original bundle id (Scarlet/eSign re-sign cleanly). + - `OpenParsec-vN-trackpadfix.ipa` — unique bundle id `com.2extndd.openparsec.trackpadfix`, macOS ad-hoc signed (AltStore/SideStore). + - `OpenParsec-vN-trackpadfix-unsigned.ipa` — unique bundle id, unsigned (parallel install). + - `CFBundleVersion` is bumped per release to defeat sideloader caches. +- **Upstream PR:** `hugeBlack/OpenParsec#70` tracks the branch. + +--- + +## 10. Test plan (manual QA, per feature) + +**Trackpad:** cursor smooth on slow + fast finger moves; no judder at gesture start. 2-finger scroll smooth; lift → visible inertia glide (~0.5 s default); toggle Inertia off → stops dead. Natural Scrolling on = swipe-down moves content down. + +**Local cursor:** enable overlay → gray dot appears centered, follows finger with no RTT; host cursor hidden. Plug external mouse → dot follows mouse (no crash). Zoom in (pinch) → scroll pans locally, dot scales with content. + +**Keyboard:** type normally (no double chars, no stuck toolbar). Cmd+A/C/V/Z in a host text app. Caps-Lock layout toggle → host switches (requires host shortcut bound). Windows remap on → Cmd+C = copy on Windows. 3-finger tap → soft keyboard + toolbar; Done → toolbar gone, language sync still live. + +**Resolution:** open overlay → Resolution menu (must NOT crash on iOS 14.x). Pick a value → "Switching resolution…" overlay, last frame frozen (no black), reconnect ≤ ~1 s, no false Disconnected alert. + +**Display:** multi-display host → pick display (switches on first tap). Disconnect, reconnect → same display restored. Kill app, relaunch, reconnect → still restored. + +**Latency:** Low Latency Mode on → metrics overlay shows device-max FPS (120 on ProMotion). Cmd shortcuts feel instant. + +**Crash reporter:** force a crash (if repro known) → relaunch → crash log in clipboard + in Files app (On My iPad → OpenParsec → last_crash.log). + +--- + +## 11. Recommended next steps for the reviewing agent + +1. **Resolve the finder-agent code review** (§6) — especially #2 (bitrate resend pile-up) and #3 (FR reclaim). These are the most likely remaining real bugs. +2. **Confirm GCMouse fix** (`fc6776c`) actually serializes all `localCursorImageView` access to main (also check the scroll x/y handlers — they only call `CParsec.sendWheelMsg`, which is SDK-thread-safe, so they're fine off-main). +3. **Decide on the inertia architecture** — keep client-side `CADisplayLink` (current) or switch to forwarding iPadOS momentum phases (canonical). The user has repeatedly reported inertia feel issues; the canonical approach may end the back-and-forth. +4. **Verify language-sync reliability** end-to-end with a host that has the shortcut bound — separate "client didn't fire" from "host didn't act". +5. **Consider fixing the `cursorScale`/`hideStatusBar` UserDefaults key collision** (§4) — pre-existing but trivial. +6. **Once a real crash log arrives**, symbolicate and confirm whether the remaining crashes are the GCMouse path (now fixed) or something else. From 5f07b8759ccb27c60077763ee6e8e65163ff27ca Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 00:18:52 +0300 Subject: [PATCH 19/40] Make crash log retrievable on demand: non-destructive peek + Settings button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crash reporter shipped in fc6776c only copied the log to the pasteboard once, at launch — invisible if the user didn't know to check. Made it discoverable: - CrashReporter.consumePending() (delete-on-read) → peek() (non-destructive) + a separate clear(). The log file now survives until the next crash overwrites it or the user clears it. - AppDelegate launch still copies to pasteboard but no longer deletes. - SettingsView → Misc → "Last Crash Log" row: shows "None" when no log exists, "Copy" when one does; tapping copies it to the clipboard and flips to "Copied!". This is the in-app, discoverable path to retrieve a crash without relying on the launch-time clipboard copy or digging in the Files app. --- OpenParsec/AppDelegate.swift | 19 +++++++++++++------ OpenParsec/SettingsView.swift | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/OpenParsec/AppDelegate.swift b/OpenParsec/AppDelegate.swift index a64cd38..919bf77 100644 --- a/OpenParsec/AppDelegate.swift +++ b/OpenParsec/AppDelegate.swift @@ -40,12 +40,17 @@ enum CrashReporter { } } - // Returns the pending crash log (if any) and removes it. - static func consumePending() -> String? { - guard let text = try? String(contentsOf: logURL, encoding: .utf8) else { return nil } - try? FileManager.default.removeItem(at: logURL) + // Non-destructive read so a Settings "Copy Last Crash Log" action can + // retrieve it at any time. The file is overwritten on the next crash and + // can be cleared explicitly via clear(). + static func peek() -> String? { + guard let text = try? String(contentsOf: logURL, encoding: .utf8), !text.isEmpty else { return nil } return text } + + static func clear() { + try? FileManager.default.removeItem(at: logURL) + } } @main @@ -56,10 +61,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate // Install the crash reporter as early as possible so it catches // failures during the rest of launch too. CrashReporter.install() - if let crash = CrashReporter.consumePending() { + if let crash = CrashReporter.peek() { // Make the previous crash trivially retrievable: copy to the // pasteboard (syncs to a Mac on the same Apple ID via Universal - // Clipboard) and print it for any attached console. + // Clipboard) and print it for any attached console. The file is + // kept (not consumed) so the Settings "Copy Last Crash Log" row + // can re-surface it later. UIPasteboard.general.string = crash print("[OpenParsec] Recovered crash log from previous run:\n\(crash)") } diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 3fadc1a..abee7fc 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -29,6 +29,7 @@ struct SettingsView:View @AppStorage("syncKeyboardLayout") var syncKeyboardLayout: Bool = true @AppStorage("layoutSyncHotkey") var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace @AppStorage("saveSessionSettings") var saveSessionSettings: Bool = true + @State private var crashCopied: Bool = false let resolutionChoices: [Choice] @@ -263,6 +264,21 @@ struct SettingsView:View Toggle("", isOn:$saveSessionSettings) .frame(width:80) } + CatItem("Last Crash Log") + { + Button(action: { + if let crash = CrashReporter.peek() { + UIPasteboard.general.string = crash + crashCopied = true + } else { + UIPasteboard.general.string = "(no crash recorded)" + crashCopied = true + } + }) { + Text(crashCopied ? "Copied!" : (CrashReporter.peek() == nil ? "None" : "Copy")) + .foregroundColor(CrashReporter.peek() == nil ? .gray : Color("AccentColor")) + } + } } Text(getVersionInfo()) .multilineTextAlignment(.center) From f62d361412808af5e037053d088a5edba7636117 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 02:43:16 +0300 Subject: [PATCH 20/40] Fix black screen on return (S01) + concurrency/crash hardening (S02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S01 — render resume / black screen on screen return: - ParsecGLKViewController.resume(): setCurrentContext -> isPaused=false (last) -> setNeedsDisplay, the idempotent self-healing recovery for any path that left the render loop paused (changeResolution, PiP, background). - cleanUp() now actually pauses (was an empty stub); glkView pinned with an autoresizing mask so it can't desync / zero-size on re-parent. - viewDidAppear and willEnterForegroundNotification both call resume(). S02 — concurrency & crash hardening: - C1 use-after-free: mouseInfo (holds a CGImage) was written off-main (poll thread + input paths) and copied on main; non-atomic ARC retain/release on cursorImg over-released it. Now guarded by os_unfair_lock — readers take an atomic snapshot, writers mutate via withMouseInfo. updateImage consumes one snapshot per frame. - C2 async-signal-unsafe crash handler: the signal trampoline allocated (ISO8601DateFormatter, String interpolation, backtrace_symbols->malloc, write(to:atomically:)) and could deadlock on the malloc lock. Signal path now uses only async-signal-safe primitives (open/write/backtrace_symbols_fd/close/ raise) with all buffers pre-allocated in install(). NSException handler keeps the rich Foundation path (normal context); a following SIGABRT appends. - C3 poll-loop teardown race: disconnect() drains via a 0.02s sleep, so a fast reconnect could leave two generations of poll loops on one client. Added a monotonic pollGeneration captured per loop; loops exit when it advances. - C5 audio_destroy use-after-free: freed ctx then dereferenced ctx->q to free silence_buf on an already-disposed queue. Now frees silence_buf while the queue is alive and before free(ctx). Verification: code-level only — no local iOS SDK (Command Line Tools only). Requires CI build + on-device test (stream -> background/change-res/PiP -> return repaints; stress cursor motion for crashes; force crash writes log). Co-Authored-By: Claude Opus 4.7 --- OpenParsec/AppDelegate.swift | 73 +++++++++++- OpenParsec/ParsecGLKViewController.swift | 25 +++- OpenParsec/ParsecSDKBridge.swift | 143 ++++++++++++++++------- OpenParsec/ParsecViewController.swift | 46 ++++++-- OpenParsec/audio.c | 12 +- 5 files changed, 239 insertions(+), 60 deletions(-) diff --git a/OpenParsec/AppDelegate.swift b/OpenParsec/AppDelegate.swift index 919bf77..4e9f840 100644 --- a/OpenParsec/AppDelegate.swift +++ b/OpenParsec/AppDelegate.swift @@ -1,4 +1,5 @@ import UIKit +import Darwin // Lightweight crash reporter. Writes the last uncaught exception / fatal // signal (with a backtrace) to Documents/last_crash.log. On the next launch @@ -12,8 +13,23 @@ enum CrashReporter { return docs.appendingPathComponent("last_crash.log") } - // MUST stay free of captured context — signal trampolines are C function - // pointers. Only touches statics / the file system / Thread API. + // ---- Async-signal-safe state, all pre-allocated in install() ---- + // The signal handler must never call malloc, Foundation, or Swift String: + // if the crashing thread already holds the malloc lock (the common case for + // SIGSEGV/SIGABRT), any allocation inside the handler deadlocks and the log + // never gets written — exactly the failure that made crashes undiagnosable. + // So everything the handler touches is allocated up front here. + private static var logPathC: UnsafeMutablePointer? = nil // strdup'd log path + private static var headerPrefixC: UnsafeMutablePointer? = nil // "=== OpenParsec fatal signal " + private static var headerSuffixC: UnsafeMutablePointer? = nil // " ===\nBacktrace:\n" + private static let backtraceCapacity: Int32 = 128 + private static var backtraceBuffer: UnsafeMutablePointer? = nil + // Set to 1 by the NSException handler so a following SIGABRT (from the + // uncaught-exception abort) appends instead of clobbering the richer log. + private static var exceptionRecorded: sig_atomic_t = 0 + + // Rich recorder for the NSException path ONLY. Runs in normal context + // (not a signal trampoline), so Foundation/allocation is safe here. static func record(_ header: String) { let stamp = ISO8601DateFormatter().string(from: Date()) var text = "=== OpenParsec crash @ \(stamp) ===\n" @@ -21,9 +37,58 @@ enum CrashReporter { text += Thread.callStackSymbols.joined(separator: "\n") text += "\n" try? text.write(to: logURL, atomically: true, encoding: .utf8) + exceptionRecorded = 1 + } + + // Async-signal-safe write of a decimal Int32 using a stack buffer only — + // no heap, so it is safe to call from the signal handler. + private static func writeInt(_ fd: Int32, _ value: Int32) { + withUnsafeTemporaryAllocation(of: CChar.self, capacity: 12) { p in + var i = 12 + var n = value + let negative = n < 0 + if negative { n = -n } + if n == 0 { i -= 1; p[i] = CChar(48) } // '0' + while n > 0 { i -= 1; p[i] = CChar(48 + Int(n % 10)); n /= 10 } + if negative { i -= 1; p[i] = CChar(45) } // '-' + _ = write(fd, p.baseAddress! + i, 12 - i) + } + } + + // THE SIGNAL HANDLER. Async-signal-safe primitives only: open, write, + // backtrace, backtrace_symbols_fd (writes straight to the fd, no malloc), + // close, signal, raise. + private static func handleSignal(_ s: Int32) { + if let path = logPathC { + // Append (don't truncate) if the exception handler already wrote a + // richer log before abort()ing into this signal. + let flags = exceptionRecorded != 0 + ? (O_WRONLY | O_CREAT | O_APPEND) + : (O_WRONLY | O_CREAT | O_TRUNC) + let fd = open(path, flags, 0o644) + if fd >= 0 { + if let h = headerPrefixC { _ = write(fd, h, strlen(h)) } + writeInt(fd, s) + if let h = headerSuffixC { _ = write(fd, h, strlen(h)) } + if let buf = backtraceBuffer { + let frames = backtrace(buf, backtraceCapacity) + backtrace_symbols_fd(buf, frames, fd) + } + _ = fsync(fd) + close(fd) + } + } + signal(s, SIG_DFL) + raise(s) } static func install() { + // Pre-allocate everything the signal handler will touch. + logPathC = strdup(logURL.path) + headerPrefixC = strdup("=== OpenParsec fatal signal ") + headerSuffixC = strdup(" ===\nBacktrace:\n") + backtraceBuffer = UnsafeMutablePointer.allocate(capacity: Int(backtraceCapacity)) + NSSetUncaughtExceptionHandler { exception in CrashReporter.record( "Uncaught NSException: \(exception.name.rawValue)\n" + @@ -33,9 +98,7 @@ enum CrashReporter { } for sig in [SIGABRT, SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP] { signal(sig) { s in - CrashReporter.record("Fatal signal: \(s)") - signal(s, SIG_DFL) - raise(s) + CrashReporter.handleSignal(s) } } } diff --git a/OpenParsec/ParsecGLKViewController.swift b/OpenParsec/ParsecGLKViewController.swift index f610a7c..c09d709 100644 --- a/OpenParsec/ParsecGLKViewController.swift +++ b/OpenParsec/ParsecGLKViewController.swift @@ -51,6 +51,11 @@ class ParsecGLKViewController : ParsecPlayground { private func setupGLKViewController() { glkView.context = EAGLContext(api: .openGLES3)! + // Track the superview's bounds so the drawable can't desync / go + // zero-size when the view is moved between parents or the layout + // changes on screen return (R4). updateSize still drives explicit + // resolution changes; this just keeps the surface pinned otherwise. + glkView.autoresizingMask = [.flexibleWidth, .flexibleHeight] glkViewController.view = glkView // Use configured FPS or device max (for ProMotion displays) @@ -71,10 +76,28 @@ class ParsecGLKViewController : ParsecPlayground { return glkView?.context } + // Symmetric stop: pause the CADisplayLink-driven render loop so + // glkView(_:drawIn:) stops being called. Used when the surface is going + // off screen; resume() reverses it. func cleanUp() { + glkViewController.isPaused = true + } + // Idempotent render resume. Safe to call repeatedly. Makes the EAGL + // context current on the main thread (where GLKViewController renders), + // unpauses the loop LAST (Apple's ordering rule), then forces one frame + // so a stale/blank framebuffer repaints immediately instead of waiting + // for the next streamed frame. This is the core fix for the black screen + // on screen return: any path that left isPaused == true (changeResolution, + // PiP, background) is self-healed here. + func resume() { + if let ctx = glkView?.context { + EAGLContext.setCurrentContext(ctx) + } + glkViewController.isPaused = false + glkView?.setNeedsDisplay() } - + func updateSize(width: CGFloat, height: CGFloat) { glkView.frame.size.width = width glkView.frame.size.height = height diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 7c72cb4..054a401 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -1,6 +1,7 @@ import ParsecSDK import MetalKit import UIKit +import os enum RendererType: Int { @@ -73,14 +74,50 @@ class ParsecSDKBridge: ParsecService // changeResolution), every send* method below early-returns so we don't // fire ParsecClientSendMessage into a disconnected client. var backgroundTaskRunning = true + // C3 fix: monotonic token bumped on every startBackgroundTask(). Each poll + // loop captures the value at spawn and exits the instant the token moves, + // so a fast disconnect→reconnect (which the 0.02 s drain in disconnect() + // can't guarantee has fully drained) cannot leave two generations of + // audio/event loops running against one client and double-polling + // ParsecGetBuffer / ParsecFree. + private var pollGeneration: Int = 0 var didSetResolution = false // Restored once per session in handleUserDataEvent case 12 so display // hot-plug / sleep-wake echoes don't keep re-firing updateHostVideoConfig // and causing momentary re-encode flicker. var didRestoreSavedDisplay = false - public var mouseInfo = MouseInfo() - + // C1 fix: `mouseInfo` is written on the poll thread (handleCursorEvent), + // on the input paths (sendMousePosition / setFrame), and read on the main + // thread (updateImage). It holds a `CGImage?` (`cursorImg`) — an ARC + // reference. A struct copy in the getter retains `cursorImg` while a + // concurrent write releases it; that non-atomic retain/release races and + // over-releases the CGImage → use-after-free crash that fires constantly + // during cursor motion. All access now goes through `os_unfair_lock`: + // readers take an atomic snapshot via the `mouseInfo` getter, writers + // mutate under the same lock via `withMouseInfo`. + private var _mouseInfo = MouseInfo() + private var mouseInfoLock = os_unfair_lock_s() + + // Atomic snapshot for cross-thread readers. Returns a consistent copy of + // the whole struct under the lock so `cursorImg`'s retain happens while no + // writer can release it. + var mouseInfo: MouseInfo { + os_unfair_lock_lock(&mouseInfoLock) + defer { os_unfair_lock_unlock(&mouseInfoLock) } + return _mouseInfo + } + + // Serialize every mutation under the same lock the snapshot getter uses. + // Keep the body short — never do heavy work (e.g. CGImage construction) + // while holding the lock; build first, then assign inside. + @discardableResult + private func withMouseInfo(_ body: (inout MouseInfo) -> T) -> T { + os_unfair_lock_lock(&mouseInfoLock) + defer { os_unfair_lock_unlock(&mouseInfoLock) } + return body(&_mouseInfo) + } + init() { print("Parsec SDK Version: " + String(ParsecSDKBridge.PARSEC_VER)) @@ -203,8 +240,10 @@ class ParsecSDKBridge: ParsecService clientWidth = Float(width) clientHeight = Float(height) - mouseInfo.mouseX = Int32(width / 2) - mouseInfo.mouseY = Int32(height / 2) + withMouseInfo { + $0.mouseX = Int32(width / 2) + $0.mouseY = Int32(height / 2) + } } // timeout in ms, 16 == 60 FPS, 8 == 120 FPS, etc. @@ -313,41 +352,49 @@ class ParsecSDKBridge: ParsecService } func handleCursorEvent(event: ParsecClientCursorEvent) { - let prevHidden = mouseInfo.cursorHidden - mouseInfo.cursorHidden = event.cursor.hidden - mouseInfo.mousePositionRelative = event.cursor.relative - - if event.cursor.imageUpdate || !getFirstCursor{ - getFirstCursor = true - let imgKey = event.key - let pointer = ParsecGetBuffer(_parsec, imgKey) - if pointer == nil{ - return - } - let size = event.cursor.size - let width = event.cursor.width - let height = event.cursor.height - mouseInfo.cursorWidth = Int(width) - mouseInfo.cursorHeight = Int(height) - + // hidden / relative always track the latest event; capture the prior + // hidden state in the same locked section to decide the reposition. + let prevHidden = withMouseInfo { info -> Bool in + let prev = info.cursorHidden + info.cursorHidden = event.cursor.hidden + info.mousePositionRelative = event.cursor.relative + return prev + } + + guard event.cursor.imageUpdate || !getFirstCursor else { return } + getFirstCursor = true + + let pointer = ParsecGetBuffer(_parsec, event.key) + if pointer == nil { + return + } + + let size = event.cursor.size + let width = event.cursor.width + let height = event.cursor.height + + // Build the CGImage BEFORE taking the lock — image construction is the + // expensive part and must not run while the snapshot getter is blocked. + let elmentLength: Int = 4 + let render: CGColorRenderingIntent = CGColorRenderingIntent.defaultIntent + let rgbColorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo: CGBitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue) + let providerRef: CGDataProvider? = CGDataProvider(data: NSData(bytes: pointer, length: Int(size))) + let cgimage: CGImage? = CGImage(width: Int(width), height: Int(height), bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: Int(width) * elmentLength, space: rgbColorSpace, bitmapInfo: bitmapInfo, provider: providerRef!, decode: nil, shouldInterpolate: true, intent: render) + ParsecFree(pointer) + + withMouseInfo { info in + info.cursorWidth = Int(width) + info.cursorHeight = Int(height) if prevHidden && !event.cursor.hidden { - mouseInfo.mouseX = Int32(event.cursor.positionX) - mouseInfo.mouseY = Int32(event.cursor.positionY) + info.mouseX = Int32(event.cursor.positionX) + info.mouseY = Int32(event.cursor.positionY) } - - mouseInfo.cursorHotX = Int(event.cursor.hotX) - mouseInfo.cursorHotY = Int(event.cursor.hotY) - - let elmentLength: Int = 4 - let render: CGColorRenderingIntent = CGColorRenderingIntent.defaultIntent - let rgbColorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo: CGBitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue) - let providerRef: CGDataProvider? = CGDataProvider(data: NSData(bytes: pointer, length: Int(size))) - let cgimage: CGImage? = CGImage(width: Int(width), height: Int(height), bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: Int(width) * elmentLength, space: rgbColorSpace, bitmapInfo: bitmapInfo, provider: providerRef!, decode: nil, shouldInterpolate: true, intent: render) - if cgimage != nil { - mouseInfo.cursorImg = cgimage + info.cursorHotX = Int(event.cursor.hotX) + info.cursorHotY = Int(event.cursor.hotY) + if let cgimage = cgimage { + info.cursorImg = cgimage } - ParsecFree(pointer) } } @@ -414,10 +461,13 @@ class ParsecSDKBridge: ParsecService func sendMouseDelta(_ dx: Int32, _ dy: Int32) { guard backgroundTaskRunning else { return } - if mouseInfo.mousePositionRelative { + // One atomic snapshot, then act on it — avoids two separate locked + // reads racing a concurrent position write. + let info = mouseInfo + if info.mousePositionRelative { sendMouseRelativeMove(dx, dy) } else { - sendMousePosition(mouseInfo.mouseX + dx, mouseInfo.mouseY + dy) + sendMousePosition(info.mouseX + dx, info.mouseY + dy) } } @@ -446,8 +496,12 @@ class ParsecSDKBridge: ParsecService func sendMousePosition(_ x:Int32, _ y:Int32) { guard backgroundTaskRunning else { return } - mouseInfo.mouseX = ParsecSDKBridge.clamp(x, minValue: 0, maxValue: Int32(self.clientWidth)) - mouseInfo.mouseY = ParsecSDKBridge.clamp(y, minValue: 0, maxValue: Int32(self.clientHeight)) + let cx = ParsecSDKBridge.clamp(x, minValue: 0, maxValue: Int32(self.clientWidth)) + let cy = ParsecSDKBridge.clamp(y, minValue: 0, maxValue: Int32(self.clientHeight)) + withMouseInfo { + $0.mouseX = cx + $0.mouseY = cy + } var motionMessage = ParsecMessage() motionMessage.type = MESSAGE_MOUSE_MOTION motionMessage.mouseMotion.x = x @@ -623,14 +677,19 @@ class ParsecSDKBridge: ParsecService : SettingsHandler.preferredFramesPerSecond let pollTimeout = UInt32(max(1000 / fps, 8)) + // Advance the generation and capture it for this pair of loops. Any + // previously-spawned loop sees the bumped value and exits. + pollGeneration &+= 1 + let generation = pollGeneration + let item1 = DispatchWorkItem { - while self.backgroundTaskRunning { + while self.backgroundTaskRunning && self.pollGeneration == generation { self.pollAudio(timeout: pollTimeout) } } let item2 = DispatchWorkItem { - while self.backgroundTaskRunning { + while self.backgroundTaskRunning && self.pollGeneration == generation { self.pollEvent(timeout: pollTimeout) } } diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 7408afc..38339c2 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -7,6 +7,10 @@ protocol ParsecPlayground { init(viewController: UIViewController, updateImage: @escaping () -> Void) func viewDidLoad() func cleanUp() + // Idempotent render resume — re-currents the GL context and unpauses the + // render loop. Called on screen return / app foreground to recover from a + // paused or stale-drawable state (the black-screen-on-return bug). + func resume() func updateSize(width: CGFloat, height: CGFloat) } @@ -107,11 +111,15 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } func updateImage() { - // Optimization: Snap current valus - let currentMouseX = CParsec.mouseInfo.mouseX - let currentMouseY = CParsec.mouseInfo.mouseY - let currentHidden = CParsec.mouseInfo.cursorHidden - let currentImg = CParsec.mouseInfo.cursorImg + // Take ONE atomic snapshot of the cross-thread mouse state. Re-reading + // CParsec.mouseInfo per field would each lock-and-copy separately and + // could tear across a concurrent poll-thread write; the snapshot also + // pins cursorImg's lifetime for the whole frame (C1 fix). + let info = CParsec.mouseInfo + let currentMouseX = info.mouseX + let currentMouseY = info.mouseY + let currentHidden = info.cursorHidden + let currentImg = info.cursorImg // Toggle host vs local cursor visibility from the same place so they // stay mutually exclusive without scattered if-statements. @@ -146,10 +154,10 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } // Using tracked values for bounds - u?.frame = CGRect(x: Int(currentMouseX) - Int(Double(CParsec.mouseInfo.cursorHotX) * SettingsHandler.cursorScale), - y: Int(currentMouseY) - Int(Double(CParsec.mouseInfo.cursorHotY) * SettingsHandler.cursorScale), - width: Int(Double(CParsec.mouseInfo.cursorWidth) * SettingsHandler.cursorScale), - height: Int(Double(CParsec.mouseInfo.cursorHeight) * SettingsHandler.cursorScale)) + u?.frame = CGRect(x: Int(currentMouseX) - Int(Double(info.cursorHotX) * SettingsHandler.cursorScale), + y: Int(currentMouseY) - Int(Double(info.cursorHotY) * SettingsHandler.cursorScale), + width: Int(Double(info.cursorWidth) * SettingsHandler.cursorScale), + height: Int(Double(info.cursorHeight) * SettingsHandler.cursorScale)) // Check bounds and pan if needed // Only pan if we are zoomed in OR if the keyboard is visible (to allow scrolling up) @@ -369,7 +377,14 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { name: UIResponder.keyboardWillHideNotification, object: nil ) - + + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } override func viewDidLayoutSubviews() { @@ -416,6 +431,17 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } scrollView.pinchGestureRecognizer?.isEnabled = zoomEnabled startLanguageSyncIfNeeded() + // Recover the render loop on any return to this screen (SwiftUI + // re-render, PiP exit, interrupted resolution change). Without this + // a left-over isPaused == true keeps the surface frozen/black. + glkView?.resume() + } + + @objc private func appWillEnterForeground() { + // Background→foreground invalidates GL drawable currency; re-current + // the context and unpause so the next frame renders instead of going + // black. Scene plumbing doesn't reach this VC, so observe directly. + glkView?.resume() } override func viewWillDisappear(_ animated: Bool) { diff --git a/OpenParsec/audio.c b/OpenParsec/audio.c index 7dae111..0bbd0ba 100644 --- a/OpenParsec/audio.c +++ b/OpenParsec/audio.c @@ -213,14 +213,22 @@ void audio_destroy(struct audio **ctx_out) if (ctx->audio_buf[x]) AudioQueueFreeBuffer(ctx->q, ctx->audio_buf[x]); } - + + // C5 fix: free the shared silence buffer while the queue is still alive + // and BEFORE freeing ctx. The old order was free(ctx) -> *ctx_out = NULL + // -> AudioQueueFreeBuffer(ctx->q, ...), which dereferenced the just-freed + // ctx (use-after-free) and freed the buffer on an already-disposed queue. + if (ctx->q && silence_buf) { + AudioQueueFreeBuffer(ctx->q, silence_buf); + silence_buf = NULL; + } + if (ctx->q) AudioQueueDispose(ctx->q, true); free(ctx); *ctx_out = NULL; isStart = false; - AudioQueueFreeBuffer(ctx->q, silence_buf); silence_inqueue = silence_outqueue = 0; } From d6b22028b3b339b11b9e06c23eb0481edd46d0cd Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 02:46:18 +0300 Subject: [PATCH 21/40] Add host-OS detection plumbing + diagnostics channel (S04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The case-11 video config already carries a `hostOS` Int but it was decoded and discarded. This wires it through for feature gating (S03 wheel units, S05 Mac Ctrl+Shift gate) and adds the discovery path needed to decode its undocumented values. - SharedModel.hostOS + ParsecSDKBridge.hostOSValue mirror (lock-free Int read from input threads), written in the case-11 main block, reset to -1 in both connect() and disconnect() so a stale value can't bleed across a host switch. - enum HostOS.from(Int): mapping intentionally empty (.unknown for all) until the Int→OS encoding is confirmed empirically — a wrong guess would mis-gate features, so OS-gated features fall back to their manual toggles meanwhile. - Diagnostics channel (Documents/diagnostics.log, append-only) sibling to CrashReporter, with a Settings "Diagnostics Log" → Copy row. case-11 logs `hostOS=` once per host change so the user can capture values against a known Mac and Windows host to fill in HostOS.from. Verification: code-level only (no local iOS SDK). On-device: connect to each host, confirm a stable distinguishing hostOS value appears in the diagnostics log; then the mapping can be filled and S03/S05 gates enabled. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/AppDelegate.swift | 37 ++++++++++++++++++++++++++++++++ OpenParsec/CParsec.swift | 33 ++++++++++++++++++++++++++++ OpenParsec/ParsecSDKBridge.swift | 18 ++++++++++++++++ OpenParsec/SettingsView.swift | 16 ++++++++++++++ OpenParsec/Shared.swift | 6 +++++- 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/OpenParsec/AppDelegate.swift b/OpenParsec/AppDelegate.swift index 4e9f840..70985e6 100644 --- a/OpenParsec/AppDelegate.swift +++ b/OpenParsec/AppDelegate.swift @@ -116,6 +116,43 @@ enum CrashReporter { } } +// Lightweight append-only diagnostics channel, sibling to CrashReporter. +// Used for empirical discovery that has no local console (e.g. the host-OS +// int encoding in S04, or netProtocol/mediaContainer in S08). Entries persist +// to Documents/diagnostics.log and can be copied out via Settings → +// "Copy Diagnostics" or pulled from the Files app. Called only from normal +// (non-signal) contexts, so Foundation use is fine here. +enum Diagnostics { + static var logURL: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return docs.appendingPathComponent("diagnostics.log") + } + + static func note(_ line: String) { + let stamp = ISO8601DateFormatter().string(from: Date()) + let entry = "[\(stamp)] \(line)\n" + print("[OpenParsec][diag] \(line)") + guard let data = entry.data(using: .utf8) else { return } + if let handle = try? FileHandle(forWritingTo: logURL) { + defer { try? handle.close() } + handle.seekToEndOfFile() + handle.write(data) + } else { + // File doesn't exist yet — create it with this first entry. + try? data.write(to: logURL, options: .atomic) + } + } + + static func peek() -> String? { + guard let text = try? String(contentsOf: logURL, encoding: .utf8), !text.isEmpty else { return nil } + return text + } + + static func clear() { + try? FileManager.default.removeItem(at: logURL) + } +} + @main class AppDelegate: UIResponder, UIApplicationDelegate { diff --git a/OpenParsec/CParsec.swift b/OpenParsec/CParsec.swift index f5b33a7..6fa982c 100644 --- a/OpenParsec/CParsec.swift +++ b/OpenParsec/CParsec.swift @@ -118,6 +118,30 @@ enum ParsecResolution: String, CaseIterable, Hashable { } +// Host operating system, derived from the case-11 video-config `hostOS` Int. +// The Int→OS encoding is NOT documented (the ParsecSDK framework headers are +// not vendored), so the mapping below is intentionally empty until the values +// are confirmed empirically. Connect to a known Mac host and a known Windows +// host and read the `hostOS=` lines written by Diagnostics.note (retrievable +// via Settings → "Copy Diagnostics" or the Files app), then fill in the cases. +// Until then `from` returns .unknown and every OS-gated feature falls back to +// its manual toggle — so a wrong guess can never mis-gate behaviour. +enum HostOS { + case unknown + case macos + case windows + case linux + + static func from(_ raw: Int) -> HostOS { + switch raw { + // TODO(discovery): map the observed raw values here, e.g. + // case 1: return .windows + // case 2: return .macos + default: return .unknown + } + } +} + struct MouseInfo { var pngCursor: Bool = false var mouseX:Int32 = 1 @@ -137,6 +161,9 @@ protocol ParsecService { var hostWidth: Float { get } var hostHeight: Float { get } var mouseInfo: MouseInfo { get } + // Raw host-OS int from the case-11 video config (-1 until received). Read + // lock-free from input threads; a plain Int load is atomic on the device. + var hostOSValue: Int { get } func connect(_ peerID: String) -> ParsecStatus func disconnect() @@ -180,6 +207,12 @@ class CParsec public static var mouseInfo: MouseInfo { return parsecImpl.mouseInfo } + + // Resolved host OS for feature gating. .unknown until the case-11 value + // arrives AND the HostOS mapping is filled in from discovery. + public static var hostOS: HostOS { + return HostOS.from(parsecImpl.hostOSValue) + } diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 054a401..5e10f0a 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -99,6 +99,12 @@ class ParsecSDKBridge: ParsecService private var _mouseInfo = MouseInfo() private var mouseInfoLock = os_unfair_lock_s() + // S04: raw host-OS int mirror, written from the case-11 main block and read + // lock-free from input threads (plain Int load is atomic on-device). Reset + // to -1 on connect AND disconnect so a stale value can't bleed across a + // host switch. -1 = unknown / not yet received. + var hostOSValue: Int = -1 + // Atomic snapshot for cross-thread readers. Returns a consistent copy of // the whole struct under the lock so `cursorImg`'s retain happens while no // writer can release it. @@ -168,6 +174,8 @@ class ParsecSDKBridge: ParsecService // silently never re-run. didRestoreSavedDisplay = false didSetResolution = false + hostOSValue = -1 + DispatchQueue.main.async { DataManager.model.hostOS = -1 } var parsecClientCfg = ParsecClientConfig() parsecClientCfg.video.0.decoderIndex = 1 @@ -209,6 +217,8 @@ class ParsecSDKBridge: ParsecService // happens to advertise. didSetResolution = false didRestoreSavedDisplay = false + hostOSValue = -1 + DispatchQueue.main.async { DataManager.model.hostOS = -1 } // Give the two `while backgroundTaskRunning` loops in // startBackgroundTask() one full poll-timeout to notice the flag @@ -299,6 +309,14 @@ class ParsecSDKBridge: ParsecService DataManager.model.resolutionY = videoConfig.resolutionY DataManager.model.bitrate = videoConfig.encoderMaxBitrate DataManager.model.constantFps = videoConfig.fullFPS + // S04: capture the host-OS int and log it once per session + // change so its undocumented encoding can be discovered + // against known Mac/Windows hosts. + if self.hostOSValue != videoConfig.hostOS { + self.hostOSValue = videoConfig.hostOS + DataManager.model.hostOS = videoConfig.hostOS + Diagnostics.note("hostOS=\(videoConfig.hostOS) (resolution=\(videoConfig.resolutionX)x\(videoConfig.resolutionY), bitrate=\(videoConfig.encoderMaxBitrate))") + } if !self.didSetResolution { self.didSetResolution = true DataManager.model.resolutionX = SettingsHandler.resolution.width diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index abee7fc..0d5227f 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -30,6 +30,7 @@ struct SettingsView:View @AppStorage("layoutSyncHotkey") var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace @AppStorage("saveSessionSettings") var saveSessionSettings: Bool = true @State private var crashCopied: Bool = false + @State private var diagCopied: Bool = false let resolutionChoices: [Choice] @@ -279,6 +280,21 @@ struct SettingsView:View .foregroundColor(CrashReporter.peek() == nil ? .gray : Color("AccentColor")) } } + CatItem("Diagnostics Log") + { + Button(action: { + if let diag = Diagnostics.peek() { + UIPasteboard.general.string = diag + diagCopied = true + } else { + UIPasteboard.general.string = "(no diagnostics recorded)" + diagCopied = true + } + }) { + Text(diagCopied ? "Copied!" : (Diagnostics.peek() == nil ? "None" : "Copy")) + .foregroundColor(Diagnostics.peek() == nil ? .gray : Color("AccentColor")) + } + } } Text(getVersionInfo()) .multilineTextAlignment(.center) diff --git a/OpenParsec/Shared.swift b/OpenParsec/Shared.swift index 24af858..79094de 100644 --- a/OpenParsec/Shared.swift +++ b/OpenParsec/Shared.swift @@ -76,7 +76,11 @@ class SharedModel: ObservableObject { @Published var constantFps = false @Published var output = "none" @Published var displayConfigs: [ParsecDisplayConfig] = [] - + // Host OS as reported in the case-11 video config. -1 = unknown / not yet + // received. The Int→OS encoding is empirical (see HostOS.from); this raw + // value is surfaced for discovery logging and feature gating. + @Published var hostOS: Int = -1 + } class DataManager { From d82eb30824b8a89e3d29ad63d9824be39a3e7fee Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 02:49:00 +0300 Subject: [PATCH 22/40] Remove client-side scroll inertia; forward native deltas only (S03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the recurring "scroll feels bad / choppy" complaint: iPadOS already synthesizes trackpad/scroll deceleration as a tail of continued `.changed` events, but the client ran its own CADisplayLink inertia on top — double-applied momentum with a velocity discontinuity at the handoff. Worse, the momentum seed was hardcoded to 60 Hz (`peak/60`) while the tick fires at the display rate, so on the 120 Hz M4 iPad the glide distance literally doubled. This is exactly why Moonlight-iOS deleted client inertia. - Removed startScrollMomentum / scrollMomentumTick / stopScrollMomentum, the momentumDisplayLink + velocity/peak state, and the deinit CADisplayLink teardown (nothing left to tear down). - handleTrackpadScroll now forwards native translation deltas only; OS momentum rides in through the `.changed` tail for free. - SC5: the zoom early-return no longer leaks a stale lastScrollTranslation — it syncs the anchor before returning so the first post-unzoom delta doesn't jump the host. - Dropped the now-dead scrollMomentum / scrollMomentumStrength settings and their Settings UI (Scroll Inertia toggle + Inertia Strength slider). Verification: code-level only (no local iOS SDK). On-device: a flick produces one smooth decelerating glide with no speed "kick"; no doubled glide on the 120 Hz iPad; scroll after pinch-zoom doesn't jump. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecViewController.swift | 176 ++++---------------------- OpenParsec/SettingsHandler.swift | 10 +- OpenParsec/SettingsView.swift | 13 -- 3 files changed, 30 insertions(+), 169 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 38339c2..4681a58 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -49,21 +49,12 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var accumulatedScrollY: Float = 0.0 var lastScrollTranslation: CGPoint = .zero - // Momentum scrolling state — CADisplayLink keeps firing wheel messages - // with exponential decay after the user's fingers leave the trackpad. - var momentumDisplayLink: CADisplayLink? - var momentumVelocityX: Float = 0.0 - var momentumVelocityY: Float = 0.0 - - // iPad's trackpad already applies its own short deceleration to scroll - // events while reporting them; by the time `.ended` fires, the gesture - // recognizer's `velocity(in:)` is near zero. To produce a noticeable - // macOS-style inertia tail we sample translation deltas during `.changed` - // and remember the PEAK velocity to seed the inertia at `.ended`. - private var lastScrollChangeTime: CFTimeInterval = 0 - private var lastScrollChangeTranslation: CGPoint = .zero - private var peakScrollVelocityX: CGFloat = 0 - private var peakScrollVelocityY: CGFloat = 0 + // S03: client-side scroll inertia was REMOVED. iPadOS already synthesizes + // trackpad/scroll deceleration as a tail of continued `.changed` events, so + // the old CADisplayLink momentum stacked a second inertia on top — and its + // seed was hardcoded to 60 Hz (`peak/60`), so on a 120 Hz iPad the glide + // distance literally doubled. We now forward native deltas only (the + // Moonlight-iOS approach); the OS-provided momentum rides in for free. // Layout sync — fires a hotkey at the host when the iPad's hardware-keyboard // input language changes (e.g. Caps Lock toggle on Magic Keyboard). @@ -101,12 +92,6 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { } deinit { - // CADisplayLink retains its target; without an explicit invalidate - // here a momentum glide still in flight at teardown would keep this - // controller alive and keep ticking. stopScrollMomentum is also - // called from viewWillDisappear, but deinit is the backstop. - momentumDisplayLink?.invalidate() - momentumDisplayLink = nil languageSync?.stop() } @@ -453,7 +438,6 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) stopLanguageSync() - stopScrollMomentum() } @@ -467,22 +451,11 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { // (see viewDidLoad), so those touches reach this override unobstructed. override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) - // Only direct finger / pencil touches should kill scroll inertia — - // .indirectPointer touches (trackpad cursor crossing a region, palm - // rest, etc.) would otherwise cancel momentum mid-glide for no - // user-visible reason. - var killMomentum = false - for touch in touches { - if touch.type == .direct || touch.type == .pencil { - killMomentum = true - } - if touch.type == .indirectPointer { - accumulatedDeltaX = 0.0 - accumulatedDeltaY = 0.0 - } - } - if killMomentum { - stopScrollMomentum() + // Reset the trackpad-cursor delta accumulators when an .indirectPointer + // touch begins so a new stroke doesn't inherit leftover sub-pixel motion. + for touch in touches where touch.type == .indirectPointer { + accumulatedDeltaX = 0.0 + accumulatedDeltaY = 0.0 } } @@ -762,60 +735,34 @@ extension ParsecViewController : UIGestureRecognizerDelegate { switch gestureRecognizer.state { case .began: - // New scroll gesture aborts any ongoing inertia. - stopScrollMomentum() lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 - lastScrollChangeTime = 0 - peakScrollVelocityX = 0 - peakScrollVelocityY = 0 case .changed: - // When the user is zoomed in, let UIScrollView's own pan handle - // the scroll locally — otherwise we'd both scroll the local view - // AND send wheel messages to the host (double-pan). + let translation = gestureRecognizer.translation(in: gestureRecognizer.view) + // When the user is zoomed in, let UIScrollView's own pan handle the + // scroll locally — otherwise we'd both scroll the local view AND + // send wheel messages to the host (double-pan). Keep + // lastScrollTranslation in sync before returning (SC5): otherwise + // the next forwarded `.changed` after un-zoom computes a delta + // against a stale anchor and jumps the host. if scrollView.zoomScale > 1.0 { + lastScrollTranslation = translation return } - let translation = gestureRecognizer.translation(in: gestureRecognizer.view) let deltaX = Float(translation.x - lastScrollTranslation.x) * sensitivity * direction let deltaY = Float(translation.y - lastScrollTranslation.y) * sensitivity * direction - - // Sample peak velocity for inertia seeding. We can't trust - // gestureRecognizer.velocity(in:) at `.ended` because iPad already - // applies its own deceleration — by then velocity is near zero. - let now = CACurrentMediaTime() - if lastScrollChangeTime > 0 { - let dt = now - lastScrollChangeTime - // iPadOS sends a deceleration tail of `.changed` events after - // the user lifts their fingers, and those events arrive - // further apart as the deceleration fades. The previous - // 0.2 s peak-reset was hitting during that tail and zeroing - // the peak right before `.ended` fired — that's why inertia - // silently didn't trigger. Raised to 1.0 s, which is much - // longer than any natural same-gesture pause but still - // catches "scroll, idle, scroll" patterns. - if dt > 1.0 { - peakScrollVelocityX = 0 - peakScrollVelocityY = 0 - } - if dt > 0.001 { - let vX = (translation.x - lastScrollChangeTranslation.x) / CGFloat(dt) - let vY = (translation.y - lastScrollChangeTranslation.y) / CGFloat(dt) - if abs(vX) > abs(peakScrollVelocityX) { peakScrollVelocityX = vX } - if abs(vY) > abs(peakScrollVelocityY) { peakScrollVelocityY = vY } - } - } - lastScrollChangeTime = now - lastScrollChangeTranslation = translation - lastScrollTranslation = translation + + // Forward the native delta. iPadOS keeps sending `.changed` events + // through its own deceleration tail after the fingers lift, so the + // momentum arrives here for free — no client-side inertia needed. accumulatedScrollX += deltaX accumulatedScrollY += deltaY - // Same rounding trick as the mouse-delta path — Int32 truncation - // previously swallowed sub-pixel ticks so slow trackpad scrolls - // felt completely dead until enough whole pixels piled up. + // Rounding accumulator: Int32 truncation previously swallowed + // sub-pixel ticks so slow trackpad scrolls felt dead until enough + // whole pixels piled up. let intX = Int32(accumulatedScrollX.rounded(.toNearestOrAwayFromZero)) let intY = Int32(accumulatedScrollY.rounded(.toNearestOrAwayFromZero)) if intX != 0 || intY != 0 { @@ -823,84 +770,15 @@ extension ParsecViewController : UIGestureRecognizerDelegate { accumulatedScrollX -= Float(intX) accumulatedScrollY -= Float(intY) } - case .ended: - lastScrollTranslation = .zero - // Use peak velocity from .changed phase (not recognizer.velocity at - // .ended — that's already decayed by iPad). The previous 80 pts/s - // threshold rejected most natural-feel scrolls; lowered to 20 so - // even gentle drags still get a short inertia tail. - let minSeedSpeed: CGFloat = 20 - if SettingsHandler.scrollMomentum, - abs(peakScrollVelocityX) > minSeedSpeed || abs(peakScrollVelocityY) > minSeedSpeed { - momentumVelocityX = Float(peakScrollVelocityX) / 60.0 * sensitivity * direction - momentumVelocityY = Float(peakScrollVelocityY) / 60.0 * sensitivity * direction - startScrollMomentum() - } - lastScrollChangeTime = 0 - peakScrollVelocityX = 0 - peakScrollVelocityY = 0 - case .cancelled, .failed: + case .ended, .cancelled, .failed: lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 - lastScrollChangeTime = 0 - peakScrollVelocityX = 0 - peakScrollVelocityY = 0 default: break } } - // Inertia tail. iPadOS hands us its own deceleration before `.ended` - // fires, so by the time we seed momentum the peak has already been - // partly converted into wheel ticks during `.changed`. The 0.5 pts/frame - // stop floor we used to have killed any seed under ~5 frames of glide — - // that's why "no inertia at all" was the dominant feedback even with - // the toggle on. Floor lowered to 0.05, decay range widened so the - // "Inertia Strength" slider has a clearly distinguishable bottom - // (snappy, ~10 frames) and top (long glide, multi-second). - private static let momentumStopThreshold: Float = 0.05 - - private func startScrollMomentum() { - if abs(momentumVelocityX) < Self.momentumStopThreshold && - abs(momentumVelocityY) < Self.momentumStopThreshold { - return - } - stopScrollMomentum() - let link = CADisplayLink(target: self, selector: #selector(scrollMomentumTick(_:))) - link.add(to: .main, forMode: .common) - momentumDisplayLink = link - } - - @objc private func scrollMomentumTick(_ link: CADisplayLink) { - accumulatedScrollX += momentumVelocityX - accumulatedScrollY += momentumVelocityY - let intX = Int32(accumulatedScrollX.rounded(.toNearestOrAwayFromZero)) - let intY = Int32(accumulatedScrollY.rounded(.toNearestOrAwayFromZero)) - if intX != 0 || intY != 0 { - CParsec.sendWheelMsg(x: intX, y: intY) - accumulatedScrollX -= Float(intX) - accumulatedScrollY -= Float(intY) - } - // Decay map: strength 0 → 0.90 (snappy), strength 1 → 0.995 (long). - // Default strength 0.5 → 0.9475, ~30-frame half-life ≈ 500 ms. - let strength = Float(SettingsHandler.scrollMomentumStrength) - let decay: Float = 0.90 + 0.095 * strength - momentumVelocityX *= decay - momentumVelocityY *= decay - if abs(momentumVelocityX) < Self.momentumStopThreshold && - abs(momentumVelocityY) < Self.momentumStopThreshold { - stopScrollMomentum() - } - } - - private func stopScrollMomentum() { - momentumDisplayLink?.invalidate() - momentumDisplayLink = nil - momentumVelocityX = 0.0 - momentumVelocityY = 0.0 - } - @objc func handleSingleFingerTap(_ gestureRecognizer: UITapGestureRecognizer) { let location = gestureRecognizer.location(in:gestureRecognizer.view) diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 0e700c9..d95173c 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -23,13 +23,9 @@ struct SettingsHandler { // call "natural scrolling" — swipe down moves content down. Flip to false // if you want classic mouse-wheel direction. @AppStorage("naturalScrolling") public static var naturalScrolling: Bool = true - // Inertia after the finger leaves the trackpad: CADisplayLink keeps - // firing wheel messages with exponential decay, so scrolls don't stop - // dead the moment you let go. - @AppStorage("scrollMomentum") public static var scrollMomentum: Bool = true - // 0.0 ≈ ~150 ms of glide; 1.0 ≈ ~2 s of long glide. Linear mapping into - // a per-frame decay multiplier in startScrollMomentum. - @AppStorage("scrollMomentumStrength") public static var scrollMomentumStrength: Double = 0.5 + // S03: client-side scroll inertia was removed (iPadOS provides native + // scroll-deceleration events; the client tail double-applied them). The + // scrollMomentum / scrollMomentumStrength settings are gone with it. // Best-effort iPadOS system-shortcut capture via UIKeyCommand registry // with .wantsPriorityOverSystemBehavior (iOS 15+). Catches Cmd+letter // combinations the iPad shell would otherwise eat. Cmd+Space, Cmd+H, diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 0d5227f..dcedfe0 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -15,8 +15,6 @@ struct SettingsView:View @AppStorage("localCursorOverlay") var localCursorOverlay: Bool = false @AppStorage("scrollSensitivity") var scrollSensitivity: Double = 1.0 @AppStorage("naturalScrolling") var naturalScrolling: Bool = true - @AppStorage("scrollMomentum") var scrollMomentum: Bool = true - @AppStorage("scrollMomentumStrength") var scrollMomentumStrength: Double = 0.5 @AppStorage("captureSystemKeys") var captureSystemKeys: Bool = true @AppStorage("windowsHostKeyboardRemap") var windowsHostKeyboardRemap: Bool = false @AppStorage("noOverlay") var noOverlay: Bool = false @@ -145,17 +143,6 @@ struct SettingsView:View Toggle("", isOn:$naturalScrolling) .frame(width:80) } - CatItem("Scroll Inertia") - { - Toggle("", isOn:$scrollMomentum) - .frame(width:80) - } - CatItem("Inertia Strength") - { - Slider(value: $scrollMomentumStrength, in:0...1, step:0.05) - .frame(width: 200) - Text(String(format: "%.2f", scrollMomentumStrength)) - } } CatTitle("Keyboard") CatList() From 656a212cfb911beed606e4a92e2189e304b445c5 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 02:51:14 +0300 Subject: [PATCH 23/40] Video-config send-gate + echo-as-confirmation (S06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateHostVideoConfig scheduled two resends + a getVideoConfig echo per call, and the case-11 echo wrote bitrate/constantFps straight back into the model. Rapid menu taps stacked timers and a stale echo clobbered the user's in-flight selection (the menu checkmark visibly jumped back). - configRevision token bumped per call; the 250 ms resend and 450 ms echo request drop if a newer call superseded them. - case-11 echo now treats bitrate/constantFps as user-owned: while a change is pending it only CONFIRMS (clears pending on an exact match) and never overwrites the user's selection; resolutionX/Y stay host-authoritative. - pendingUserConfig self-heals after a 1.5 s window so a host that never echoes the exact value can't permanently block future echo adoption. - V2: added the missing `guard backgroundTaskRunning` to sendUserData, so it matches every other send path and can't NULL-deref in the reconnect gap. Verification: code-level only (no local iOS SDK). On-device: rapid bitrate / display taps settle on the last selection without bouncing; no sends fire during the disconnect→reconnect gap. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecSDKBridge.swift | 54 +++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 5e10f0a..03ca733 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -105,6 +105,17 @@ class ParsecSDKBridge: ParsecService // host switch. -1 = unknown / not yet received. var hostOSValue: Int = -1 + // S06: monotonic token bumped per updateHostVideoConfig() so a superseded + // resend/echo-request from an earlier call drops instead of stacking timers + // and re-confirming a stale value. Plus the user-owned fields awaiting host + // confirmation: while set, a case-11 echo CONFIRMS (clears) on match but + // must never clobber the user's in-flight bitrate/fps selection with a + // stale echoed value — that was the "checkmark jumps back" bug. Touched on + // the main thread (all callers are main); the resend closures only read the + // token (benign plain-Int read, same pattern as backgroundTaskRunning). + private var configRevision: Int = 0 + private var pendingUserConfig: (bitrate: Int, constantFps: Bool)? = nil + // Atomic snapshot for cross-thread readers. Returns a consistent copy of // the whole struct under the lock so `cursorImg`'s retain happens while no // writer can release it. @@ -305,10 +316,23 @@ class ParsecSDKBridge: ParsecService let videoConfig = config.video[0] DispatchQueue.main.async { + // resolutionX/Y are host-authoritative — always adopt. DataManager.model.resolutionX = videoConfig.resolutionX DataManager.model.resolutionY = videoConfig.resolutionY - DataManager.model.bitrate = videoConfig.encoderMaxBitrate - DataManager.model.constantFps = videoConfig.fullFPS + // S06: bitrate/constantFps are user-owned. If a user change + // is in flight, the echo only CONFIRMS (clears pending on a + // match) — it must not overwrite the user's selection with a + // stale value, which made the menu checkmark jump back. With + // nothing pending, adopt the host's reported values. + if let pending = self.pendingUserConfig { + if videoConfig.encoderMaxBitrate == pending.bitrate + && videoConfig.fullFPS == pending.constantFps { + self.pendingUserConfig = nil + } + } else { + DataManager.model.bitrate = videoConfig.encoderMaxBitrate + DataManager.model.constantFps = videoConfig.fullFPS + } // S04: capture the host-OS int and log it once per session // change so its undocumented encoding can be discovered // against known Mac/Windows hosts. @@ -721,6 +745,10 @@ class ParsecSDKBridge: ParsecService } func sendUserData(type: ParsecUserDataType, message: Data) { + // V2: the only send* method that was missing this gate — protects + // against a NULL deref inside ParsecClientSendUserData during the + // disconnect→reconnect gap, consistent with every other send path. + guard backgroundTaskRunning else { return } var nullTerminatedMessage = message nullTerminatedMessage.append(0) nullTerminatedMessage.withUnsafeBytes { ptr in @@ -738,22 +766,38 @@ class ParsecSDKBridge: ParsecService videoConfig.video[0].output = DataManager.model.output let encoder = JSONEncoder() let data = try! encoder.encode(videoConfig) + + // S06: bump the revision so any in-flight resend from a previous call + // drops, and record the user-owned fields so the case-11 echo confirms + // (rather than reverts) this selection. + configRevision &+= 1 + let revision = configRevision + pendingUserConfig = (DataManager.model.bitrate, DataManager.model.constantFps) + CParsec.sendUserData(type: .setVideoConfig, message: data) // User reports: display switches needed two or three taps to actually // take effect. setVideoConfig is fire-and-forget; the host can drop // the message if its encoder is in the middle of a reset triggered // by a previous request. Re-send the same payload after 250 ms - // (idempotent — same output reapplied is a no-op on the host). + // (idempotent — same output reapplied is a no-op on the host), but only + // if this call is still the latest (a newer tap supersedes it). // Then ask the host to echo back its current config so case-11 // confirms the switch landed. DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self = self, self.backgroundTaskRunning else { return } + guard let self = self, self.backgroundTaskRunning, self.configRevision == revision else { return } CParsec.sendUserData(type: .setVideoConfig, message: data) } DispatchQueue.global().asyncAfter(deadline: .now() + 0.45) { [weak self] in - guard let self = self, self.backgroundTaskRunning else { return } + guard let self = self, self.backgroundTaskRunning, self.configRevision == revision else { return } let empty = "".data(using: .utf8)! CParsec.sendUserData(type: .getVideoConfig, message: empty) } + // Self-heal: drop the pending guard after a confirmation window so a + // host that never echoes our exact value can't permanently block future + // echo adoption of bitrate/fps. Only clears if still the latest call. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self = self, self.configRevision == revision else { return } + self.pendingUserConfig = nil + } } } From 1051343f8c7214faaa80fa8add84853221d82824 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 02:52:40 +0300 Subject: [PATCH 24/40] Fix UserDefaults key collision + cancel force-unwrap crash (S07: Q1,Q2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q1 [HIGH] hideStatusBar (Bool) and cursorScale (Double) both bound the "cursorScale" @AppStorage key in SettingsHandler and SettingsView, so they corrupted each other at runtime: toggling hideStatusBar off wrote 0 to the shared key and zeroed the cursor scale (invisible cursor); a fractional cursor scale flipped hideStatusBar. Gave hideStatusBar its own "hideStatusBar" key in both files and added migrateLegacyStatusBarKeyIfNeeded() — a one-time launch migration that seeds the new key (original default true) and clamps any out-of-range stored cursorScale (notably 0) back into 0.1...4. Q2 [MED] cancelConnection force-unwrapped pollTimer!, crashing if Cancel was tapped before the connect poll timer scheduled. Now pollTimer?.invalidate(); pollTimer = nil. Verification: code-level only (no local iOS SDK). On-device: toggle hideStatusBar and confirm the cursor stays visible / cursor scale unaffected; tap Cancel immediately on connect without crashing. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/AppDelegate.swift | 3 +++ OpenParsec/MainView.swift | 5 ++++- OpenParsec/SettingsHandler.swift | 28 +++++++++++++++++++++++++++- OpenParsec/SettingsView.swift | 2 +- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/OpenParsec/AppDelegate.swift b/OpenParsec/AppDelegate.swift index 70985e6..21f2c0a 100644 --- a/OpenParsec/AppDelegate.swift +++ b/OpenParsec/AppDelegate.swift @@ -161,6 +161,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate // Install the crash reporter as early as possible so it catches // failures during the rest of launch too. CrashReporter.install() + // Q1: repair the hideStatusBar / cursorScale shared-key corruption + // before any UI reads these settings. + SettingsHandler.migrateLegacyStatusBarKeyIfNeeded() if let crash = CrashReporter.peek() { // Make the previous crash trivially retrievable: copy to the // pasteboard (syncs to a Mac on the same Apple ID via Universal diff --git a/OpenParsec/MainView.swift b/OpenParsec/MainView.swift index 785ff38..56bad12 100644 --- a/OpenParsec/MainView.swift +++ b/OpenParsec/MainView.swift @@ -628,7 +628,10 @@ struct MainView: View CParsec.disconnect() - pollTimer!.invalidate() + // Q2: pollTimer is nil if Cancel is tapped before the connect poll + // timer schedules — the old force-unwrap crashed on that race. + pollTimer?.invalidate() + pollTimer = nil } func logout() diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index d95173c..6f6558e 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -38,7 +38,11 @@ struct SettingsHandler { // and Opt stays Alt (it's the same physical key + same Windows mapping). @AppStorage("windowsHostKeyboardRemap") public static var windowsHostKeyboardRemap: Bool = false @AppStorage("noOverlay") public static var noOverlay: Bool = false - @AppStorage("cursorScale") public static var hideStatusBar: Bool = true + // Q1: previously shared the "cursorScale" key with the Double cursorScale + // above — a Bool write coerced cursorScale (false → 0 = invisible cursor) + // and vice versa. Own key now; migrateLegacyStatusBarKeyIfNeeded() seeds it + // and clamps any corrupted cursorScale on first launch after the upgrade. + @AppStorage("hideStatusBar") public static var hideStatusBar: Bool = true @AppStorage("rightClickPosition") public static var rightClickPosition: RightClickPosition = .firstFinger @AppStorage("preferredFramesPerSecond") public static var preferredFramesPerSecond: Int = 0 // 0 = use device max (ProMotion). Default was 60 — that capped 120 Hz iPads at half their refresh, doubling glass-to-glass present latency. @AppStorage("decoderCompatibility") public static var decoderCompatibility: Bool = false // Enable for stutter issues on some devices @@ -68,4 +72,26 @@ struct SettingsHandler { @AppStorage("savedDisplayOutput") public static var savedDisplayOutput: String = "" @AppStorage("savedDisplayName") public static var savedDisplayName: String = "" + // Q1 one-time migration. hideStatusBar used to (incorrectly) share the + // "cursorScale" UserDefaults key, so the two settings corrupted each other. + // On the first launch after the fix, seed the new "hideStatusBar" key with + // its original default and clamp any cursorScale value that a Bool write + // may have driven out of range (notably 0 = invisible cursor). Call once at + // app launch, before any UI reads these values. + static func migrateLegacyStatusBarKeyIfNeeded() { + let defaults = UserDefaults.standard + // Absence of the new key == migration not yet run. + guard defaults.object(forKey: "hideStatusBar") == nil else { return } + // The shared value can't be split back into two settings, so restore + // the original hideStatusBar default (true) and sanitize cursorScale. + defaults.set(true, forKey: "hideStatusBar") + if defaults.object(forKey: "cursorScale") != nil { + let raw = defaults.double(forKey: "cursorScale") + let clamped = min(max(raw, 0.1), 4.0) + if clamped != raw { + defaults.set(clamped, forKey: "cursorScale") + } + } + } + } diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index dcedfe0..fdca10e 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -18,7 +18,7 @@ struct SettingsView:View @AppStorage("captureSystemKeys") var captureSystemKeys: Bool = true @AppStorage("windowsHostKeyboardRemap") var windowsHostKeyboardRemap: Bool = false @AppStorage("noOverlay") var noOverlay: Bool = false - @AppStorage("cursorScale") var hideStatusBar: Bool = true + @AppStorage("hideStatusBar") var hideStatusBar: Bool = true @AppStorage("rightClickPosition") var rightClickPosition: RightClickPosition = .firstFinger @AppStorage("preferredFramesPerSecond") var preferredFramesPerSecond: Int = 0 // 0 = use device max (ProMotion) @AppStorage("decoderCompatibility") var decoderCompatibility: Bool = false // Enable for stutter issues on some devices From 9998919c2137b1fd5e7dd99a2c47026685503a74 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:02:37 +0300 Subject: [PATCH 25/40] =?UTF-8?q?Defer=20system=20edge=20gestures=20under?= =?UTF-8?q?=20pointer=20lock=20=E2=80=94=20fix=20trackpad=20dead-zone=20(S?= =?UTF-8?q?09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Magic Keyboard trackpad had a dead-zone at the bottom edge that appeared only inside Parsec. Root cause: prefersPointerLocked captures the pointer, but the project never set preferredScreenEdgesDeferringSystemGestures, so iPadOS kept ownership of the bottom-edge strip for its home-indicator/Control-Center swipe. Locked-pointer motion into that strip was partly eaten by the system recognizer and never reached our handling. Override preferredScreenEdgesDeferringSystemGestures = .all and trigger the update next to setNeedsUpdateOfPrefersPointerLocked(). A second deliberate swipe still hits the system gesture, so Control-Center access is preserved. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecViewController.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 4681a58..7a0a323 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -77,7 +77,18 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { override var prefersHomeIndicatorAutoHidden : Bool { return true } - + + // With the pointer locked (prefersPointerLocked), iPadOS still owns a strip at + // the bottom edge for the home-indicator / Control-Center swipe. Trackpad motion + // landing in that strip is partly eaten by the system gesture and never reaches + // our pointer handling, which the user feels as a dead-zone — and only inside + // Parsec, because the pointer lock is unique to this screen. Deferring the system + // edge gestures hands that strip back to the app. A second deliberate swipe still + // reaches the system gesture, so Control-Center access is preserved. + override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { + return .all + } + init() { super.init(nibName: nil, bundle: nil) @@ -289,7 +300,8 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { // will move it to the centre. setNeedsUpdateOfPrefersPointerLocked() - + setNeedsUpdateOfScreenEdgesDeferringSystemGestures() + let pointerInteraction = UIPointerInteraction(delegate: self) view.addInteraction(pointerInteraction) From 432d15227e10ab4285b6dbe74643e9b0d35b4628 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:04:36 +0300 Subject: [PATCH 26/40] =?UTF-8?q?Add=20Ctrl+Shift=E2=86=92Cmd+Space=20chor?= =?UTF-8?q?d=20+=20pressesCancelled=20key-up=20(S05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New opt-in: pressing Ctrl+Shift alone (nothing else in between) fires Cmd+Space at the host — the macOS input-source/Spotlight chord the iPad shell swallows. Implemented as a modifier-only chord state machine at the press level (not the per-scancode remap), so raw scancodes are still forwarded unchanged and host shortcuts like Ctrl+Shift+X keep working. Holding Ctrl+Shift then pressing any other key (e.g. Ctrl+Shift+Arrow) trips chordSawOtherKey and does NOT emulate. Alt or Cmd joining the combo disqualifies it too. Gated on a manual @AppStorage("ctrlShiftEmulatesCmdSpace") toggle (off by default, surfaced in Settings → Keyboard) rather than host OS, because S04's host-OS detection still resolves to .unknown until the Int→OS mapping is found empirically. Also adds pressesCancelled to the VC (K7): a cancelled press never delivers pressesEnded, so its key-up was lost — leaving a stuck modifier on the host and corrupting chord bookkeeping. Now forwards the key-up and resets chord state. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecViewController.swift | 126 +++++++++++++++++++++++++- OpenParsec/SettingsHandler.swift | 10 ++ OpenParsec/SettingsView.swift | 6 ++ 3 files changed, 139 insertions(+), 3 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 7a0a323..9fe1ce8 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -63,6 +63,16 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var mouseSensitivity: Float = Float(SettingsHandler.mouseSensitivity) var activatedPanFingerNumber: Int = 0 + // Ctrl+Shift→Cmd+Space chord state machine (S05). `chordArmed` is true while + // both Ctrl and Shift are held with nothing else pressed; `chordSawOtherKey` + // trips the moment any non-modifier key joins the combo, so Ctrl+Shift+Arrow + // (host selection) does NOT emulate Cmd+Space. We track held modifiers as a + // small set of HID usages rather than reading event.modifierFlags so left/ + // right Ctrl and Shift are both honored and key-up bookkeeping is exact. + private var heldModifierKeyCodes: Set = [] + private var chordArmed: Bool = false + private var chordSawOtherKey: Bool = false + var keyboardAccessoriesView : UIView? var keyboardHeight : CGFloat = 0.0 var keyboardVisible : Bool = false @@ -566,17 +576,127 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { for press in presses { + chordTrackPressBegan(press.key) + // Always forward the raw scancode unchanged — the Cmd+Space emulation + // is additive and fires only on a clean Ctrl+Shift release, so host + // shortcuts like Ctrl+Shift+X keep working. CParsec.sendKeyboardMessage(event:KeyBoardKeyEvent(input: press.key, isPressBegin: true) ) } } - + override func pressesEnded (_ presses: Set, with event: UIPressesEvent?) { - + for press in presses { CParsec.sendKeyboardMessage(event:KeyBoardKeyEvent(input: press.key, isPressBegin: false) ) + chordTrackPressEnded(press.key) + } + + } + + override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + super.pressesCancelled(presses, with: event) + // A cancelled press never delivers pressesEnded, so its key-up would be + // lost — both for the host (stuck modifier) and for our chord bookkeeping. + for press in presses { + CParsec.sendKeyboardMessage(event: KeyBoardKeyEvent(input: press.key, isPressBegin: false)) + chordTrackPressEnded(press.key) + } + // Any interruption invalidates an in-flight chord. + chordArmed = false + chordSawOtherKey = false + } + + // MARK: Ctrl+Shift → Cmd+Space chord machine (S05) + + private func isModifierUsage(_ usage: UIKeyboardHIDUsage) -> Bool { + switch usage { + case .keyboardLeftControl, .keyboardRightControl, + .keyboardLeftShift, .keyboardRightShift, + .keyboardLeftAlt, .keyboardRightAlt, + .keyboardLeftGUI, .keyboardRightGUI: + return true + default: + return false + } + } + + private func chordHasControl() -> Bool { + return heldModifierKeyCodes.contains(.keyboardLeftControl) + || heldModifierKeyCodes.contains(.keyboardRightControl) + } + + private func chordHasShift() -> Bool { + return heldModifierKeyCodes.contains(.keyboardLeftShift) + || heldModifierKeyCodes.contains(.keyboardRightShift) + } + + // Any modifier outside Ctrl/Shift (Alt or Cmd) disqualifies the chord — we + // only want a *clean* Ctrl+Shift, not Ctrl+Shift+Alt etc. + private func chordHasForeignModifier() -> Bool { + return heldModifierKeyCodes.contains(.keyboardLeftAlt) + || heldModifierKeyCodes.contains(.keyboardRightAlt) + || heldModifierKeyCodes.contains(.keyboardLeftGUI) + || heldModifierKeyCodes.contains(.keyboardRightGUI) + } + + private func chordTrackPressBegan(_ key: UIKey?) { + guard SettingsHandler.ctrlShiftEmulatesCmdSpace, let usage = key?.keyCode else { return } + if isModifierUsage(usage) { + heldModifierKeyCodes.insert(usage) + // Arm only on a clean Ctrl+Shift with no foreign modifier. + if chordHasControl() && chordHasShift() && !chordHasForeignModifier() { + chordArmed = true + chordSawOtherKey = false + } else if chordHasForeignModifier() { + // Alt/Cmd joined → this is a different combo, not our chord. + chordSawOtherKey = true + } + } else { + // A real key was pressed while modifiers are held → not a bare chord. + chordSawOtherKey = true + } + } + + private func chordTrackPressEnded(_ key: UIKey?) { + guard let usage = key?.keyCode else { return } + guard SettingsHandler.ctrlShiftEmulatesCmdSpace else { + // Setting may have flipped off mid-hold; keep the held-set honest. + if isModifierUsage(usage) { heldModifierKeyCodes.remove(usage) } + return + } + guard isModifierUsage(usage) else { return } + + let wasArmed = chordArmed + let cleanRelease = !chordSawOtherKey + // Fire when the last of the Ctrl/Shift pair lifts on a clean chord. + let isCtrlOrShift = (usage == .keyboardLeftControl || usage == .keyboardRightControl + || usage == .keyboardLeftShift || usage == .keyboardRightShift) + + heldModifierKeyCodes.remove(usage) + + if wasArmed && cleanRelease && isCtrlOrShift && !(chordHasControl() && chordHasShift()) { + // The pair is no longer both-held → the chord completed cleanly. + chordArmed = false + fireCmdSpace() + } + // Once neither Ctrl nor Shift remains, fully reset. + if !chordHasControl() && !chordHasShift() { + chordArmed = false + chordSawOtherKey = false + } + } + + private func fireCmdSpace() { + // LGUI = left Cmd. Press Cmd → tap Space → release Cmd, with the same + // ~50 ms modifier-hold the layout-sync chord uses so the host registers + // the combo intact. Note: iPadOS owns the *physical* Cmd+Space, but here + // we synthesize it as host scancodes, which the host honors. + CParsec.sendVirtualKeyboardInput(text: "LGUI", isOn: true) + CParsec.sendVirtualKeyboardInput(text: "SPACE") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + CParsec.sendVirtualKeyboardInput(text: "LGUI", isOn: false) } - } @objc func keyboardWillShow(notification: NSNotification) { diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 6f6558e..380bd0a 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -60,6 +60,16 @@ struct SettingsHandler { @AppStorage("syncKeyboardLayout") public static var syncKeyboardLayout: Bool = true @AppStorage("layoutSyncHotkey") public static var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace + // When true, pressing Ctrl+Shift *alone* (no other key in between) fires + // Cmd+Space at the host — the macOS "switch input source" / Spotlight chord + // the iPad shell otherwise swallows. Holding Ctrl+Shift and then pressing + // any other key (e.g. Ctrl+Shift+Arrow to extend a selection) is forwarded + // normally and does NOT fire the emulation. Off by default; intended for Mac + // hosts. Not auto-gated on host OS yet because host-OS detection (S04) still + // resolves to .unknown until the Int→OS mapping is discovered empirically — + // so this stays a deliberate manual opt-in. + @AppStorage("ctrlShiftEmulatesCmdSpace") public static var ctrlShiftEmulatesCmdSpace: Bool = false + @AppStorage("saveSessionSettings") public static var saveSessionSettings: Bool = true @AppStorage("savedZoomEnabled") public static var savedZoomEnabled: Bool = false @AppStorage("savedConstantFps") public static var savedConstantFps: Bool = false diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index fdca10e..80bcd52 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -26,6 +26,7 @@ struct SettingsView:View @AppStorage("showKeyboardButton") var showKeyboardButton: Bool = true @AppStorage("syncKeyboardLayout") var syncKeyboardLayout: Bool = true @AppStorage("layoutSyncHotkey") var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace + @AppStorage("ctrlShiftEmulatesCmdSpace") var ctrlShiftEmulatesCmdSpace: Bool = false @AppStorage("saveSessionSettings") var saveSessionSettings: Bool = true @State private var crashCopied: Bool = false @State private var diagCopied: Bool = false @@ -164,6 +165,11 @@ struct SettingsView:View Choice("Off", LayoutSyncHotkey.none) ]) } + CatItem("⌃⇧ → ⌘Space (Mac host)") + { + Toggle("", isOn:$ctrlShiftEmulatesCmdSpace) + .frame(width:80) + } CatItem("Capture System Shortcuts") { Toggle("", isOn:$captureSystemKeys) From 78f301754158d5302436be91dc9e09d46caf0992 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:05:42 +0300 Subject: [PATCH 27/40] Add poor-network low-latency profile: bitrate cap on first echo (S08) Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecSDKBridge.swift | 13 +++++++++++++ OpenParsec/SettingsView.swift | 15 +++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 03ca733..34cb104 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -345,6 +345,19 @@ class ParsecSDKBridge: ParsecService self.didSetResolution = true DataManager.model.resolutionX = SettingsHandler.resolution.width DataManager.model.resolutionY = SettingsHandler.resolution.height + // S08: apply the poor-network bitrate cap on the same + // first echo that pushes the saved resolution. Bitrate + // only reaches the host via updateHostVideoConfig (the + // connect-time SDK config carries no encoderMaxBitrate), + // and the case-11 branch above otherwise adopts the + // host's bitrate — so without this the profile's cap + // would never take effect. Riding the existing one-shot + // push avoids a separate timer/race and leaves `output` + // untouched, so it can't clobber the restored display. + if SettingsHandler.lowLatencyMode && SettingsHandler.bitrate > 0 { + DataManager.model.bitrate = SettingsHandler.bitrate + Diagnostics.note("S08 low-latency: capping bitrate to \(SettingsHandler.bitrate) Mbps (host reported \(videoConfig.encoderMaxBitrate))") + } self.updateHostVideoConfig() } } diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 80bcd52..74aeaca 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -226,11 +226,18 @@ struct SettingsView:View .frame(width:80) .onChange(of: lowLatencyMode) { newValue in if newValue { - // Flip the most impactful knobs in one shot. - // Users can still override individually after. - preferredFramesPerSecond = 0 - decoder = .h265 + // S08 — Poor-Network profile. Strip the latency-adding + // knobs AND cap bitrate: on a weak uplink an uncapped + // encoder outruns the link, and the resulting queue + // (bufferbloat) is what actually makes a stream feel + // laggy on bad WiFi. This trades sharpness for + // responsiveness — each knob stays individually + // overridable afterward. + preferredFramesPerSecond = 0 // device-max present rate + decoder = .h265 // more quality per capped bit noOverlay = true + decoderCompatibility = false // compat decode path adds latency + bitrate = 5 // poor-network ceiling (Mbps) } } } From e5c06f171dfef98dc841459b7f7b9b98221fe830 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:07:03 +0300 Subject: [PATCH 28/40] Guard network response/decode paths against crashes (S07 Q3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host/self/friends refresh handlers and the login handler force-cast the URLResponse (`as! HTTPURLResponse`) and force-decoded the JSON (`try! decode`). A network failure delivers a nil/non-HTTP response, and a malformed or partial body fails to decode — either one crashed the app on a flaky connection. Login also force-unwrapped `String(data:data, encoding:.utf8)!` in a debug print, crashing on any non-UTF-8 body. Replace with `response as? HTTPURLResponse` guards and `try?` decodes that no-op on failure, add an explicit network-error alert branch to each handler, and drop the force-unwrapped debug prints. Behavior on the success path is unchanged. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/LoginView.swift | 20 ++++--- OpenParsec/MainView.swift | 115 ++++++++++++++++++++----------------- 2 files changed, 76 insertions(+), 59 deletions(-) diff --git a/OpenParsec/LoginView.swift b/OpenParsec/LoginView.swift index 30bb609..7c2f5ec 100644 --- a/OpenParsec/LoginView.swift +++ b/OpenParsec/LoginView.swift @@ -192,15 +192,11 @@ struct LoginView:View { (data, response, error) in DispatchQueue.main.async { isLoading = false - if let data = data + if let data = data, let http = response as? HTTPURLResponse { - let statusCode:Int = (response as! HTTPURLResponse).statusCode + let statusCode:Int = http.statusCode let decoder = JSONDecoder() - print("Login Information:") - print(statusCode) - print(String(data:data, encoding:.utf8)!) - if statusCode == 201 // 201 Created { // store it and recover it from the next app opening, so people won't swear @@ -216,7 +212,12 @@ struct LoginView:View } else if statusCode >= 400 // 4XX client errors { - let info:ErrorInfo = try! decoder.decode(ErrorInfo.self, from:data) + guard let info = try? decoder.decode(ErrorInfo.self, from:data) else + { + alertText = "Login failed (HTTP \(statusCode))." + showAlert = true + return + } do { @@ -244,6 +245,11 @@ struct LoginView:View } } } + else + { + alertText = "Network error. Check your connection and try again." + showAlert = true + } } } task.resume() diff --git a/OpenParsec/MainView.swift b/OpenParsec/MainView.swift index 56bad12..8700df6 100644 --- a/OpenParsec/MainView.swift +++ b/OpenParsec/MainView.swift @@ -433,43 +433,51 @@ struct MainView: View let task = URLSession.shared.dataTask(with:request) { (data, response, error) in DispatchQueue.main.async { - if let data = data + if let data = data, let http = response as? HTTPURLResponse { - let statusCode:Int = (response as! HTTPURLResponse).statusCode + let statusCode:Int = http.statusCode let decoder = JSONDecoder() if statusCode == 200 // 200 OK { - let info:HostInfoList = try! decoder.decode(HostInfoList.self, from:data) - hosts.removeAll() - if let datas = info.data + if let info = try? decoder.decode(HostInfoList.self, from:data) { - datas.forEach - { h in - hosts.append(IdentifiableHostInfo(id:h.peer_id, hostname:h.name, user:h.user, connections:h.players)) + hosts.removeAll() + if let datas = info.data + { + datas.forEach + { h in + hosts.append(IdentifiableHostInfo(id:h.peer_id, hostname:h.name, user:h.user, connections:h.players)) + } } - } - var grammar: String = "hosts" - if hosts.count == 1 - { - grammar = "host" - } + var grammar: String = "hosts" + if hosts.count == 1 + { + grammar = "host" + } - hostCountStr = "\(hosts.count) \(grammar)" + hostCountStr = "\(hosts.count) \(grammar)" - let formatter = DateFormatter() - formatter.dateFormat = "M/d/yyyy h:mm a" - refreshTime = "Last refreshed at \(formatter.string(from:Date()))" + let formatter = DateFormatter() + formatter.dateFormat = "M/d/yyyy h:mm a" + refreshTime = "Last refreshed at \(formatter.string(from:Date()))" + } } else if statusCode == 403 // 403 Forbidden { - let info:ErrorInfo = try! decoder.decode(ErrorInfo.self, from:data) - - baseAlertText = "Error gathering hosts: \(info.error)" - showBaseAlert = true + if let info = try? decoder.decode(ErrorInfo.self, from:data) + { + baseAlertText = "Error gathering hosts: \(info.error)" + showBaseAlert = true + } } } + else if error != nil + { + baseAlertText = "Network error gathering hosts. Check your connection and try again." + showBaseAlert = true + } isRefreshing = false } @@ -499,22 +507,25 @@ struct MainView: View let task = URLSession.shared.dataTask(with:request) { (data, response, error) in DispatchQueue.main.async { - if let data = data + if let data = data, let http = response as? HTTPURLResponse { - let statusCode:Int = (response as! HTTPURLResponse).statusCode + let statusCode:Int = http.statusCode let decoder = JSONDecoder() if statusCode == 200 // 200 OK { - let data: SelfInfoData = try! decoder.decode(SelfInfo.self, from:data).data - userInfo = IdentifiableUserInfo(id:data.id, username:data.name) + if let selfData = try? decoder.decode(SelfInfo.self, from:data).data + { + userInfo = IdentifiableUserInfo(id:selfData.id, username:selfData.name) + } } else { - let info:ErrorInfo = try! decoder.decode(ErrorInfo.self, from:data) - - baseAlertText = "Error gathering user info: \(info.error)" - showBaseAlert = true + if let info = try? decoder.decode(ErrorInfo.self, from:data) + { + baseAlertText = "Error gathering user info: \(info.error)" + showBaseAlert = true + } } } } @@ -544,40 +555,40 @@ struct MainView: View let task = URLSession.shared.dataTask(with:request) { (data, response, error) in DispatchQueue.main.async { - if let data = data + if let data = data, let http = response as? HTTPURLResponse { - let statusCode:Int = (response as! HTTPURLResponse).statusCode + let statusCode:Int = http.statusCode let decoder = JSONDecoder() - print("/friendships: \(statusCode)") - print(String(data:data, encoding:.utf8)!) - if statusCode == 200 // 200 OK { - let info:FriendInfoList = try! decoder.decode(FriendInfoList.self, from:data) - friends.removeAll() - if let datas = info.data + if let info = try? decoder.decode(FriendInfoList.self, from:data) { - datas.forEach - { f in - friends.append(IdentifiableUserInfo(id:f.user_id, username:f.user_name)) + friends.removeAll() + if let datas = info.data + { + datas.forEach + { f in + friends.append(IdentifiableUserInfo(id:f.user_id, username:f.user_name)) + } } - } - var grammar: String = "friends" - if friends.count == 1 - { - grammar = "friend" - } + var grammar: String = "friends" + if friends.count == 1 + { + grammar = "friend" + } - friendCountStr = "\(friends.count) \(grammar)" + friendCountStr = "\(friends.count) \(grammar)" + } } else { - let info:ErrorInfo = try! decoder.decode(ErrorInfo.self, from:data) - - baseAlertText = "Error gathering friends: \(info.error)" - showBaseAlert = true + if let info = try? decoder.decode(ErrorInfo.self, from:data) + { + baseAlertText = "Error gathering friends: \(info.error)" + showBaseAlert = true + } } } From 091697ac55b77c83f173ea7b1e63c6dfad75d8da Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:07:36 +0300 Subject: [PATCH 29/40] Use weak NSMapTable for VC-patch storage to stop reconnect leak (S07 Q4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home-indicator and pointer-lock child-VC overrides stored their mapping in static [UIViewController: UIViewController] dictionaries, which strongly retain both the parent VC (key) and the ParsecViewController (value). The teardown path clears them on viewWillDisappear, but an abnormal disconnect that skips it leaked the entire Parsec VC — GLKView, gesture recognizers, render plumbing — once per reconnect. Switch both to NSMapTable.weakToWeakObjects(): neither side is retained, so a dropped VC deallocates and its entry auto-empties even without the explicit clear. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ViewContainerPatch.swift | 33 +++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/OpenParsec/ViewContainerPatch.swift b/OpenParsec/ViewContainerPatch.swift index e94bb19..38f0274 100644 --- a/OpenParsec/ViewContainerPatch.swift +++ b/OpenParsec/ViewContainerPatch.swift @@ -29,32 +29,39 @@ fileprivate extension NSObject { /// We need to set these when the VM starts running since there is no way to do it from SwiftUI right now extension UIViewController { - private static var _childForHomeIndicatorAutoHiddenStorage: [UIViewController: UIViewController] = [:] - + // Q4: these used `[UIViewController: UIViewController]`, which strongly + // retains BOTH the parent VC (key) and the ParsecViewController (value). + // If teardown's nil-clear didn't run (abnormal disconnect), the entry — and + // with it the GLKView + every gesture recognizer hanging off the Parsec VC — + // leaked for the lifetime of the process, accumulating one set per reconnect. + // NSMapTable.weakToWeakObjects() holds neither side, so a dropped VC + // deallocates and its entry auto-empties even without an explicit clear. + private static let _childForHomeIndicatorAutoHiddenStorage = NSMapTable.weakToWeakObjects() + @objc private dynamic var _childForHomeIndicatorAutoHidden: UIViewController? { - Self._childForHomeIndicatorAutoHiddenStorage[self] + Self._childForHomeIndicatorAutoHiddenStorage.object(forKey: self) } - + @objc dynamic func setChildForHomeIndicatorAutoHidden(_ value: UIViewController?) { if let value = value { - Self._childForHomeIndicatorAutoHiddenStorage[self] = value + Self._childForHomeIndicatorAutoHiddenStorage.setObject(value, forKey: self) } else { - Self._childForHomeIndicatorAutoHiddenStorage.removeValue(forKey: self) + Self._childForHomeIndicatorAutoHiddenStorage.removeObject(forKey: self) } setNeedsUpdateOfHomeIndicatorAutoHidden() } - - private static var _childViewControllerForPointerLockStorage: [UIViewController: UIViewController] = [:] - + + private static let _childViewControllerForPointerLockStorage = NSMapTable.weakToWeakObjects() + @objc private dynamic var _childViewControllerForPointerLock: UIViewController? { - Self._childViewControllerForPointerLockStorage[self] + Self._childViewControllerForPointerLockStorage.object(forKey: self) } - + @objc dynamic func setChildViewControllerForPointerLock(_ value: UIViewController?) { if let value = value { - Self._childViewControllerForPointerLockStorage[self] = value + Self._childViewControllerForPointerLockStorage.setObject(value, forKey: self) } else { - Self._childViewControllerForPointerLockStorage.removeValue(forKey: self) + Self._childViewControllerForPointerLockStorage.removeObject(forKey: self) } setNeedsUpdateOfPrefersPointerLocked() } From babeb67e932329c2cfaead7861d7f0f9e05a9f6d Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:08:09 +0300 Subject: [PATCH 30/40] Harden monitor switch: suppress false disconnect alert + GL resume (S10 D1/D2) Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecView.swift | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 9160be1..3749b14 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -640,7 +640,28 @@ struct ParsecView: View SettingsHandler.savedDisplayName = "\(cfg.name) \(cfg.adapterName)" } } + + // S10: switching the streamed monitor makes the host re-init its + // encoder for the new display (usually a different resolution), + // forcing a client-side decoder reset. During that window + // getStatusEx briefly returns non-OK, and the poll loop pops a + // spurious "Disconnected" alert — suppressed only while + // isReconfiguring. changeResolution raises that flag; changeDisplay + // historically never did (D1). Bracket the switch the same way. + self.isReconfiguring = true CParsec.updateHostVideoConfig() + + // No reconnect happens here, so there's no completion callback to + // clear the flag — use a fixed window covering + // updateHostVideoConfig's 250/450 ms resend + echo cycle plus + // headroom. Resume the GL surface once it settles in case the + // decoder reset blanked it (D2 — same failure class as S01, on a + // path viewDidAppear/foreground hooks don't cover since no screen + // transition occurs here). + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.parsecViewController.glkView?.resume() + self.isReconfiguring = false + } } } From 4bf993903762c0c5d0caac5d31c5fd3a0af3c262 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:09:01 +0300 Subject: [PATCH 31/40] Harden version string + fix typo (S07 Q5) getVersionInfo force-unwrapped Bundle.main.infoDictionary! twice and cast its values implicitly, and carried a "Unknown versino" typo in the fallback. Read the dictionary optionally with typed `as? String` fallbacks and fix the typo. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/SettingsView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 74aeaca..2fee8da 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -321,7 +321,10 @@ struct SettingsView:View } func getVersionInfo() -> String { - return "Version \(Bundle.main.infoDictionary!["CFBundleShortVersionString"] ?? "Unknown versino")-\(Bundle.main.infoDictionary!["GitCommitInfo"] ?? "Unknown commit")" + let info = Bundle.main.infoDictionary + let version = info?["CFBundleShortVersionString"] as? String ?? "Unknown version" + let commit = info?["GitCommitInfo"] as? String ?? "Unknown commit" + return "Version \(version)-\(commit)" } } From 437ce382ca50fab9b178d60c5d1cf1282706ccde Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 03:11:45 +0300 Subject: [PATCH 32/40] Fix build: EAGLContext.setCurrentContext renamed to setCurrent (Xcode 26.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S01 resume() path called EAGLContext.setCurrentContext(_:), which the SDK shipped with Xcode 26.2 has renamed to setCurrent(_:). This broke the CI build (the only compiler we have — no local iOS SDK). Use the new name; discard its Bool return with `_ =`. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecGLKViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenParsec/ParsecGLKViewController.swift b/OpenParsec/ParsecGLKViewController.swift index c09d709..d6fdcec 100644 --- a/OpenParsec/ParsecGLKViewController.swift +++ b/OpenParsec/ParsecGLKViewController.swift @@ -92,7 +92,7 @@ class ParsecGLKViewController : ParsecPlayground { // PiP, background) is self-healed here. func resume() { if let ctx = glkView?.context { - EAGLContext.setCurrentContext(ctx) + _ = EAGLContext.setCurrent(ctx) } glkViewController.isPaused = false glkView?.setNeedsDisplay() From 5249b85bd23e931fec50c9864dde1054c6a1cead Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 14:52:45 +0300 Subject: [PATCH 33/40] Restore trackpad scroll inertia with frame-rate-independent glide (S03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S03 removed client-side momentum on the assumption iPadOS feeds a deceleration tail "for free". It does not on this path: the trackpad scroll handler is driven by a raw UIPanGestureRecognizer (allowedScrollTypesMask = .all, maximumNumberOfTouches = 0), and only a UIScrollView synthesizes post-lift momentum — a raw recognizer gets none. So a flick died instantly and inertia disappeared entirely (user-confirmed on device). Re-seed a decaying glide from the PEAK velocity sampled during .changed (velocity(in:) at .ended is already ~0 because iPadOS pre-decelerates its event stream), driven by a CADisplayLink. Both the per-tick advance and the exponential decay are scaled by the real frame duration (targetTimestamp - timestamp), so the glide distance is identical at 60 Hz and 120 Hz — fixing the original hardcoded `peak/60` doubling bug without throwing the feature away. Glide is cancelled on a new scroll, zoom, and view teardown (deinit + viewWillDisappear) so the link can't retain the VC or send wheel msgs into a dead session. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecViewController.swift | 114 +++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 10 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 9fe1ce8..08ab267 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -49,12 +49,33 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { var accumulatedScrollY: Float = 0.0 var lastScrollTranslation: CGPoint = .zero - // S03: client-side scroll inertia was REMOVED. iPadOS already synthesizes - // trackpad/scroll deceleration as a tail of continued `.changed` events, so - // the old CADisplayLink momentum stacked a second inertia on top — and its - // seed was hardcoded to 60 Hz (`peak/60`), so on a 120 Hz iPad the glide - // distance literally doubled. We now forward native deltas only (the - // Moonlight-iOS approach); the OS-provided momentum rides in for free. + // Client-side scroll inertia. The trackpad scroll path is driven by a raw + // UIPanGestureRecognizer (allowedScrollTypesMask = .all, max 0 touches), NOT + // a UIScrollView — and a raw recognizer gets NO OS-provided momentum tail + // after the fingers lift (only UIScrollView synthesizes deceleration). So a + // flick died instantly and the host saw no glide. S03 removed the old inertia + // on the wrong assumption that "the OS momentum rides in for free"; it does + // not on this recognizer, which is why inertia disappeared entirely. + // + // We re-seed a decaying glide from the PEAK velocity sampled during `.changed` + // (gestureRecognizer.velocity at `.ended` is already near-zero because iPadOS + // pre-decelerates its scroll-event stream) and drive it with a CADisplayLink. + // BOTH the per-tick advance and the decay are scaled by the real frame + // duration, so the glide distance is identical at 60 Hz and 120 Hz — fixing + // the original bug (a hardcoded `peak/60` seed that doubled the glide on the + // 120 Hz M4 iPad) without throwing away the feature. + var momentumDisplayLink: CADisplayLink? + var momentumVelocityX: Float = 0.0 // scaled host-wheel units / second + var momentumVelocityY: Float = 0.0 + private var peakScrollVelocityX: CGFloat = 0 // raw points / second + private var peakScrollVelocityY: CGFloat = 0 + // Glide tuning. Decay is expressed per-second and raised to dt each tick, so + // it is frame-rate independent. Start/stop are in scaled units/sec (same + // scale as momentumVelocity). These are deliberately conservative; the real + // feel can only be judged on-device, so they are easy to nudge. + private let scrollMomentumDecayPerSecond: Float = 0.02 // → ~2% of speed remains after 1 s + private let scrollMomentumStartSpeed: Float = 60.0 // below this a release doesn't glide + private let scrollMomentumStopSpeed: Float = 24.0 // below this the glide ends // Layout sync — fires a hotkey at the host when the iPad's hardware-keyboard // input language changes (e.g. Caps Lock toggle on Magic Keyboard). @@ -114,6 +135,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { deinit { languageSync?.stop() + stopScrollMomentum() } func updateImage() { @@ -460,6 +482,7 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) stopLanguageSync() + stopScrollMomentum() } @@ -867,9 +890,13 @@ extension ParsecViewController : UIGestureRecognizerDelegate { switch gestureRecognizer.state { case .began: + // A fresh scroll cancels any glide still in progress. + stopScrollMomentum() lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 + peakScrollVelocityX = 0 + peakScrollVelocityY = 0 case .changed: let translation = gestureRecognizer.translation(in: gestureRecognizer.view) // When the user is zoomed in, let UIScrollView's own pan handle the @@ -887,9 +914,14 @@ extension ParsecViewController : UIGestureRecognizerDelegate { let deltaY = Float(translation.y - lastScrollTranslation.y) * sensitivity * direction lastScrollTranslation = translation - // Forward the native delta. iPadOS keeps sending `.changed` events - // through its own deceleration tail after the fingers lift, so the - // momentum arrives here for free — no client-side inertia needed. + // Track the peak velocity over the gesture. `velocity(in:)` at + // `.ended` is already near-zero because iPadOS pre-decelerates its + // scroll-event stream, so we seed the post-lift glide from the peak + // seen mid-gesture instead. + let v = gestureRecognizer.velocity(in: gestureRecognizer.view) + if abs(v.x) > abs(peakScrollVelocityX) { peakScrollVelocityX = v.x } + if abs(v.y) > abs(peakScrollVelocityY) { peakScrollVelocityY = v.y } + accumulatedScrollX += deltaX accumulatedScrollY += deltaY // Rounding accumulator: Int32 truncation previously swallowed @@ -902,15 +934,77 @@ extension ParsecViewController : UIGestureRecognizerDelegate { accumulatedScrollX -= Float(intX) accumulatedScrollY -= Float(intY) } - case .ended, .cancelled, .failed: + case .ended: + // Seed a decaying glide from the peak velocity (scaled the same way + // the live deltas were). The accumulator carries over so sub-pixel + // remainder isn't lost between the live phase and the glide. + lastScrollTranslation = .zero + if scrollView.zoomScale <= 1.0 { + startScrollMomentum( + velX: Float(peakScrollVelocityX) * sensitivity * direction, + velY: Float(peakScrollVelocityY) * sensitivity * direction + ) + } + peakScrollVelocityX = 0 + peakScrollVelocityY = 0 + case .cancelled, .failed: + stopScrollMomentum() lastScrollTranslation = .zero accumulatedScrollX = 0.0 accumulatedScrollY = 0.0 + peakScrollVelocityX = 0 + peakScrollVelocityY = 0 default: break } } + // MARK: - Trackpad scroll momentum (client-side inertia) + + private func startScrollMomentum(velX: Float, velY: Float) { + stopScrollMomentum() + // Don't bother gliding for a near-stationary release. + guard hypotf(velX, velY) >= scrollMomentumStartSpeed else { return } + momentumVelocityX = velX + momentumVelocityY = velY + let link = CADisplayLink(target: self, selector: #selector(stepScrollMomentum(_:))) + link.add(to: .main, forMode: .common) + momentumDisplayLink = link + } + + @objc private func stepScrollMomentum(_ link: CADisplayLink) { + // dt is the real duration of the upcoming frame, so the glide travels the + // same distance per second at 60 Hz and 120 Hz (the bug that doubled the + // old hardcoded `peak/60` seed on the 120 Hz iPad). Clamp out the absurd + // first-tick / stall values. + let dt = Float(link.targetTimestamp - link.timestamp) + guard dt > 0, dt < 0.1 else { return } + + accumulatedScrollX += momentumVelocityX * dt + accumulatedScrollY += momentumVelocityY * dt + let intX = Int32(accumulatedScrollX.rounded(.toNearestOrAwayFromZero)) + let intY = Int32(accumulatedScrollY.rounded(.toNearestOrAwayFromZero)) + if intX != 0 || intY != 0 { + CParsec.sendWheelMsg(x: intX, y: intY) + accumulatedScrollX -= Float(intX) + accumulatedScrollY -= Float(intY) + } + + let decay = powf(scrollMomentumDecayPerSecond, dt) + momentumVelocityX *= decay + momentumVelocityY *= decay + if hypotf(momentumVelocityX, momentumVelocityY) < scrollMomentumStopSpeed { + stopScrollMomentum() + } + } + + private func stopScrollMomentum() { + momentumDisplayLink?.invalidate() + momentumDisplayLink = nil + momentumVelocityX = 0 + momentumVelocityY = 0 + } + @objc func handleSingleFingerTap(_ gestureRecognizer: UITapGestureRecognizer) { let location = gestureRecognizer.location(in:gestureRecognizer.view) From d861222f14a2fbcbfcb4799fddfb834979b318fe Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 14:52:55 +0300 Subject: [PATCH 34/40] Confirm-and-retry display switch so it lands on the first tap (S10/S06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A monitor switch is a fire-and-forget setVideoConfig with a single 250 ms resend. Switching re-inits the host encoder for the new output; requests landing during that reset are dropped, with no confirmation or further retry. That is the "needs two or three taps" symptom — and why the workaround "select the current monitor, then the target" worked: it spaced two sends by a human delay long enough for the host to settle. Close the loop: track the requested output as pendingOutput, re-assert it on a widening schedule (0.35/0.8/1.5/2.5 s) until the case-11 echo reports it as the host's current output, then stop. Each resend is idempotent (reapplying the active output is a host no-op), so over-asserting is harmless; under-asserting was the bug. The echo is only READ for confirmation, never written back into model.output, so a host that omits/normalizes the field can't clobber the user's selection (same caution as the bitrate confirm guard). "none" (Auto) has no stable id to confirm against, so it keeps the plain base send. The cap + revision guard ensure a host that never echoes a match can't leave the guard pending or stack retries across a newer change. Session-restore (case 12) rides the same path, so a remembered display reasserts too. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecSDKBridge.swift | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 34cb104..5ca133a 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -115,6 +115,15 @@ class ParsecSDKBridge: ParsecService // token (benign plain-Int read, same pattern as backgroundTaskRunning). private var configRevision: Int = 0 private var pendingUserConfig: (bitrate: Int, constantFps: Bool)? = nil + // The display id a switch is still trying to land. A single fire-and-forget + // setVideoConfig can be dropped while the host encoder is mid-reset from the + // switch — the reason a display change historically needed two or three taps + // (and why "select the current monitor, then the target" worked: it spaced + // two sends by a human delay). We keep re-asserting this output on a widening + // schedule until the case-11 echo reports it as the host's current output, or + // we hit the attempt cap. Cleared on the main thread; the retry closures read + // it lock-free (same benign-plain-read pattern as pendingUserConfig). + private var pendingOutput: String? = nil // Atomic snapshot for cross-thread readers. Returns a consistent copy of // the whole struct under the lock so `cursorImg`'s retain happens while no @@ -333,6 +342,14 @@ class ParsecSDKBridge: ParsecService DataManager.model.bitrate = videoConfig.encoderMaxBitrate DataManager.model.constantFps = videoConfig.fullFPS } + // Confirm a display switch: once the host echoes the output we + // asked for, stop re-asserting it. We only READ the echoed + // output here (never write it back into model.output) so a + // host that omits/normalizes the field can't clobber the + // user's selection — same caution as the bitrate guard above. + if let pendingOut = self.pendingOutput, videoConfig.output == pendingOut { + self.pendingOutput = nil + } // S04: capture the host-OS int and log it once per session // change so its undocumented encoding can be discovered // against known Mac/Windows hosts. @@ -786,6 +803,16 @@ class ParsecSDKBridge: ParsecService configRevision &+= 1 let revision = configRevision pendingUserConfig = (DataManager.model.bitrate, DataManager.model.constantFps) + // Track a concrete display switch so the confirm-and-retry loop below can + // keep re-asserting it. "none" (Auto) has no stable echoed id to confirm + // against, so it just rides the base send + the existing 250 ms resend. + let switchTarget = DataManager.model.output + if switchTarget != "none" && !switchTarget.isEmpty { + pendingOutput = switchTarget + scheduleDisplaySwitchRetry(revision: revision, data: data, attempt: 0) + } else { + pendingOutput = nil + } CParsec.sendUserData(type: .setVideoConfig, message: data) // User reports: display switches needed two or three taps to actually @@ -813,4 +840,34 @@ class ParsecSDKBridge: ParsecService self.pendingUserConfig = nil } } + + // Re-assert a display switch on a widening schedule until the host's case-11 + // echo confirms it (which clears pendingOutput) or we exhaust the attempts. + // A display change re-inits the host encoder for the new output; a request + // that lands during that reset is dropped, so one fire-and-forget send (or a + // single 250 ms resend, which fires while the host is often still busy) was + // unreliable — hence the historical "needs two or three taps". Each resend is + // idempotent (same output reapplied is a no-op once it's the active display), + // so over-asserting is harmless; under-asserting was the bug. + private func scheduleDisplaySwitchRetry(revision: Int, data: Data, attempt: Int) { + let delays: [Double] = [0.35, 0.8, 1.5, 2.5] + guard attempt < delays.count else { + // Give up re-asserting; drop the guard so a host that never echoes a + // matching output can't leave it pending forever. + DispatchQueue.main.async { [weak self] in + guard let self = self, self.configRevision == revision else { return } + self.pendingOutput = nil + } + return + } + DispatchQueue.global().asyncAfter(deadline: .now() + delays[attempt]) { [weak self] in + guard let self = self, self.backgroundTaskRunning, self.configRevision == revision else { return } + // Confirmed by an echo (or superseded)? Stop. + guard self.pendingOutput != nil else { return } + CParsec.sendUserData(type: .setVideoConfig, message: data) + let empty = "".data(using: .utf8)! + CParsec.sendUserData(type: .getVideoConfig, message: empty) + self.scheduleDisplaySwitchRetry(revision: revision, data: data, attempt: attempt + 1) + } + } } From eac24b720b650e5ee87092608649b7c59fae1ee8 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 16:46:14 +0300 Subject: [PATCH 35/40] Keep stream alive on brief backgrounding via keep-alive grace window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backgrounding without PiP previously dropped the Parsec session the instant the app resigned active, forcing a full reconnect (with its connect handshake + 0.5s delay) on return. Now SceneDelegate opens a finite UIBackgroundTask keep-alive window instead: a quick app-switch that returns inside the window resumes the still-live connection instantly, while staying away past it (or the OS reclaiming the time via the task's expiration handler) falls back to the existing disconnect+reconnect path. The expiry path tears the SDK session down synchronously (CParsec.disconnect) before releasing the background-task assertion, matching the PiP-stop path's suspend-hazard guard — relying on the async UI-notification to reach disconnect() risks iOS suspending us first. GLKViewController auto-pauses its render loop on resign-active, so no GL work runs during the window. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecBackgroundManager.swift | 94 +++++++++++++++++++++--- OpenParsec/SceneDelegate.swift | 18 +++-- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/OpenParsec/ParsecBackgroundManager.swift b/OpenParsec/ParsecBackgroundManager.swift index 6b27361..926a621 100644 --- a/OpenParsec/ParsecBackgroundManager.swift +++ b/OpenParsec/ParsecBackgroundManager.swift @@ -9,6 +9,19 @@ class ParsecBackgroundManager { private var didDisconnectDueToBackground = false private(set) var isReconnecting = false + // Keep-alive grace window: when the app is backgrounded without PiP we + // hold a finite UIBackgroundTask instead of dropping the stream at once. + // A quick app-switch that returns inside the window resumes the live + // connection instantly; staying away past it (or the OS reclaiming the + // time via the expiration handler) falls back to the disconnect+reconnect + // path. beginBackgroundTask needs no background-mode entitlement. + private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid + private var graceTimer: Timer? + // Held under iOS's typical ~30 s background allowance so our timer — not a + // hard OS suspension — usually drives the disconnect; the expiration + // handler is the backstop when the OS grants less time. + private let backgroundGracePeriod: TimeInterval = 20 + var onShouldReconnect: ((String) -> Void)? var onShouldDisconnect: (() -> Void)? @@ -41,6 +54,14 @@ class ParsecBackgroundManager { } func sceneDidBecomeActive() { + // Returned inside the keep-alive window: the connection was never torn + // down, so cancel the pending disconnect and resume instantly. No + // reconnect — didDisconnectDueToBackground was never set. + if graceTimer != nil { + endBackgroundGrace() + return + } + // Takes priority over isPiPActive check because stopPiP() is async if didDisconnectDueToBackground, let peerId = lastPeerId { didDisconnectDueToBackground = false @@ -52,15 +73,68 @@ class ParsecBackgroundManager { } } - func sceneDidEnterBackground() { - if hasActiveConnection { - var pipAttempted = false - if #available(iOS 15.0, *) { - pipAttempted = isPiPActive || PictureInPictureManager.shared.isStarting - } - if !pipAttempted { - didDisconnectDueToBackground = true - } + // Backgrounded without PiP: open a finite keep-alive window rather than + // dropping the stream immediately. Called from SceneDelegate only when a + // connection is active and PiP was not attempted. + func beginBackgroundGrace() { + guard hasActiveConnection else { return } + // Re-entrancy: a window is already open, leave it running. + guard backgroundTaskId == .invalid else { return } + + backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "ParsecKeepAlive") { [weak self] in + // OS is about to suspend us — disconnect now while we still can. + self?.expireBackgroundGrace() + } + + // No background time granted: fall back to the old immediate drop. + if backgroundTaskId == .invalid { + triggerBackgroundDisconnect() + return + } + + graceTimer?.invalidate() + graceTimer = Timer.scheduledTimer(withTimeInterval: backgroundGracePeriod, repeats: false) { [weak self] _ in + self?.expireBackgroundGrace() + } + } + + // Window survived to its end (timer fired or OS reclaimed the time): + // disconnect and let the normal return-to-foreground reconnect take over. + private func expireBackgroundGrace() { + guard backgroundTaskId != .invalid else { return } + graceTimer?.invalidate() + graceTimer = nil + triggerBackgroundDisconnect() + endBackgroundTaskAssertion() + } + + // Window cancelled because the app came back: keep the live connection. + private func endBackgroundGrace() { + graceTimer?.invalidate() + graceTimer = nil + endBackgroundTaskAssertion() + } + + private func triggerBackgroundDisconnect() { + didDisconnectDueToBackground = true + // Tear the SDK session down synchronously *here*. When this runs from + // the UIBackgroundTask expiration handler, iOS may suspend us the + // instant endBackgroundTaskAssertion() releases the assertion, so we + // cannot rely on the async UI-notification path to reach + // CParsec.disconnect() in time — the same suspend hazard the PiP-stop + // path guards against (ParsecView.post). disconnect() also flips + // hasActiveConnection to false via connectionDidEnd(), so a second + // background event can't reopen a window over an already-dropped + // session. The notification below then does the UI teardown (return to + // main view, GL cleanup), which is safe to finish on the next runloop. + CParsec.disconnect() + onShouldDisconnect?() + } + + private func endBackgroundTaskAssertion() { + if backgroundTaskId != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTaskId) + backgroundTaskId = .invalid } } @@ -73,5 +147,7 @@ class ParsecBackgroundManager { didDisconnectDueToBackground = false isReconnecting = false lastPeerId = nil + // An explicit disconnect cancels any open keep-alive window. + endBackgroundGrace() } } diff --git a/OpenParsec/SceneDelegate.swift b/OpenParsec/SceneDelegate.swift index 6534d8f..50f6e49 100644 --- a/OpenParsec/SceneDelegate.swift +++ b/OpenParsec/SceneDelegate.swift @@ -41,18 +41,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate func sceneDidEnterBackground(_ scene: UIScene) { + guard ParsecBackgroundManager.shared.hasActiveConnection else { return } + var pipAttempted = false if #available(iOS 15.0, *) { - if ParsecBackgroundManager.shared.hasActiveConnection { - PictureInPictureManager.shared.startPiP() - pipAttempted = PictureInPictureManager.shared.isPiPActive || PictureInPictureManager.shared.isStarting - } + PictureInPictureManager.shared.startPiP() + pipAttempted = PictureInPictureManager.shared.isPiPActive || PictureInPictureManager.shared.isStarting } - if !pipAttempted && ParsecBackgroundManager.shared.hasActiveConnection { - ParsecBackgroundManager.shared.onShouldDisconnect?() + // PiP keeps the stream alive in the background; its own stop/fail + // callbacks own the disconnect from there. Without PiP, hold a short + // keep-alive window so a quick app-switch resumes instantly instead of + // forcing a full reconnect. + if !pipAttempted { + ParsecBackgroundManager.shared.beginBackgroundGrace() } - - ParsecBackgroundManager.shared.sceneDidEnterBackground() } } From a1c53162f50359290daa8de46c7ff32accba85e7 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 16:46:21 +0300 Subject: [PATCH 36/40] Review fixes: poll-timer leak, chord partial-release, pendingOutput race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs found reviewing the branch (main...fix/trackpad-input): - MainView.connectTo reassigned pollTimer without invalidating an in-flight one. A background-reconnect (onShouldReconnect -> connectTo) or a rapid re-tap during the connecting phase orphaned the old repeating timer; both then fired, racing setView(.parsec)/showBaseAlert. Invalidate before scheduling. - The Ctrl+Shift->Cmd+Space chord (S05) fired on the FIRST of the pair releasing while the other was still held: dropping Ctrl to continue with Shift+ spuriously emitted Cmd+Space. Fire only once the pair has fully lifted (neither Ctrl nor Shift held), matching the documented intent. - pendingOutput (String?) was read off-main in the display-switch retry closure while mutated on main — a data race on a non-atomic reference, not the 'benign plain-read' the comment claimed. Read it on the main queue. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/MainView.swift | 7 +++++++ OpenParsec/ParsecSDKBridge.swift | 18 ++++++++++++------ OpenParsec/ParsecViewController.swift | 8 +++++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/OpenParsec/MainView.swift b/OpenParsec/MainView.swift index 8700df6..9c7015b 100644 --- a/OpenParsec/MainView.swift +++ b/OpenParsec/MainView.swift @@ -607,6 +607,13 @@ struct MainView: View var status = CParsec.connect(who.id) + // Invalidate any in-flight poll timer before scheduling a new one. A + // background-reconnect (onShouldReconnect -> connectTo) or a rapid + // re-tap during the connecting phase can call connectTo while a prior + // timer is still live; without this the old repeating timer is orphaned + // and both fire, racing setView(.parsec)/showBaseAlert. + pollTimer?.invalidate() + // Polling status pollTimer = Timer.scheduledTimer(withTimeInterval:1, repeats: true) { timer in diff --git a/OpenParsec/ParsecSDKBridge.swift b/OpenParsec/ParsecSDKBridge.swift index 5ca133a..8d57b83 100644 --- a/OpenParsec/ParsecSDKBridge.swift +++ b/OpenParsec/ParsecSDKBridge.swift @@ -862,12 +862,18 @@ class ParsecSDKBridge: ParsecService } DispatchQueue.global().asyncAfter(deadline: .now() + delays[attempt]) { [weak self] in guard let self = self, self.backgroundTaskRunning, self.configRevision == revision else { return } - // Confirmed by an echo (or superseded)? Stop. - guard self.pendingOutput != nil else { return } - CParsec.sendUserData(type: .setVideoConfig, message: data) - let empty = "".data(using: .utf8)! - CParsec.sendUserData(type: .getVideoConfig, message: empty) - self.scheduleDisplaySwitchRetry(revision: revision, data: data, attempt: attempt + 1) + // pendingOutput is mutated on the main queue (set in updateHostVideoConfig, + // cleared by the case-11 confirmation echo). It's a String?, not an atomic + // scalar, so read it on main to avoid racing that clear. Re-assert there if + // the switch is still unconfirmed — sending on main is fine (the base send + // at the top of updateHostVideoConfig already does). + DispatchQueue.main.async { + guard self.backgroundTaskRunning, self.configRevision == revision, self.pendingOutput != nil else { return } + CParsec.sendUserData(type: .setVideoConfig, message: data) + let empty = "".data(using: .utf8)! + CParsec.sendUserData(type: .getVideoConfig, message: empty) + self.scheduleDisplaySwitchRetry(revision: revision, data: data, attempt: attempt + 1) + } } } } diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 08ab267..7942244 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -698,9 +698,11 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { heldModifierKeyCodes.remove(usage) - if wasArmed && cleanRelease && isCtrlOrShift && !(chordHasControl() && chordHasShift()) { - // The pair is no longer both-held → the chord completed cleanly. - chordArmed = false + // Fire only once the pair has *fully* lifted (neither Ctrl nor Shift + // still held). Keying on "not both-held" instead would fire on the + // FIRST release while the other modifier is still down — so dropping + // Ctrl to continue with Shift+ would spuriously emit Cmd+Space. + if wasArmed && cleanRelease && isCtrlOrShift && !chordHasControl() && !chordHasShift() { fireCmdSpace() } // Once neither Ctrl nor Shift remains, fully reset. From 5501c0c5be085465ae37692c8ef6b54ceb7c7ce8 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 17:05:34 +0300 Subject: [PATCH 37/40] Debounce disconnect: require ~1s of non-OK polls before alerting A single non-OK getStatusEx poll on a jittery link is usually the BUD transport mid-recovery (loss/RTT spike), not a real disconnect. Alerting on the first bad poll tore down healthy sessions on unstable networks. Now require 5 consecutive non-OK polls (~1s at the 0.2s interval) before showing Disconnected; any OK poll resets the streak. A genuine drop persists and still surfaces within ~1s. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecView.swift | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 3749b14..3faf7fb 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -11,6 +11,12 @@ struct ParsecStatusBar : View { @Binding var DCAlertText: String @State var parsecViewController: ParsecViewController? @State var wasDisconnected: Bool = true + // Consecutive non-OK polls before we believe the session is really gone. + // At a 0.2s poll interval, 5 ≈ 1s of grace — long enough to ride out a + // transient loss/RTT spike on a jittery link (BUD recovers on its own), + // short enough that a genuine drop still surfaces promptly. + @State var consecutiveFailures: Int = 0 + private let disconnectFailureThreshold = 5 let timer = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() init(isReconfiguring: Binding, showMenu: Binding, showDCAlert: Binding, DCAlertText: Binding, parsecViewController: ParsecViewController) { @@ -110,12 +116,22 @@ struct ParsecStatusBar : View { return } - wasDisconnected = true - DCAlertText = "Disconnected (code \(status.rawValue))" - showDCAlert = true + // Debounce transient loss: a single bad poll on a jittery link is + // usually the BUD transport mid-recovery, not a real disconnect. + // Only alert after the status has been non-OK for several polls in + // a row; a genuine drop persists and still surfaces within ~1s. + consecutiveFailures += 1 + if consecutiveFailures >= disconnectFailureThreshold { + wasDisconnected = true + DCAlertText = "Disconnected (code \(status.rawValue))" + showDCAlert = true + } return } - + + // Status is OK — clear any in-flight failure streak. + consecutiveFailures = 0 + if showMenu { let str = String.fromBuffer(&pcs.decoder.0.name.0, length:16) From 717e5965c5c11114929d749cd7d21a10e302aa83 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 17:05:39 +0300 Subject: [PATCH 38/40] Send coalesced trackpad samples so fast motion tracks at sensor rate The cursor path sent mouse deltas at the UIKit event rate, sub-sampling fast trackpad flicks into large jumpy steps. Iterate coalescedTouches (up to 120 Hz) and sum per-sample deltas; each sample's precisePreviousLocation chains to the prior sample, so the full motion path is reconstructed and the cursor tracks fast movement smoothly. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/ParsecViewController.swift | 53 ++++++++++++++++----------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 7942244..0565b4f 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -513,28 +513,37 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { CParsec.sendMousePosition(Int32(adjusted.x), Int32(adjusted.y)) moveLocalCursor(to: adjusted) } else { - let prev = touch.precisePreviousLocation(in: view) - let cur = touch.preciseLocation(in: view) - let rawDX = cur.x - prev.x - let rawDY = cur.y - prev.y - let scale = effectiveDeltaScale(rawDX: rawDX, rawDY: rawDY) - let preciseDX = rawDX * scale - let preciseDY = rawDY * scale - - // Move the LOCAL cursor at full sub-pixel precision so it - // glides smoothly even when the rounded host-delta is zero. - moveLocalCursor(byX: preciseDX, y: preciseDY) - - accumulatedDeltaX += Float(preciseDX) - accumulatedDeltaY += Float(preciseDY) - // Round-half-away-from-zero (not Int32 truncation) so that - // sub-pixel ticks like 0.4 still emit a 1-pixel send to host. - let dx = Int32(accumulatedDeltaX.rounded(.toNearestOrAwayFromZero)) - let dy = Int32(accumulatedDeltaY.rounded(.toNearestOrAwayFromZero)) - if dx != 0 || dy != 0 { - CParsec.sendMouseDelta(dx, dy) - accumulatedDeltaX -= Float(dx) - accumulatedDeltaY -= Float(dy) + // Iterate coalesced samples so fast trackpad motion is sent at + // the hardware sample rate (up to 120 Hz) rather than being + // sub-sampled to the slower UIKit event rate. Each sample's + // precisePreviousLocation chains to the prior sample, so summing + // per-sample deltas reconstructs the full path — the cursor + // tracks fast flicks smoothly instead of jumping in big steps. + let samples = event?.coalescedTouches(for: touch) ?? [touch] + for sample in samples { + let prev = sample.precisePreviousLocation(in: view) + let cur = sample.preciseLocation(in: view) + let rawDX = cur.x - prev.x + let rawDY = cur.y - prev.y + let scale = effectiveDeltaScale(rawDX: rawDX, rawDY: rawDY) + let preciseDX = rawDX * scale + let preciseDY = rawDY * scale + + // Move the LOCAL cursor at full sub-pixel precision so it + // glides smoothly even when the rounded host-delta is zero. + moveLocalCursor(byX: preciseDX, y: preciseDY) + + accumulatedDeltaX += Float(preciseDX) + accumulatedDeltaY += Float(preciseDY) + // Round-half-away-from-zero (not Int32 truncation) so that + // sub-pixel ticks like 0.4 still emit a 1-pixel send to host. + let dx = Int32(accumulatedDeltaX.rounded(.toNearestOrAwayFromZero)) + let dy = Int32(accumulatedDeltaY.rounded(.toNearestOrAwayFromZero)) + if dx != 0 || dy != 0 { + CParsec.sendMouseDelta(dx, dy) + accumulatedDeltaX -= Float(dx) + accumulatedDeltaY -= Float(dy) + } } } } From 2db80b0e4fa2394ece44345ec76219bdfc7e7e21 Mon Sep 17 00:00:00 2001 From: 2extndd Date: Wed, 27 May 2026 17:21:29 +0300 Subject: [PATCH 39/40] Clamp audio memcpy to buffer size to prevent post-stall heap overflow audio_cb copied frames*4 bytes into a fixed 4096-byte AudioQueue buffer with no bound. Normal ~10ms chunks stay well under the limit, but an oversized chunk delivered during recovery on a flaky link overran the allocation. Clamp the copy length to BUFFER_SIZE. Co-Authored-By: Claude Opus 4.7 --- OpenParsec/audio.c | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/OpenParsec/audio.c b/OpenParsec/audio.c index 0bbd0ba..c7936bc 100644 --- a/OpenParsec/audio.c +++ b/OpenParsec/audio.c @@ -279,8 +279,17 @@ void audio_cb(const int16_t *pcm, uint32_t frames, void *opaque) return; } - memcpy((*find_idle)->mAudioData, pcm, frames * 4); - (*find_idle)->mAudioDataByteSize = frames * 4; + // The idle buffer is a fixed BUFFER_SIZE (4096-byte) AudioQueue + // allocation, but `frames` is host-supplied and unbounded. Normal ~10ms + // chunks are well under 1024 stereo frames, so this clamp is a no-op on + // the happy path — but a post-stall recovery on a flaky link can deliver + // an oversized chunk, and `frames * 4 > BUFFER_SIZE` would memcpy past the + // buffer (heap overflow → crash/corruption). Clamp to what fits; playing a + // truncated chunk is strictly better than overrunning the allocation. + uint32_t bytes = frames * 4; + if (bytes > BUFFER_SIZE) bytes = BUFFER_SIZE; + memcpy((*find_idle)->mAudioData, pcm, bytes); + (*find_idle)->mAudioDataByteSize = bytes; if(!isStart) { From 857651e44674c39fd251068dde3913311d614eab Mon Sep 17 00:00:00 2001 From: 2extndd Date: Sat, 30 May 2026 05:47:57 +0300 Subject: [PATCH 40/40] =?UTF-8?q?Keyboard:=20reclaim=20FR=20on=20any=20key?= =?UTF-8?q?board=20hide=20+=20backtick=E2=86=92=E2=8C=98Space=20macro=20+?= =?UTF-8?q?=20readable=20-6023?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Language-layout sync silently died after the soft keyboard was dismissed via the toolbar Done button, a swipe-down, or any system-driven hide: only setKeyboardVisible(false) reclaimed first responder for the hidden LanguageSyncTextField, so currentInputModeDidChangeNotification stopped firing. Reclaim in keyboardWillHide (the universal dismiss funnel), deferred and guarded on !keyboardVisible so it never fights an in-progress show; the field's empty inputView means reclaiming never re-pops the keyboard. Also includes the manual language-switch macro: a bare backtick (`) fires ⌘Space at the host (opt-in, off by default). It rides remapKeyForHostIfNeeded, so with Windows Host Remap on it lands as Ctrl+Space — Cmd+Space on a Mac host, Ctrl+Space on a Windows host. Auto-repeat and the matching key-up are swallowed so holding the key doesn't spam input-source switches. And: decode -6023/-6024 ("Unable To Negotiate A Successful Connection") into a plain-language network/NAT hint instead of a bare error code; unknown codes still show the raw value. Co-Authored-By: Claude Opus 4.8 --- OpenParsec/ParsecView.swift | 19 ++++++++- OpenParsec/ParsecViewController.swift | 56 ++++++++++++++++++++++++++- OpenParsec/SettingsHandler.swift | 11 ++++++ OpenParsec/SettingsView.swift | 6 +++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/OpenParsec/ParsecView.swift b/OpenParsec/ParsecView.swift index 3faf7fb..40365a1 100644 --- a/OpenParsec/ParsecView.swift +++ b/OpenParsec/ParsecView.swift @@ -19,6 +19,23 @@ struct ParsecStatusBar : View { private let disconnectFailureThreshold = 5 let timer = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() + // Translate a raw ParsecStatus error into something a user can act on. The + // SDK returns bare integers; the worst case is a session that drops with an + // opaque "-6023" the user can't interpret. Known codes get a plain-language + // reason; the raw code is always appended so we can still triage anything new. + static func disconnectMessage(forCode code: Int) -> String { + switch code { + case -6023, -6024: + // Parsec: "Unable To Negotiate A Successful Connection" — a network/NAT + // problem, not an app bug. The iPad and host couldn't open a P2P tunnel. + // Only these two are documented; everything else shows the raw code so + // we never present an unverified reason as fact. + return "Disconnected: couldn't reach the host (network/NAT). Check Wi-Fi, firewall, and the host's UPnP / port-forwarding.\n(code \(code))" + default: + return "Disconnected (code \(code))" + } + } + init(isReconfiguring: Binding, showMenu: Binding, showDCAlert: Binding, DCAlertText: Binding, parsecViewController: ParsecViewController) { _isReconfiguring = isReconfiguring _showMenu = showMenu @@ -123,7 +140,7 @@ struct ParsecStatusBar : View { consecutiveFailures += 1 if consecutiveFailures >= disconnectFailureThreshold { wasDisconnected = true - DCAlertText = "Disconnected (code \(status.rawValue))" + DCAlertText = Self.disconnectMessage(forCode: Int(status.rawValue)) showDCAlert = true } return diff --git a/OpenParsec/ParsecViewController.swift b/OpenParsec/ParsecViewController.swift index 0565b4f..f0920ab 100644 --- a/OpenParsec/ParsecViewController.swift +++ b/OpenParsec/ParsecViewController.swift @@ -94,6 +94,12 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { private var chordArmed: Bool = false private var chordSawOtherKey: Bool = false + // Key-downs we deliberately swallowed (currently only the backtick→Cmd+Space + // remap). Their matching key-up must be swallowed too, otherwise the host + // receives a lone grave release and — worse — if the user holds the key, the + // auto-repeat key-downs we drop but a single trailing key-up could desync. + private var suppressedKeyUps: Set = [] + var keyboardAccessoriesView : UIView? var keyboardHeight : CGFloat = 0.0 var keyboardVisible : Bool = false @@ -609,6 +615,18 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { for press in presses { chordTrackPressBegan(press.key) + // Backtick→Cmd+Space remap: a bare ` becomes a manual language switch. + // Swallow the grave key-down and synthesize Cmd+Space at the host; + // record the suppression so the matching key-up is swallowed too. + if backtickWillEmulateCmdSpace(press.key), let usage = press.key?.keyCode { + // Auto-repeat delivers repeated pressesBegan with no intervening + // key-up; fire Cmd+Space only on the first down so holding ` does + // not spam input-source switches at the host. + if suppressedKeyUps.insert(usage).inserted { + fireCmdSpace() + } + continue + } // Always forward the raw scancode unchanged — the Cmd+Space emulation // is additive and fires only on a clean Ctrl+Shift release, so host // shortcuts like Ctrl+Shift+X keep working. @@ -620,6 +638,12 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { override func pressesEnded (_ presses: Set, with event: UIPressesEvent?) { for press in presses { + // If we swallowed this key's key-down (backtick→Cmd+Space), swallow the + // key-up too — the host already saw a complete Cmd+Space tap. + if let usage = press.key?.keyCode, suppressedKeyUps.remove(usage) != nil { + chordTrackPressEnded(press.key) + continue + } CParsec.sendKeyboardMessage(event:KeyBoardKeyEvent(input: press.key, isPressBegin: false) ) chordTrackPressEnded(press.key) } @@ -631,6 +655,10 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { // A cancelled press never delivers pressesEnded, so its key-up would be // lost — both for the host (stuck modifier) and for our chord bookkeeping. for press in presses { + if let usage = press.key?.keyCode, suppressedKeyUps.remove(usage) != nil { + chordTrackPressEnded(press.key) + continue + } CParsec.sendKeyboardMessage(event: KeyBoardKeyEvent(input: press.key, isPressBegin: false)) chordTrackPressEnded(press.key) } @@ -639,6 +667,16 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { chordSawOtherKey = false } + // True when the backtick→Cmd+Space remap should fire for this press: the + // feature is enabled, the key is the grave/backtick, and NO Cmd/Ctrl/Alt/ + // Shift is held (so Shift+` = tilde and Cmd+` still reach the host as normal + // keys). Caps Lock and other non-blocking flags are ignored. + private func backtickWillEmulateCmdSpace(_ key: UIKey?) -> Bool { + guard SettingsHandler.backtickEmulatesCmdSpace, let key = key else { return false } + guard key.keyCode == .keyboardGraveAccentAndTilde else { return false } + return key.modifierFlags.isDisjoint(with: [.command, .control, .alternate, .shift]) + } + // MARK: Ctrl+Shift → Cmd+Space chord machine (S05) private func isModifierUsage(_ usage: UIKeyboardHIDUsage) -> Bool { @@ -776,8 +814,24 @@ class ParsecViewController: UIViewController, UIScrollViewDelegate { scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: newOffsetY), animated: true) } onKeyboardVisibilityChanged?(false) + + // Reclaim first responder for the hidden language-sync field after ANY + // soft-keyboard dismissal. Previously only setKeyboardVisible(false) + // reclaimed it, so dismissing via the toolbar Done button, a swipe-down, + // the Globe key, or any system-driven hide left the hidden field resigned + // — at which point currentInputModeDidChangeNotification stops firing and + // keyboard-layout sync silently dies until the next setKeyboardVisible(false). + // keyboardWillHide is the common funnel for every dismiss path, so reclaim + // here. Deferred to the next runloop tick so we don't fight the in-progress + // hide; guarded on !keyboardVisible so a show that raced in keeps the VC as + // first responder. The field has an empty inputView, so this never re-shows + // the keyboard. + DispatchQueue.main.async { [weak self] in + guard let self = self, !self.keyboardVisible else { return } + self.languageSync?.reclaimFirstResponder() + } } - + } extension ParsecViewController : UIGestureRecognizerDelegate { diff --git a/OpenParsec/SettingsHandler.swift b/OpenParsec/SettingsHandler.swift index 380bd0a..9ac044b 100644 --- a/OpenParsec/SettingsHandler.swift +++ b/OpenParsec/SettingsHandler.swift @@ -70,6 +70,17 @@ struct SettingsHandler { // so this stays a deliberate manual opt-in. @AppStorage("ctrlShiftEmulatesCmdSpace") public static var ctrlShiftEmulatesCmdSpace: Bool = false + // When true, pressing a *bare* backtick/grave (`) — no Cmd/Ctrl/Alt/Shift — + // fires Cmd+Space at the host instead of sending the grave scancode. This is + // a manual language-switch macro: the physical Cmd+Space is swallowed by + // iPadOS (Spotlight) and never reaches the host, but a backtick is an + // ordinary key we can intercept and re-emit as host scancodes. Shift+` + // (tilde) and Cmd+` are untouched because any modifier disqualifies the + // remap. Off by default; turning it on means you can no longer type a literal + // backtick into the host while connected. Intended for Mac hosts whose + // "Select previous input source" / Spotlight is bound to ⌘Space. + @AppStorage("backtickEmulatesCmdSpace") public static var backtickEmulatesCmdSpace: Bool = false + @AppStorage("saveSessionSettings") public static var saveSessionSettings: Bool = true @AppStorage("savedZoomEnabled") public static var savedZoomEnabled: Bool = false @AppStorage("savedConstantFps") public static var savedConstantFps: Bool = false diff --git a/OpenParsec/SettingsView.swift b/OpenParsec/SettingsView.swift index 2fee8da..129d247 100644 --- a/OpenParsec/SettingsView.swift +++ b/OpenParsec/SettingsView.swift @@ -27,6 +27,7 @@ struct SettingsView:View @AppStorage("syncKeyboardLayout") var syncKeyboardLayout: Bool = true @AppStorage("layoutSyncHotkey") var layoutSyncHotkey: LayoutSyncHotkey = .ctrlSpace @AppStorage("ctrlShiftEmulatesCmdSpace") var ctrlShiftEmulatesCmdSpace: Bool = false + @AppStorage("backtickEmulatesCmdSpace") var backtickEmulatesCmdSpace: Bool = false @AppStorage("saveSessionSettings") var saveSessionSettings: Bool = true @State private var crashCopied: Bool = false @State private var diagCopied: Bool = false @@ -170,6 +171,11 @@ struct SettingsView:View Toggle("", isOn:$ctrlShiftEmulatesCmdSpace) .frame(width:80) } + CatItem("` key → ⌘Space (language switch)") + { + Toggle("", isOn:$backtickEmulatesCmdSpace) + .frame(width:80) + } CatItem("Capture System Shortcuts") { Toggle("", isOn:$captureSystemKeys)