Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8277e0d
Fix trackpad input lag and choppy 2-finger scroll on Magic Keyboard
extndd May 25, 2026
c6f9eb2
Add Mac↔iPad keyboard layout sync via host hotkey
extndd May 25, 2026
96ae81e
Trackpad polish: natural scroll, inertia, sensitivity + best-effort k…
extndd May 25, 2026
5d2bf2d
Fix keyboard toolbar regression + improve scroll inertia + Mac-first …
extndd May 26, 2026
5464629
Persist last-used display + actually-working in-session resolution ch…
extndd May 26, 2026
54a96d0
ci: retrigger workflow
extndd May 26, 2026
de86645
Apply audit P0/P1/P2/P3: fix reconnect race, cache keyCommands, gate …
extndd May 26, 2026
4ad1d42
Fix build: ParsecStatusBar custom init + add local cursor overlay
extndd May 26, 2026
0bc675d
Windows host key remap + 4 audit-found bugfixes
extndd May 26, 2026
2b57e24
v4 — real bugfixes from user testing
extndd May 26, 2026
af5261b
v4 follow-up: loosen language-sync gates + lower inertia threshold
extndd May 26, 2026
aca422b
Add Mac Ctrl+Shift hotkey + adjustable mouse acceleration
extndd May 26, 2026
fc8357f
Fix scroll inertia tail — stop threshold + decay range + touchesBegan…
extndd May 26, 2026
5d31035
Fix Resolution-menu crash, display persistence, display-switch debounce
extndd May 26, 2026
041c789
Fix build: actually add @AppStorage mouseAcceleration declarations
extndd May 26, 2026
e924473
GCMouse: move local cursor, fix wheel direction, fix x/y wheel swap
extndd May 26, 2026
fc6776c
Fix GCMouse off-main-thread crash + add crash reporter + CADisplayLin…
extndd May 26, 2026
bff3c9b
Add HANDOFF.md — comprehensive engineering handoff for review agent
extndd May 26, 2026
5f07b87
Make crash log retrievable on demand: non-destructive peek + Settings…
extndd May 26, 2026
f62d361
Fix black screen on return (S01) + concurrency/crash hardening (S02)
extndd May 26, 2026
d6b2202
Add host-OS detection plumbing + diagnostics channel (S04)
extndd May 26, 2026
d82eb30
Remove client-side scroll inertia; forward native deltas only (S03)
extndd May 26, 2026
656a212
Video-config send-gate + echo-as-confirmation (S06)
extndd May 26, 2026
1051343
Fix UserDefaults key collision + cancel force-unwrap crash (S07: Q1,Q2)
extndd May 26, 2026
9998919
Defer system edge gestures under pointer lock — fix trackpad dead-zon…
extndd May 27, 2026
432d152
Add Ctrl+Shift→Cmd+Space chord + pressesCancelled key-up (S05)
extndd May 27, 2026
78f3017
Add poor-network low-latency profile: bitrate cap on first echo (S08)
extndd May 27, 2026
e5c06f1
Guard network response/decode paths against crashes (S07 Q3)
extndd May 27, 2026
091697a
Use weak NSMapTable for VC-patch storage to stop reconnect leak (S07 Q4)
extndd May 27, 2026
babeb67
Harden monitor switch: suppress false disconnect alert + GL resume (S…
extndd May 27, 2026
4bf9939
Harden version string + fix typo (S07 Q5)
extndd May 27, 2026
437ce38
Fix build: EAGLContext.setCurrentContext renamed to setCurrent (Xcode…
extndd May 27, 2026
5249b85
Restore trackpad scroll inertia with frame-rate-independent glide (S03)
extndd May 27, 2026
d861222
Confirm-and-retry display switch so it lands on the first tap (S10/S06)
extndd May 27, 2026
eac24b7
Keep stream alive on brief backgrounding via keep-alive grace window
extndd May 27, 2026
a1c5316
Review fixes: poll-timer leak, chord partial-release, pendingOutput race
extndd May 27, 2026
5501c0c
Debounce disconnect: require ~1s of non-OK polls before alerting
extndd May 27, 2026
717e596
Send coalesced trackpad samples so fast motion tracks at sensor rate
extndd May 27, 2026
2db80b0
Clamp audio memcpy to buffer size to prevent post-stall heap overflow
extndd May 27, 2026
857651e
Keyboard: reclaim FR on any keyboard hide + backtick→⌘Space macro + r…
extndd May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 303 additions & 0 deletions HANDOFF.md

Large diffs are not rendered by default.

169 changes: 169 additions & 0 deletions OpenParsec/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,179 @@
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
// 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")
}

// ---- 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<CChar>? = nil // strdup'd log path
private static var headerPrefixC: UnsafeMutablePointer<CChar>? = nil // "=== OpenParsec fatal signal "
private static var headerSuffixC: UnsafeMutablePointer<CChar>? = nil // " ===\nBacktrace:\n"
private static let backtraceCapacity: Int32 = 128
private static var backtraceBuffer: UnsafeMutablePointer<UnsafeMutableRawPointer?>? = 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"
text += header + "\n\nBacktrace:\n"
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<UnsafeMutableRawPointer?>.allocate(capacity: Int(backtraceCapacity))

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.handleSignal(s)
}
}
}

// 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)
}
}

// 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
{
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()
// 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
// 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)")
}

// Override point for customization after application launch.
UTMViewControllerPatches.patchAll()
return true
Expand Down
49 changes: 47 additions & 2 deletions OpenParsec/CParsec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n>` 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
Expand All @@ -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()
Expand Down Expand Up @@ -180,28 +207,46 @@ 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)
}



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()
}

static func destroy()
{

}

static func connect(_ peerID: String) -> ParsecStatus
{
parsecImpl.connect(peerID)
lastConnectedPeerID = peerID
return parsecImpl.connect(peerID)
}

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()
}

Expand Down
35 changes: 30 additions & 5 deletions OpenParsec/GameController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,23 +91,48 @@ 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)
}
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
// 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))
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
// (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)
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions OpenParsec/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
<string></string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
20 changes: 13 additions & 7 deletions OpenParsec/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -244,6 +245,11 @@ struct LoginView:View
}
}
}
else
{
alertText = "Network error. Check your connection and try again."
showAlert = true
}
}
}
task.resume()
Expand Down
Loading