Files
enricobuehler 8740f48c92 feat(apple): the shared clipboard now works on iPhone and iPad
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

10 KiB

title, description
title description
Shared clipboard Copy on one machine and paste on the other — the two switches that have to be on, what actually crosses, and why the toggle does nothing when only one of them is flipped.

Punktfunk can share the clipboard between the machine you are sitting at and the host you are streaming. Copy a URL on your laptop, paste it into a browser on the host. Copy an error message on the host, paste it into a chat app on your laptop.

Two separate switches have to be on:

  1. The host operator has to allow it, with a line in host.env and a host restart. This one is off by default.
  2. You have to turn it on for that one host, in that host's edit sheet on your client. This one is off by default on the macOS, Windows and Linux clients — but on by default on Android.

Flipping one and not the other looks exactly like the feature not existing. So check both.

1. Allow it on the host

Add a PUNKTFUNK_CLIPBOARD line to the host's host.env~/.config/punktfunk/host.env on Linux, %ProgramData%\punktfunk\host.env on Windows.

PUNKTFUNK_CLIPBOARD=on

The accepted values:

Value Effect
unset, empty, 0, off, false Off (the default). The host never advertises the clipboard capability and never accepts a clipboard transfer.
text-only, no-files, text On for text, HTML, rich text and images. File transfer is refused.
on, 1 On, and file transfer is permitted by policy.

Values are trimmed and compared case-insensitively. Anything the host doesn't recognise is treated as on — a typo like PUNKTFUNK_CLIPBOARD=yes or no-file enables the permissive policy rather than failing, so check the spelling if you meant text-only.

The file is only read at startup, so restart the host. On Linux:

systemctl --user restart punktfunk-host

On Windows, from an Administrator prompt:

punktfunk-host service restart

See Configuration for the rest of host.env.

About the file mode. No client shipping today asks for file transfer, and no host clipboard backend offers file formats yet. on and text-only therefore behave the same in practice — text-only is how you make that explicit and keep it that way.

2. Turn it on for that host, in your client

The client switch is per saved host, not global: handing a machine your clipboard is a decision about that machine. You set it in the host's edit sheet, and it is deliberately not something a settings profile can carry.

Client Where the switch is Label Default
macOS Host card menu → Edit… Share clipboard with this host Off
iOS, iPadOS Host card menu → Edit… Share clipboard with this host Off
Windows Host tile menu → Edit… Share clipboard with this host Off
Linux (GTK) Host card menu → Edit… Share clipboard Off
Android (touch) Host card menu → Edit… Shared clipboard On

On Android the switch is only in the touch edit dialog. The controller/TV interface — what you get on Android TV, and on a phone when a controller is attached — has its own Edit Host screen with no clipboard row, so there is nowhere to change it there. It stays on, which is the Android default.

The setting is read when a session starts, so if you change it while streaming, reconnect.

macOS can also flip it mid-session: Stream ▸ Share Clipboard (⌃⌥⇧C), which becomes Stop Sharing Clipboard once the host has acknowledged it. On an iPad with a hardware keyboard the same combo works, though there is no menu bar to show it in — and only while the pointer is released, as a captured session sends the keys to the host instead.

tvOS and a Steam Deck in Gaming Mode have no clipboard switch — the Apple TV has no pasteboard to share at all, and neither the Decky panel nor the client's console home has a host edit sheet — see what each client does below.

Nothing crosses until something pastes

A copy costs nothing. When you copy, your machine announces only the list of formats it now holds — no bytes. The bytes are pulled across on a separate transfer, and only when an application on the other end actually pastes. Copying a large image and never pasting it transfers nothing.

That holds for everything you copy on your own machine, and for both directions on the host. It does not hold for a host copy arriving at a Windows or Android client: those two fetch the content straight away and put it on your local clipboard, whether or not you ever paste. On Windows that is because the lazy path needs Windows delayed rendering, which the client doesn't implement yet; on Android there is no way to satisfy a paste from the network at all. The macOS and iOS clients are lazy in both directions.

On iOS there is one deliberate exception. Backgrounding the app ends the session, and a promise nobody can answer is worse than no promise at all — so if the host copied something and you have not pasted it yet, those bytes are pulled across as the session ends, up to 8 MiB. That is what makes "copy on the host, switch to Safari, paste" work on an iPad. Nothing is fetched if you never leave the app, or if you already pasted.

