The shared clipboard now works on iPhone and iPad #250

Merged
enricobuehler merged 1 commits from worktree-worktree-ios-clipboard into main 2026-08-15 16:15:15 +00:00
Owner

The clipboard bridge was written against NSPasteboard and gated #if os(macOS), so the iOS half of the same universal app had a per-host toggle it could not show and a wire plane it never opened.

Nothing below the pasteboard was ever platform-specific. abi.rs's clipboard exports carry only #[cfg(feature = "quic")] — no OS gate — so those symbols already shipped in the ios/tvos slices of the framework, and PunktfunkConnection.swift contains no #if at all. The port is ClipboardSync.swift plus ~13 guards in the app layer.

Shape

Rather than keep a second copy of the subtle half — one drain thread, offer sequence numbers, the pending fetches a blocked paste waits on, echo suppression — that logic moves behind a ClipboardPasteboard seam and stays shared:

file role
Clipboard/ClipboardFormats.swift wire ↔ uniform-type table, announce decision, PNG floor
Clipboard/ClipboardPasteboard.swift the protocol, plus the cross-thread result box
Clipboard/ClipboardPasteboard+AppKit.swift NSPasteboard.general
Clipboard/ClipboardPasteboard+UIKit.swift UIPasteboard.general

The table is stated once because both frameworks name these types with the same strings — NSPasteboard.PasteboardType.string is "public.utf8-plain-text". What genuinely differs is small: AppKit fulfils a paste by blocking a provider thread, UIKit by answering an NSItemProvider load handler; AppKit transcodes images through NSImage, UIKit through UIImage. macOS behaviour is unchanged — same poll interval, same timeouts, same lock discipline.

Two things iOS needs that the Mac does not

Backgrounding ends the session. ContentView's scenePhase driver disconnects on .background unless the opt-in keep-alive is on, so a lazy promise on the pasteboard outlives anything able to answer it — "copy on the host, switch to Safari, paste" would hand Safari nothing. An unpasted host offer is therefore pulled across inside ClipboardSync.stop(), while the drain thread and connection are both still up, bounded to 8 MiB and 3 s and skipped entirely if it was already pasted. Everything else stays lazy: copy on the host, stay in the app, paste nothing, and no clipboard bytes move.

Doing this from willResignActive or didEnterBackground was the obvious first idea and is wrong — Control-Center peeks fire the first, and the second races the very teardown it needs to precede.

Reading the pasteboard is a privacy event. Since iOS 14 reading contents is something the user sees, and since iOS 16 it can ask first; reading the change count and type list is not. That maps onto the lazy design exactly, so the announce poll stays silent however long a session runs, and the one real read happens when someone on the host pastes. That read moved off the drain thread, because on iOS 16+ it can sit waiting for the user's answer and the drain thread is what would deliver the host's cancel.

Fixed in passing

Pasteboard ownership was recorded from a write's resulting change count without checking the write had landed. A dropped write would have made the sync read the user's own next copy as its own echo and never announce it again.

Scope

tvOS has no pasteboard, so the guards became #if !os(tvOS) rather than widening to every platform. Files still ride Phase 2, on every client.

Verification

  • macOS swift build + 319 tests green, 15 of them new (ClipboardFormatsTests — the announce decision, the PNG floor, concealed/transient handling, and the cross-thread fetch box).
  • iOS and tvOS typechecks via the --triple recipe.
  • xcodebuild -scheme Punktfunk-iOS -destination 'generic/platform=iOS'BUILD SUCCEEDED.

⚠️ The locally-built PunktfunkCore.xcframework was ABI v17 against a repo header at v22; it needs a 5-slice rebuild before any Apple build in a fresh worktree gets as far as this diff.

No on-glass test yet. The specific thing worth watching on a real iPad is the iOS 16 paste-permission alert — it should appear once per host copy, when the host pastes, but that is reasoning rather than observation.

In-repo docs updated (clipboard.md, support-matrix.md, clients.md, roadmap.md all said iOS was not implemented). punktfunk-planning/design/clipboard-and-file-transfer.md §5 is still titled "macOS client implementation" — left alone, as it lives in a sibling repo.

