|
| 1 | +# Design: Custom clipboard formats |
| 2 | + |
| 3 | +| | | |
| 4 | +|---|---| |
| 5 | +| **Status** | Proposed | |
| 6 | +| **Issue** | [#17](https://github.com/golang-design/clipboard/issues/17) | |
| 7 | +| **Supersedes** | POC in [#43](https://github.com/golang-design/clipboard/pull/43) | |
| 8 | +| **Also closes** | [#40](https://github.com/golang-design/clipboard/issues/40) (raw read/write) | |
| 9 | + |
| 10 | +## 1. Current state |
| 11 | + |
| 12 | +**Platforms:** macOS (Cgo), Linux/X11 (Cgo), Windows (pure syscall), iOS + Android |
| 13 | +(gomobile), and FreeBSD/OpenBSD/NetBSD (added in `aa2c0d4`). |
| 14 | + |
| 15 | +**Formats:** only `FmtText` (UTF-8) and `FmtImage` (PNG). `FmtImage` performs |
| 16 | +*conversion* under the hood — DIB↔PNG on Windows, TIFF→PNG on macOS. |
| 17 | + |
| 18 | +**API:** `Init`, `Read(Format) []byte`, `Write(Format, []byte) <-chan struct{}`, |
| 19 | +`Watch(ctx, Format) <-chan []byte`, with `type Format int` and consts |
| 20 | +`FmtText = 0`, `FmtImage = 1`. |
| 21 | + |
| 22 | +## 2. Why the #43 POC stalled (idea check) |
| 23 | + |
| 24 | +The POC validated the *idea* but its shape isn't shippable: |
| 25 | + |
| 26 | +1. Redefines `Format` from `int` → `interface{}`, breaking type safety and the |
| 27 | + `iota` consts. |
| 28 | +2. A custom format is an `unsafe.Pointer(C.NSPasteboardTypePDF)` — this **forces |
| 29 | + user code to import Cgo + Cocoa**, which defeats the cross-platform purpose and |
| 30 | + collides directly with the purego migration in #83. |
| 31 | +3. Only darwin is implemented; Linux / Windows / mobile are untouched. |
| 32 | +4. `Handler` carries only `Format() interface{}`; the read/write transforms are an |
| 33 | + unfinished `// TODO: generics`. |
| 34 | + |
| 35 | +The POC has served its purpose and should be closed in favour of this design. |
| 36 | + |
| 37 | +## 3. Design principle |
| 38 | + |
| 39 | +> A custom format is a **portable MIME-type string**. The package maps it to each |
| 40 | +> platform's native type behind an opaque `Format` token. No platform/Cgo detail |
| 41 | +> ever leaks into user code. |
| 42 | +
|
| 43 | +MIME is not an arbitrary choice — it is the *native data model* of the platforms we |
| 44 | +still want to add (see §6): Wayland advertises `mime_type` strings on |
| 45 | +`wl_data_offer`; the Web Clipboard API keys `ClipboardItem` by MIME type. X11 |
| 46 | +targets are already MIME-shaped (`image/png`), and macOS `UTType` bridges to MIME. |
| 47 | +Choosing MIME as the identity makes the abstraction *forward-compatible* with those |
| 48 | +backends instead of fighting them. |
| 49 | + |
| 50 | +## 4. Public API |
| 51 | + |
| 52 | +```go |
| 53 | +// type Format int stays exactly as-is. FmtText = 0, FmtImage = 1 are unchanged. |
| 54 | + |
| 55 | +// Register maps a MIME type to a Format token usable with Read/Write/Watch. |
| 56 | +// Idempotent: Register("text/html") always returns the same token. |
| 57 | +// Safe to call before Init and concurrently. |
| 58 | +func Register(mime string) Format |
| 59 | + |
| 60 | +// ReadAs decodes a custom format into a typed value. This relocates the |
| 61 | +// generic idea from the original issue sketch to where generics actually |
| 62 | +// compose — a free helper — instead of a heterogeneous registry that would |
| 63 | +// have to box every func([]byte)(T,error) back to `any`. |
| 64 | +func ReadAs[T any](f Format, decode func([]byte) (T, error)) (T, error) |
| 65 | +``` |
| 66 | + |
| 67 | +That's the entire new surface: one function plus one generic helper. `Read` |
| 68 | +and `Write` are unchanged and accept the new tokens directly. `Watch` is |
| 69 | +already variadic — `Watch(ctx, ...Format) <-chan Data` (shipped in #124 for |
| 70 | +#89) — so it accepts registered tokens too and tags every value with the |
| 71 | +`Format` it was detected in. Watching a custom format therefore needs **no |
| 72 | +new API**: `Watch(ctx, Register("text/html"))` works as soon as `resolve` |
| 73 | +(§5) maps the token. `Watch(ctx)` with no formats watches the built-ins |
| 74 | +(`FmtText`, `FmtImage`) only; registered formats are watched by passing |
| 75 | +their tokens explicitly, so the no-args default stays stable and cheap as |
| 76 | +the registry grows (rather than implicitly spinning up a watcher per |
| 77 | +ever-registered MIME type). |
| 78 | + |
| 79 | +### Why not the original `Register[T any](fmt, read, write)` sketch? |
| 80 | + |
| 81 | +A package-level generic registry can't store `func([]byte)(T, error)` for varying |
| 82 | +`T` without erasing it to `any` — so the generics buy nothing at the registry |
| 83 | +level. And `Read(f) []byte` already returns bytes; a *typed* decode can't be looked |
| 84 | +up by format at runtime, so it belongs in caller code. `ReadAs[T]` keeps the typed |
| 85 | +ergonomics exactly where they're sound. |
| 86 | + |
| 87 | +### Semantics |
| 88 | + |
| 89 | +- **Raw byte passthrough.** Custom formats do **no** conversion (unlike `FmtImage`'s |
| 90 | + DIB/PNG/TIFF handling). Bytes in = bytes out. This is precisely what #40 asks |
| 91 | + for, so #40 is subsumed: `Register("application/octet-stream")` (or any native |
| 92 | + type) gives raw access, and it's also the escape hatch around the #48 Windows |
| 93 | + image-conversion bug. |
| 94 | +- **Idempotent registration**, guarded by an `RWMutex`-protected registry. |
| 95 | +- **nocgo build:** `Register` returns a token and never panics; `Read`/`Write` |
| 96 | + degrade exactly like the existing nocgo path (nil / closed channel). |
| 97 | + |
| 98 | +## 5. Per-platform resolution (the load-bearing part) |
| 99 | + |
| 100 | +A Go-level indirection — `resolve(Format) (nativeType, error)` — keeps all platform |
| 101 | +specifics out of the public API and out of user code. |
| 102 | + |
| 103 | +| Platform | Native type | MIME → native | |
| 104 | +|---|---|---| |
| 105 | +| Linux/X11 | target atom (`XInternAtom`) | MIME ≈ atom directly (`text/html`, `application/pdf`) | |
| 106 | +| macOS | `NSPasteboardType` (NSString) | any string round-trips with itself; cross-app interop via best-effort MIME→UTI alias table (`text/html`→`public.html`, `application/pdf`→`com.adobe.pdf`) | |
| 107 | +| Windows | `CF_*` or `RegisterClipboardFormat` | predefined `CF_*` for known MIME, else register the MIME string as a named format | |
| 108 | + |
| 109 | +**Honest scope:** *self round-trip* (this library writing and reading its own data) |
| 110 | +works with any string on every desktop platform. *Cross-application interop* is |
| 111 | +best-effort and depends on the alias tables matching what the other app expects. We |
| 112 | +should not overclaim portability here. |
| 113 | + |
| 114 | +Because `resolve` is pure Go, it adds **no new Cgo surface** and stays compatible |
| 115 | +with the purego migration (#83) — the exact opposite of the #43 POC. |
| 116 | + |
| 117 | +## 6. Is the abstraction wide enough? (all open platform/feature issues) |
| 118 | + |
| 119 | +| Issue | Bucket | How the design relates | |
| 120 | +|---|---|---| |
| 121 | +| #40 raw r/w | **Subsumed** | Raw passthrough *is* the custom-format path. | |
| 122 | +| #6 Wayland | **Fits natively** | Wayland's data model is MIME strings → the registry maps 1:1. New backend, same API. | |
| 123 | +| #64 web (wasm) | **Fits natively** | `ClipboardItem` is MIME-keyed → same. (Caveats below.) | |
| 124 | +| #67 Linux 2nd selection | **Orthogonal axis** | PRIMARY vs CLIPBOARD is a *selection* axis, not a data-type axis. Must NOT be folded into `Format`. | |
| 125 | +| #89 watch-all + type | **Partly shipped** | Tagged multi-format watch shipped in #124: `Watch(ctx, ...Format) <-chan Data` already accepts registered tokens. Remaining: *enumeration* (“what's on the clipboard?”), reserved as `Formats() []Format`. | |
| 126 | +| #48 Windows img bug | **Escape hatch** | Raw passthrough lets users bypass the lossy DIB/PNG conversion. | |
| 127 | +| #25/#69/#83 remove Cgo | **Unaffected** | `resolve` is pure Go; compatible with purego. | |
| 128 | +| #22 throttle reads | **Unaffected** | X11 serving behavior; independent. | |
| 129 | + |
| 130 | +### Axes the design deliberately keeps separate |
| 131 | + |
| 132 | +To stay wide without becoming a combinatorial mess, `Format` is **only** the |
| 133 | +data-type axis. Three independent axes are reserved for future work and must not be |
| 134 | +overloaded onto `Format`: |
| 135 | + |
| 136 | +1. **Selection** (#67): CLIPBOARD vs PRIMARY (X11), general vs find pasteboard |
| 137 | + (macOS). Future: a functional-option parameter on Read/Write/Watch, e.g. |
| 138 | + `clipboard.Read(f, clipboard.Primary())` — never a new `Format`. |
| 139 | +2. **Enumeration** (#89): discover available formats. The *typed/tagged watch* |
| 140 | + half of #89 already shipped (`Watch(ctx, ...Format) <-chan Data`, #124) and |
| 141 | + accepts registered tokens directly, so the registry needs no watch-API |
| 142 | + changes. What remains reserved is enumeration — `func Formats() []Format` |
| 143 | + (“what's on the clipboard right now?”). The MIME registry is what makes this |
| 144 | + expressible. |
| 145 | +3. **Capability / async** (#64 web, Wayland permissions): the web clipboard is async |
| 146 | + and permission-gated, and today's `Read() []byte` swallows errors as `nil`. |
| 147 | + `ReadAs[T]` (which returns `error`) is the seam where an error-returning / |
| 148 | + capability-aware path can grow without breaking the byte-based core. |
| 149 | + |
| 150 | +## 7. Example |
| 151 | + |
| 152 | +```go |
| 153 | +clipboard.Init() |
| 154 | + |
| 155 | +html := clipboard.Register("text/html") |
| 156 | +clipboard.Write(html, []byte("<b>hi</b>")) |
| 157 | +b := clipboard.Read(html) |
| 158 | + |
| 159 | +// typed decode via the generic helper: |
| 160 | +doc, err := clipboard.ReadAs(html, func(b []byte) (*Node, error) { |
| 161 | + return parseHTML(b) |
| 162 | +}) |
| 163 | +``` |
| 164 | + |
| 165 | +## 8. Scope of the first PR |
| 166 | + |
| 167 | +- `Register`, `ReadAs[T]`, the registry, and `resolve` on all desktop platforms. |
| 168 | +- Round-trip tests per platform; doc + README update (incl. the BSD platforms |
| 169 | + currently missing from the README). |
| 170 | +- Out of scope (reserved, §6.x): selection axis (#67), enumeration (#89's |
| 171 | + remaining half — the tagged watch already shipped in #124), Wayland/web |
| 172 | + backends (#6/#64) — each lands as its own PR on top of this foundation. |
0 commit comments