merge(core): reconcile the W7/W8 client refactor with origin's shared-clipboard feature
origin/main landed the shared clipboard (design/clipboard-and-file-transfer.md) while
this branch split quic/msgs.rs -> quic/{caps,control,...} and client.rs ->
client/{mod,control,worker,pump,planes,...} (W7) and deleted the two monoliths. The
feature had modified both deleted files, so its delta is re-applied onto the split
instead of resurrecting the monoliths:
- HOST_CAP_CLIPBOARD -> quic/caps.rs
- MSG_CLIP_* / CLIP_* consts, the six Clip*
structs, and their encode/decode impls -> quic/control.rs (beside the clock codecs)
- CtrlRequest::{ClipControl,ClipOffer} +
Negotiated.host_caps -> client/control.rs
- WorkerArgs.{clip_event_tx,clip_cmd_rx} -> client/worker.rs
- CLIP_EVENT_QUEUE -> client/planes.rs
- NativeClient clip fields, the 7 clip_* /
host_caps / next_clip methods, connect()
channel wiring -> client/mod.rs
- the control-task encode/decode arms and
the clipboard-task spawn -> client/pump.rs
Cargo.lock reconciled (adds pf-clipboard), punktfunk-host/Cargo.toml unions the W6
pf-* subsystem deps with pf-clipboard, and include/punktfunk_core.h is the cbindgen
union (clipboard + rumble C-ABI). punktfunk-core builds --all-features and its 174
lib tests pass, including quic::tests::clip_loopback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -224,6 +224,9 @@ struct ContentView: View {
|
||||
#if !os(tvOS)
|
||||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||||
isStreaming: model.connection != nil,
|
||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||||
clipboardOn: model.clipboardEnabled,
|
||||
toggleClipboard: { model.toggleClipboardSync() },
|
||||
disconnect: { model.disconnect() }))
|
||||
#endif
|
||||
#if os(macOS)
|
||||
|
||||
@@ -20,6 +20,12 @@ struct AddHostSheet: View {
|
||||
@State private var address: String
|
||||
@State private var port: Int
|
||||
@State private var mac: String
|
||||
#if os(macOS)
|
||||
/// Share the clipboard with this host (macOS sessions only; design
|
||||
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
|
||||
/// advertises the capability at connect.
|
||||
@State private var clipboardSync: Bool
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
private enum EditField: String, Identifiable {
|
||||
case name, address, port, mac
|
||||
@@ -41,6 +47,9 @@ struct AddHostSheet: View {
|
||||
_port = State(initialValue: Int(existing?.port ?? 9777))
|
||||
let stored = existing?.macAddresses ?? []
|
||||
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
|
||||
#if os(macOS)
|
||||
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -96,6 +105,9 @@ struct AddHostSheet: View {
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Toggle("Share clipboard with this host", isOn: $clipboardSync)
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
@@ -147,6 +159,11 @@ struct AddHostSheet: View {
|
||||
host.address = address.trimmingCharacters(in: .whitespaces)
|
||||
host.port = UInt16(clamping: port)
|
||||
host.macAddresses = Self.parseMacs(mac)
|
||||
#if os(macOS)
|
||||
// nil when off: the key stays absent from the saved JSON (forward-compat, and "never
|
||||
// opted in" and "opted out" read the same — off).
|
||||
host.clipboardSync = clipboardSync ? true : nil
|
||||
#endif
|
||||
onSave(host)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@@ -139,6 +139,18 @@ final class SessionModel: ObservableObject {
|
||||
private var audio: SessionAudio?
|
||||
private var gamepadCapture: GamepadCapture?
|
||||
private var gamepadFeedback: GamepadFeedback?
|
||||
#if os(macOS)
|
||||
/// The live session's clipboard bridge (design/clipboard-and-file-transfer.md §5) — created
|
||||
/// by `beginStreaming` when the per-host toggle is on and the host advertises
|
||||
/// `HOST_CAP_CLIPBOARD`; stopped (off-main, drain joined) in `disconnect`.
|
||||
private var clipboardSync: ClipboardSync?
|
||||
#endif
|
||||
/// Whether clipboard sync is live (host-acked `ClipState.enabled`) — drives the Stream menu
|
||||
/// item's title and the settings footnote. Always false off-macOS.
|
||||
@Published private(set) var clipboardEnabled = false
|
||||
/// The host's last `ClipState.reason` (`CLIP_REASON_*`) — why an enable was refused
|
||||
/// (backend unavailable / policy disabled / …); 0 = OK.
|
||||
@Published private(set) var clipboardReason: UInt8 = 0
|
||||
#if os(tvOS)
|
||||
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||
@@ -418,6 +430,12 @@ final class SessionModel: ObservableObject {
|
||||
#endif
|
||||
let feedback = gamepadFeedback
|
||||
gamepadFeedback = nil
|
||||
#if os(macOS)
|
||||
let clipboard = clipboardSync
|
||||
clipboardSync = nil
|
||||
#endif
|
||||
clipboardEnabled = false
|
||||
clipboardReason = 0
|
||||
if let conn = connection {
|
||||
// Drain-thread teardown waits the pullers out and close() waits out in-flight
|
||||
// polls + joins the Rust worker threads — keep all of it off the main actor,
|
||||
@@ -425,6 +443,9 @@ final class SessionModel: ObservableObject {
|
||||
Task.detached {
|
||||
audio?.stop()
|
||||
feedback?.stop()
|
||||
#if os(macOS)
|
||||
clipboard?.stop() // disables sync on the wire while the connection is still up
|
||||
#endif
|
||||
// Deliberate user quit → tell the host to skip the keep-alive linger (must precede close).
|
||||
if deliberate { conn.disconnectQuit() }
|
||||
conn.close()
|
||||
@@ -433,6 +454,9 @@ final class SessionModel: ObservableObject {
|
||||
Task.detached {
|
||||
audio?.stop()
|
||||
feedback?.stop()
|
||||
#if os(macOS)
|
||||
clipboard?.stop()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
connection = nil
|
||||
@@ -507,6 +531,14 @@ final class SessionModel: ObservableObject {
|
||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||
feedback.start()
|
||||
gamepadFeedback = feedback
|
||||
#if os(macOS)
|
||||
// Shared clipboard: opt-in per host AND host-advertised (older hosts / operator-disabled
|
||||
// hosts never see a ClipControl). Same trust gate as audio — nothing is announced
|
||||
// during the trust prompt.
|
||||
if activeHost?.clipboardSync == true, conn.hostSupportsClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
let pointer = SiriRemotePointer(connection: conn)
|
||||
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||
@@ -515,6 +547,40 @@ final class SessionModel: ObservableObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Create + start the session's clipboard bridge and route its host acks into the published
|
||||
/// UI state. `ClipboardSync.start()` sends the enable; the host's `.state` answer flips
|
||||
/// `clipboardEnabled` (or leaves it false with a `clipboardReason` the UI can explain).
|
||||
private func startClipboardSync(_ conn: PunktfunkConnection) {
|
||||
let sync = ClipboardSync(connection: conn)
|
||||
sync.onState = { [weak self] enabled, _, reason in
|
||||
Task { @MainActor in
|
||||
self?.clipboardEnabled = enabled
|
||||
self?.clipboardReason = reason
|
||||
}
|
||||
}
|
||||
sync.start()
|
||||
clipboardSync = sync
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Flip clipboard sync mid-session (the Stream menu). Off → on requires the host cap; on →
|
||||
/// off tears the bridge down (off-main — the drain join must not block the main actor) and
|
||||
/// tells the host, which drops any selection we own there. No-op off-macOS or while idle.
|
||||
func toggleClipboardSync() {
|
||||
#if os(macOS)
|
||||
guard let conn = connection, phase == .streaming else { return }
|
||||
if let sync = clipboardSync {
|
||||
clipboardSync = nil
|
||||
clipboardEnabled = false
|
||||
clipboardReason = 0
|
||||
Task.detached { sync.stop() }
|
||||
} else if conn.hostSupportsClipboard {
|
||||
startClipboardSync(conn)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startStatsTimer() {
|
||||
lastFramesDropped = 0 // a fresh connection's cumulative drop counter starts at 0
|
||||
latencySplit.reset() // no stale receipts/samples from a previous session
|
||||
|
||||
@@ -21,6 +21,12 @@ import SwiftUI
|
||||
/// `.focusedSceneValue` so the Scene-level commands can drive it.
|
||||
struct SessionFocus {
|
||||
var isStreaming: Bool
|
||||
/// The connected host advertises `HOST_CAP_CLIPBOARD` (gates the Share Clipboard item —
|
||||
/// macOS-only UI, but the fact is platform-neutral).
|
||||
var clipboardAvailable: Bool
|
||||
/// Clipboard sync is live (host-acked) — drives the item's Stop/Share title.
|
||||
var clipboardOn: Bool
|
||||
var toggleClipboard: () -> Void
|
||||
var disconnect: () -> Void
|
||||
}
|
||||
|
||||
@@ -58,6 +64,15 @@ struct StreamCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true)
|
||||
#if os(macOS)
|
||||
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
|
||||
// when the host doesn't advertise the cap (older host / operator policy off).
|
||||
Button(session?.clipboardOn == true ? "Stop Sharing Clipboard" : "Share Clipboard") {
|
||||
session?.toggleClipboard()
|
||||
}
|
||||
.keyboardShortcut("c", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true || session?.clipboardAvailable != true)
|
||||
#endif
|
||||
Divider()
|
||||
Button("Disconnect") { session?.disconnect() }
|
||||
.keyboardShortcut("d", modifiers: [.control, .option, .shift])
|
||||
|
||||
Reference in New Issue
Block a user