The clipboard bridge was written against NSPasteboard and gated `#if os(macOS)`, so the iOS half of the same universal app had a per-host toggle it could not show and a wire plane it never opened. Nothing below the pasteboard was ever platform-specific. `abi.rs`'s clipboard exports carry only `#[cfg(feature = "quic")]` — no OS gate — so those symbols already shipped in the ios/tvos slices of the framework, and `PunktfunkConnection.swift` contains no `#if` at all. The port is `ClipboardSync.swift` plus ~13 guards in the app layer. ## Shape Rather than keep a second copy of the subtle half — one drain thread, offer sequence numbers, the pending fetches a blocked paste waits on, echo suppression — that logic moves behind a `ClipboardPasteboard` seam and stays shared: | file | role | |---|---| | `Clipboard/ClipboardFormats.swift` | wire ↔ uniform-type table, announce decision, PNG floor | | `Clipboard/ClipboardPasteboard.swift` | the protocol, plus the cross-thread result box | | `Clipboard/ClipboardPasteboard+AppKit.swift` | `NSPasteboard.general` | | `Clipboard/ClipboardPasteboard+UIKit.swift` | `UIPasteboard.general` | The table is stated once because both frameworks name these types with the same strings — `NSPasteboard.PasteboardType.string` *is* `"public.utf8-plain-text"`. What genuinely differs is small: AppKit fulfils a paste by **blocking** a provider thread, UIKit by answering an `NSItemProvider` load handler; AppKit transcodes images through NSImage, UIKit through UIImage. macOS behaviour is unchanged — same poll interval, same timeouts, same lock discipline. ## Two things iOS needs that the Mac does not **Backgrounding ends the session.** `ContentView`'s scenePhase driver disconnects on `.background` unless the opt-in keep-alive is on, so a lazy promise on the pasteboard outlives anything able to answer it — "copy on the host, switch to Safari, paste" would hand Safari nothing. An unpasted host offer is therefore pulled across inside `ClipboardSync.stop()`, while the drain thread and connection are both still up, bounded to 8 MiB and 3 s and skipped entirely if it was already pasted. Everything else stays lazy: copy on the host, stay in the app, paste nothing, and no clipboard bytes move. Doing this from `willResignActive` or `didEnterBackground` was the obvious first idea and is wrong — Control-Center peeks fire the first, and the second races the very teardown it needs to precede. **Reading the pasteboard is a privacy event.** Since iOS 14 reading *contents* is something the user sees, and since iOS 16 it can ask first; reading the change count and type list is not. That maps onto the lazy design exactly, so the announce poll stays silent however long a session runs, and the one real read happens when someone on the host pastes. That read moved off the drain thread, because on iOS 16+ it can sit waiting for the user's answer and the drain thread is what would deliver the host's cancel. ## Fixed in passing Pasteboard ownership was recorded from a write's resulting change count without checking the write had landed. A dropped write would have made the sync read the user's own next copy as its own echo and never announce it again. ## Scope tvOS has no pasteboard, so the guards became `#if !os(tvOS)` rather than widening to every platform. Files still ride Phase 2, on every client. ## Verification - macOS `swift build` + **319 tests** green, 15 of them new (`ClipboardFormatsTests` — the announce decision, the PNG floor, concealed/transient handling, and the cross-thread fetch box). - iOS and tvOS typechecks via the `--triple` recipe. - `xcodebuild -scheme Punktfunk-iOS -destination 'generic/platform=iOS'` → **BUILD SUCCEEDED**. ⚠️ The locally-built `PunktfunkCore.xcframework` was **ABI v17 against a repo header at v22**; it needs a 5-slice rebuild before any Apple build in a fresh worktree gets as far as this diff. **No on-glass test yet.** The specific thing worth watching on a real iPad is the iOS 16 paste-permission alert — it should appear once per host copy, when the host pastes, but that is reasoning rather than observation. In-repo docs updated (`clipboard.md`, `support-matrix.md`, `clients.md`, `roadmap.md` all said iOS was not implemented). `punktfunk-planning/design/clipboard-and-file-transfer.md` §5 is still titled "macOS client implementation" — left alone, as it lives in a sibling repo.
enricobuehler added 1 commit 2026-08-15 14:12:43 +00:00
feat(apple): the shared clipboard now works on iPhone and iPad
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 2m2s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 4m29s
ci / rust-arm64 (pull_request) Successful in 5m55s
ci / rust (pull_request) Failing after 14m20s
8740f48c92
The clipboard bridge was written against NSPasteboard and gated `#if os(macOS)`,
so the iOS half of the same universal app had a per-host toggle it could not
show and a wire plane it never opened — even though the core's clipboard ABI is
in every slice of the framework and nothing below the pasteboard was
platform-specific.

Rather than keep a second copy of the subtle half (one drain thread, offer
sequence numbers, the pending fetches a blocked paste waits on, echo
suppression), that logic moves into a `ClipboardPasteboard` seam and stays
shared. What genuinely differs is small and lives in two adapters: AppKit
fulfils a paste by blocking a provider thread, UIKit by answering an
NSItemProvider load handler; AppKit transcodes images through NSImage, UIKit
through UIImage. macOS behaviour is unchanged — same poll interval, same
timeouts, same lock discipline.

Two things the iOS side needs that the Mac does not.

Backgrounding the app ends the session, so a lazy promise on the pasteboard
would outlive anything able to answer it and "copy on the host, switch to
Safari, paste" would hand Safari nothing. So a host offer still unpasted when
the sync tears down is pulled across then — bounded to 8 MiB and 3 seconds,
skipped entirely if it was already pasted. Everything else stays lazy: copy on
the host, stay in the app, paste nothing, and no clipboard bytes move.

And since iOS 14 reading pasteboard *contents* is a privacy event the user
sees, while reading its change count and type list is not. That maps onto the
lazy design exactly, so the announce poll stays silent however long a session
runs; the one real read happens when someone on the host pastes. It now runs
off the drain thread, because on iOS 16+ that read can sit waiting for the
user's answer, and the drain thread is what would deliver the host's cancel.

One bug fixed while moving the code: ownership of the pasteboard was recorded
from a write's resulting change count without checking the write had landed. A
dropped write would have made the sync read the user's own next copy as its own
echo and never announce it again.

tvOS has no pasteboard, so the guards became `#if !os(tvOS)` rather than
widening to every platform.

Verified: macOS `swift build` + 319 tests green (15 new), iOS and tvOS
typechecks via the `--triple` recipe, and `xcodebuild` of the shipping
Punktfunk-iOS scheme.
enricobuehler merged commit d9d877f985 into main 2026-08-15 16:15:15 +00:00
enricobuehler deleted branch worktree-worktree-ios-clipboard 2026-08-15 16:15:20 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#250