e1af4d57c6
ci / rust (push) Has been cancelled
The whole client now runs on iPadOS/iOS from the same sources, first-lit live in the
iPad simulator against the real host at 1280x720@60 (60 fps on the HUD, capture state
machine active, mic permission flow shown).
- PunktfunkCore.xcframework grows iOS device + universal-simulator slices
(BUILD_IOS=1; rustup targets aarch64-apple-ios{,-sim} + x86_64-apple-ios).
- The decode pump is extracted into a shared StreamPump (identical IDR re-gate logic on
both platforms); the iOS StreamView (StreamViewIOS.swift) has the same name/signature
as the macOS one, so ContentView & co. are byte-identical across platforms — hosted
in a UIViewController for prefersPointerLocked (the iPadOS cursor capture; see README
note 9 for the UIHostingController forwarding caveat).
- Touch is always forwarded: per-finger wire ids, coordinates mapped through the
aspect-fit letterbox into LIVE host-mode pixels (surface == host mode, identity
rescale host-side; follows mid-stream requestMode switches).
- InputCapture is cross-platform: GC works the same on iPadOS, ⌘⎋ is detected from the
HID stream there; stale-⌘ tracking after focus loss fixed on both platforms
(releaseAll now drops the modifier/latch state — a ⌘ released in another app
otherwise hijacked Esc forever).
- SessionAudio: AVAudioSession on iOS (.playAndRecord + .defaultToSpeaker — without it
iPhones route host audio to the EARPIECE; deactivated with
notifyOthersOnDeactivation on stop so interrupted background audio resumes); HAL
device pinning + the Settings pickers stay macOS-only.
- New Punktfunk-iOS app target (shared synchronized sources, generated Info.plist with
mic + local-network usage descriptions — QUIC to a LAN host trips local network
privacy on real devices — scene manifest + indirect input events for Stage Manager /
external displays), shared scheme, macOS min-window frames gated off iOS.
For the iPad-on-an-external-screen idea: with multiple scenes + indirect input enabled,
Stage Manager iPads can drag the punktfunk window onto the external display and drive
the PC with keyboard/mouse/touch. Known gaps (README note 9): the pointer-lock
preference isn't consulted through UIHostingController (relative mouse works, the local
cursor just stays visible) and AVAudioSession interruptions don't auto-restart audio.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
133 lines
5.6 KiB
Swift
133 lines
5.6 KiB
Swift
// PIN pairing sheet. The host, started with --allow-pairing (or --require-pairing),
|
|
// prints a short PIN at startup ("PAIRING ARMED — enter this PIN on the client to
|
|
// pair"); the user types it here. The ceremony is SPAKE2, so a wrong PIN buys an
|
|
// attacker exactly one online guess — for the user a typo just means "try again" (the
|
|
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
|
|
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
|
|
// client's identity in return.
|
|
|
|
import Foundation
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call
|
|
/// can't be interrupted, so its completion checks this flag and self-discards — a late
|
|
/// success must NOT pin and auto-connect to a host the user cancelled out of. Only
|
|
/// touched on the main actor.
|
|
private final class CeremonyToken: @unchecked Sendable {
|
|
var cancelled = false
|
|
}
|
|
|
|
struct PairSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let host: StoredHost
|
|
/// Called with the verified host fingerprint after a successful ceremony.
|
|
let onPaired: (Data) -> Void
|
|
|
|
@State private var pin = ""
|
|
#if os(macOS)
|
|
@State private var clientName = Host.current().localizedName ?? "Mac"
|
|
#else
|
|
@State private var clientName = UIDevice.current.name
|
|
#endif
|
|
@State private var busy = false
|
|
@State private var errorText: String?
|
|
@State private var token = CeremonyToken()
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
Form {
|
|
Section {
|
|
TextField("PIN", text: $pin, prompt: Text("Shown in the host's log"))
|
|
.font(.system(.body, design: .monospaced))
|
|
TextField(
|
|
"Client name", text: $clientName,
|
|
prompt: Text("How the host lists this Mac"))
|
|
} header: {
|
|
Text("Pair with \(host.displayName)")
|
|
} footer: {
|
|
Text("The host prints the PIN when pairing is armed "
|
|
+ "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). "
|
|
+ "Pairing verifies both sides at once — no fingerprint "
|
|
+ "comparison needed.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let errorText {
|
|
Section {
|
|
Text(errorText)
|
|
.font(.callout)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
HStack {
|
|
Button("Cancel", role: .cancel) {
|
|
token.cancelled = true
|
|
dismiss()
|
|
}
|
|
.keyboardShortcut(.cancelAction)
|
|
Spacer()
|
|
if busy {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.padding(.trailing, 8)
|
|
}
|
|
Button("Pair & Connect") { runCeremony() }
|
|
.buttonStyle(.borderedProminent)
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
.padding(16)
|
|
}
|
|
#if os(macOS)
|
|
.frame(width: 400)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
#endif
|
|
.interactiveDismissDisabled(busy)
|
|
.onDisappear { token.cancelled = true } // any other dismissal path
|
|
}
|
|
|
|
private func runCeremony() {
|
|
busy = true
|
|
errorText = nil
|
|
let pin = pin.trimmingCharacters(in: .whitespaces)
|
|
let name = clientName.trimmingCharacters(in: .whitespaces)
|
|
let address = host.address
|
|
let port = host.port
|
|
let token = token
|
|
Task.detached(priority: .userInitiated) {
|
|
// Identity load + the ceremony both block — keep them off the main actor.
|
|
// loadForPairing is the strict variant: the host durably trusts this
|
|
// identity, so it must have made it into the Keychain.
|
|
let result = Result {
|
|
let identity = try ClientIdentityStore.shared.loadForPairing()
|
|
return try PunktfunkKit.pair(
|
|
host: address, port: port, identity: identity,
|
|
pin: pin, name: name.isEmpty ? "Mac" : name)
|
|
}
|
|
await MainActor.run {
|
|
guard !token.cancelled else { return } // sheet dismissed mid-ceremony
|
|
busy = false
|
|
switch result {
|
|
case .success(let fingerprint):
|
|
onPaired(fingerprint)
|
|
dismiss()
|
|
case .failure(PunktfunkClientError.wrongPIN):
|
|
errorText = "Wrong PIN — check the host's \u{201C}PAIRING ARMED\u{201D} "
|
|
+ "line and try again."
|
|
case .failure(is ClientIdentityStore.IdentityError):
|
|
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
|
+ "pairing would not survive a relaunch. Unlock the login "
|
|
+ "keychain and try again."
|
|
case .failure:
|
|
errorText = "Pairing failed. Is the host reachable, armed with "
|
|
+ "--allow-pairing, and not mid-session? Retries are rate-limited "
|
|
+ "to one per 2 seconds."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|