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
1 Commits
Author SHA1 Message Date
enricobuehler 8740f48c92 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
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.
2026-08-15 15:56:44 +02:00