A single transfer is capped at 64 MiB. Nothing else limits size, so a very large host-side copy can cross to a Windows or Android client for a paste that never happens.

What you copy on the macOS, iOS or Windows client is filtered for secrets: content marked org.nspasteboard.ConcealedType or org.nspasteboard.TransientType on the Apple clients, or ExcludeClipboardContentFromMonitorProcessing on Windows — what password managers set — is never announced and never served. That check exists only in those clients. The Android client has no equivalent, and neither does the host, so a password copied on the host is announced to your client like anything else.

The clipboard rides the native Punktfunk protocol's control channel, so it only exists in sessions from a Punktfunk client. A Moonlight client has no clipboard.

Which hosts and clients support it

Hosts. The host runs on Linux and Windows, and both have a clipboard backend — but on Linux it depends on the desktop session.

On Linux the host needs one of two mechanisms in the session it is streaming:

  • ext-data-control-v1 — KWin, wlroots/Sway and Hyprland. Tried first.
  • GNOME's own org.gnome.Mutter.RemoteDesktop.Session clipboard, used directly. Tried second.

The older zwlr-data-control-unstable-v1 is not implemented, so a compositor that offers only that has no backend. Neither does a gamescope session.

On Windows the host uses the Win32 clipboard, with delayed rendering so your content is only read when a host application pastes.

Clients, and what each one actually moves:

Client What crosses
macOS Plain text, rich text (RTF), HTML, and PNG, JPEG and GIF images
iOS, iPadOS Plain text, rich text (RTF), HTML, and PNG, JPEG and GIF images
Windows Plain text, and PNG images
Android, Android TV Plain text only
Linux (GTK), Steam Deck Nothing yet — see below
tvOS Not implemented — tvOS has no pasteboard

The Linux client has the switch but no working clipboard bridge: it enables the plane and then has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is pasted. Turning it on there is harmless but has no effect today. On a Steam Deck in Gaming Mode there is no switch at all — the Decky panel doesn't edit hosts — and since a Deck streams with that same Linux client, a switch there would have nothing to move anyway.

When you copy on the Windows client, images cross only if the copying application publishes the registered PNG clipboard format. Many Windows apps publish only a bitmap, and those copies aren't announced yet. The other direction is fine: an image copied on the host reaches the Windows client either way.

The host side is richer than any client: it can offer and accept text, HTML, RTF, PNG, JPEG and GIF. What you get is therefore whatever your client supports.

Why the toggle does nothing (or is greyed out)

On macOS, Stream ▸ Share Clipboard is greyed out whenever you are not streaming, or the connected host did not advertise a clipboard. On the other clients there is nothing to grey out — the per-host switch always looks available, and a host that can't do it simply does nothing. Work through these in order:

  • The host has it off. The default. Nothing was added to host.env, or the value is off, 0, false or empty. Fix it with step 1 above.
  • host.env was edited but the host wasn't restarted. The file is read once, at startup.
  • The switch is off for this host in your client. It is per saved host, and off by default everywhere except Android. Check the host's Edit… sheet — step 2 above.
  • The host's session has no supported backend. The host allows the clipboard, so it still advertises the capability, but it has nothing to read the desktop's clipboard with. This is a gamescope session, a compositor with only the old zwlr-data-control-unstable-v1, or a GNOME session whose Mutter doesn't expose the direct RemoteDesktop clipboard. Nothing on screen tells you this apart — the host log does.
  • The host is older than the feature. A host from before clipboard sync never advertises it.
  • Your client doesn't implement it — Linux, Steam Deck, iOS, iPadOS or tvOS. Nothing crosses regardless of what the host allows.
  • You changed the switch while connected. Reconnect, or use ⌃⌥⇧C on macOS.
  • The copy was a secret, or a format nobody handles. Concealed content is skipped on purpose on the macOS and Windows clients, and an image copied on Windows as a bare bitmap isn't announced.

Still stuck? The host log records what it decided on each session — a clipboard control line with the resolved state, and a clipboard backend unavailable line when the session had nothing to bind to. That is the fastest way to tell "off by policy" from "no backend" — see Troubleshooting.