|
| 1 | +# Design: drop the Cgo / C-toolchain dependency on mobile (iOS, Android) |
| 2 | + |
| 3 | +| | | |
| 4 | +|---|---| |
| 5 | +| **Status** | Proposed | |
| 6 | +| **Issue** | _none open_ — closes the mobile gap left by #69/#25; see [#8](https://github.com/golang-design/clipboard/issues/8) (Android support), [#70](https://github.com/golang-design/clipboard/issues/70) (Android fails to build) | |
| 7 | +| **Builds on** | [#117](https://github.com/golang-design/clipboard/pull/117) / [`specs/darwin-remove-cgo.md`](./darwin-remove-cgo.md) — the purego/objc pasteboard binding this mirrors | |
| 8 | +| **Related** | [#25](https://github.com/golang-design/clipboard/issues/25) (Linux/X11), [#69](https://github.com/golang-design/clipboard/issues/69) (darwin) — the desktop Cgo-removal track this completes | |
| 9 | + |
| 10 | +## 1. Current state |
| 11 | + |
| 12 | +The two mobile backends both bind native clipboard APIs through Cgo: |
| 13 | + |
| 14 | +- **iOS** — `clipboard_ios.go` (`//go:build ios`) carries a Cgo preamble |
| 15 | + (`#cgo CFLAGS: -x objective-c`, `#cgo LDFLAGS: -framework Foundation -framework |
| 16 | + UIKit -framework MobileCoreServices`) and declares two C functions; |
| 17 | + `clipboard_ios.m` implements them against `UIPasteboard` (`generalPasteboard`, |
| 18 | + `string`, `setString:`). Only `FmtText` is supported; `FmtImage` returns |
| 19 | + `errUnsupported`. **It imports no `golang.org/x/mobile` package** — iOS |
| 20 | + `UIPasteboard` is process-global and needs no Activity/Context handle. |
| 21 | +- **Android** — `clipboard_android.go` (`//go:build android`) declares two C |
| 22 | + functions; `clipboard_android.c` implements them with **JNI** against the |
| 23 | + Android `ClipboardManager`. The JNI calls need a live `JNIEnv` + app `Context`, |
| 24 | + obtained via `golang.org/x/mobile/app.RunOnJVM(func(vm, env, ctx uintptr) ...)`. |
| 25 | + |
| 26 | +Consequences of the Cgo dependency (same shape as #69/#25 on the desktop): |
| 27 | + |
| 28 | +- Building either backend **requires a C toolchain** and `CGO_ENABLED=1`. |
| 29 | +- Under `CGO_ENABLED=0` a file with `import "C"` is dropped from the build, and |
| 30 | + `clipboard_nocgo.go` excludes `darwin` (so it never covers iOS). The result is |
| 31 | + the same silent-degradation bug #69 fixed on darwin — verified empirically: |
| 32 | + |
| 33 | + ``` |
| 34 | + $ GOOS=ios GOARCH=arm64 CGO_ENABLED=0 go build . |
| 35 | + ./clipboard.go:144:15: undefined: initialize # …read/write/watch too |
| 36 | + $ GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build . |
| 37 | + ./clipboard.go:144:15: undefined: initialize # same |
| 38 | + ``` |
| 39 | + |
| 40 | + Neither a working backend *nor* the graceful `ErrCgoDisabled` stubs are linked. |
| 41 | + |
| 42 | +Now that darwin (#117), Linux (#120), and BSD (#121) are Cgo-free, the two mobile |
| 43 | +backends are the last ones that drag in a C compiler. |
| 44 | + |
| 45 | +## 2. The decisive question, applied per platform |
| 46 | + |
| 47 | +The single test that decides feasibility: **can the platform's clipboard backend |
| 48 | +function without importing a package that itself requires Cgo?** |
| 49 | + |
| 50 | +| | library compiles cgo-free? | reachable API | verdict | |
| 51 | +|---|---|---|---| |
| 52 | +| **iOS** | ✅ stdlib builds (`GOOS=ios CGO_ENABLED=0 go build std` ✓); backend imports no cgo package | `UIPasteboard` via `purego/objc` (process-global, no Context) | **actionable** | |
| 53 | +| **Android** | ❌ backend needs `x/mobile/app.RunOnJVM` → `mobileinit.RunOnJVM` → `C.lockJNI` (cgo) | `ClipboardManager` via JNI, but only reachable through the cgo JVM bridge | **non-goal** | |
| 54 | + |
| 55 | +iOS passes; Android fails on a hard dependency. The rest of the spec follows from |
| 56 | +this split. There is no Android escape hatch within the gomobile model (§7). |
| 57 | + |
| 58 | +## 3. Goal & non-goals |
| 59 | + |
| 60 | +**Goal:** the `golang.design/x/clipboard` **library** builds for `GOOS=ios` with |
| 61 | +**`CGO_ENABLED=0`** and **no C toolchain**, with behavior identical to today |
| 62 | +(text read/write/watch; image remains unsupported on iOS). This also fixes the |
| 63 | +silent-degradation bug (§1) for iOS. |
| 64 | + |
| 65 | +**Non-goals — and be precise about the iOS one, because it is easy to overclaim:** |
| 66 | + |
| 67 | +- **A gomobile *app* does not become Cgo-free.** This spec removes Cgo from the |
| 68 | + *clipboard library*. `golang.org/x/mobile/app`'s `darwin_ios.go` is itself |
| 69 | + `import "C"`, so any gomobile iOS app still links Cgo via x/mobile's own |
| 70 | + scaffolding regardless of this package. The win is at the **library** level: |
| 71 | + the package cross-compiles to iOS from a toolchain-less environment, stops |
| 72 | + degrading to undefined symbols / no-op stubs, and stops *forcing* Cgo on |
| 73 | + consumers that bind iOS clipboard access without the full gomobile app harness. |
| 74 | + The three-way framing: |
| 75 | + |
| 76 | + | platform | library forces Cgo? | typical consumer links Cgo? | |
| 77 | + |---|---|---| |
| 78 | + | darwin (#117) | no | no | |
| 79 | + | **iOS (this spec)** | **no** | yes, from x/mobile app scaffolding (not from us) | |
| 80 | + | **Android (this spec)** | **yes** (unavoidable) | yes | |
| 81 | + |
| 82 | +- **Android stays Cgo.** See §7 — it is documented as a non-goal with its full |
| 83 | + dependency analysis, not silently skipped. |
| 84 | +- No public API change; no new clipboard formats; `cmd/gclip-gui` (Fyne/OpenGL, |
| 85 | + Cgo regardless) is untouched. |
| 86 | + |
| 87 | +## 4. iOS — the binding (mirror darwin) |
| 88 | + |
| 89 | +A near-copy of the darwin purego/objc backend (`clipboard_darwin.go`), retargeted |
| 90 | +from `NSPasteboard` to `UIPasteboard`. Because `UIPasteboard` is process-global, |
| 91 | +this is *simpler* than darwin: text-only, no image, no TIFF fallback. |
| 92 | + |
| 93 | +```go |
| 94 | +//go:build ios |
| 95 | + |
| 96 | +class_UIPasteboard := objc.GetClass("UIPasteboard") |
| 97 | +class_NSString := objc.GetClass("NSString") |
| 98 | +sel_generalPasteboard := objc.RegisterName("generalPasteboard") |
| 99 | +sel_string := objc.RegisterName("string") |
| 100 | +sel_setString := objc.RegisterName("setString:") |
| 101 | +// + NSString <-> bytes via initWithBytes:length:encoding: / dataUsingEncoding: |
| 102 | +``` |
| 103 | + |
| 104 | +- **`read(FmtText)`** → `[[UIPasteboard generalPasteboard] string]` → an |
| 105 | + `NSString`; convert to bytes with `dataUsingEncoding:` (NSUTF8 = 4) and copy via |
| 106 | + the shared `nsdataBytes` helper. `read(FmtImage)` → `errUnsupported` (unchanged). |
| 107 | +- **`write(FmtText)`** → build an `NSString` with |
| 108 | + `+[NSString stringWithUTF8String:]` (or `-initWithBytes:length:encoding:` to be |
| 109 | + NUL-safe) and `[pasteboard setString:]`. `write(FmtImage)` → `errUnsupported`. |
| 110 | +- **`watch`** → keep the existing poll-`Read` diff loop (1 s cadence), unchanged. |
| 111 | + `UIPasteboard.changeCount` exists but is not needed to preserve current |
| 112 | + behavior; not adopting it keeps the objc surface minimal. |
| 113 | + |
| 114 | +**Class reachability:** in a UIKit process `UIPasteboard`/`NSString` are already |
| 115 | +loaded, so `objc.GetClass` returns them without a `Dlopen`. No exported-symbol |
| 116 | +constants are needed (encodings are compile-time enum values), so unlike darwin |
| 117 | +there is **no `purego.Dlsym`**. As with darwin there are **no struct return |
| 118 | +values**, keeping the binding inside purego's proven `objc_msgSend` envelope on |
| 119 | +`arm64`. |
| 120 | + |
| 121 | +### Correctness (carry over the darwin lessons) |
| 122 | + |
| 123 | +- **Autorelease pools.** `-string` and the `NSString`/`NSData` accessors return |
| 124 | + *autoreleased* objects; these goroutines run on arbitrary OS threads with no |
| 125 | + pool, so without draining they leak — including in the per-second `watch` loop. |
| 126 | + Wrap each operation in an autorelease pool (`defer newAutoreleasePool()()`). |
| 127 | +- **Keep Go buffers alive.** `runtime.KeepAlive` around any `Send` that passes a |
| 128 | + Go slice's backing pointer (write path), exactly as darwin does. |
| 129 | +- **Fixes a latent use-after-free.** Today's `clipboard_ios.m` |
| 130 | + `return (char *)[str UTF8String];` hands back a pointer into autoreleased |
| 131 | + storage with no copy — a use-after-free the moment the pool drains. The purego |
| 132 | + port copies bytes out via `nsdataBytes`, eliminating it. A read test asserting |
| 133 | + round-tripped bytes is the regression guard. |
| 134 | + |
| 135 | +### Shared darwin/iOS code |
| 136 | + |
| 137 | +`newAutoreleasePool`, `nsdataBytes`, and the `must`/`must2` helpers in |
| 138 | +`clipboard_darwin.go` are platform-neutral within Apple — only the pasteboard |
| 139 | +class and selectors differ. The build tag `darwin` **includes** `ios`, so factor |
| 140 | +them into a `clipboard_apple.go` (`//go:build darwin`) shared file, leaving |
| 141 | +`clipboard_darwin.go` (`darwin && !ios`) and `clipboard_ios.go` (`ios`) to hold |
| 142 | +only their pasteboard-specific bindings. Decide at impl time; do not duplicate. |
| 143 | + |
| 144 | +## 5. Files touched (iOS) |
| 145 | + |
| 146 | +| File | Change | |
| 147 | +|---|---| |
| 148 | +| `clipboard_ios.go` | Replace Cgo preamble + bodies with a purego/objc `UIPasteboard` binding; keep `//go:build ios`. Builds under `CGO_ENABLED=0`. | |
| 149 | +| `clipboard_ios.m` | **Delete.** | |
| 150 | +| `clipboard_apple.go` *(new, optional)* | `//go:build darwin` shared `newAutoreleasePool`/`nsdataBytes`/`must` lifted from `clipboard_darwin.go`. | |
| 151 | +| `clipboard_nocgo.go` | Add `&& !ios` (and keep `!android`) so iOS no longer falls through to stubs. Android **still** falls through — see §7. | |
| 152 | +| `clipboard_test.go` | Any `TestClipboardNoCgo`/`TestClipboardInit` assumptions about iOS returning `ErrCgoDisabled` must change (iOS no longer does). | |
| 153 | +| `README.md` | `iOS/Android: collaborate with gomobile` → split: iOS gains a "no Cgo (library)" note like darwin; Android keeps the gomobile/Cgo note with the §7 rationale. | |
| 154 | +| `go.mod` | No change — `github.com/ebitengine/purego` is already a dependency (darwin). | |
| 155 | + |
| 156 | +## 6. Testing & CI (iOS) |
| 157 | + |
| 158 | +iOS cannot run `go test` on GitHub-hosted runners (no device/simulator). Mirror |
| 159 | +the existing build-only jobs (`freebsd_build`, `openbsd_build`, `windows_386_build`): |
| 160 | + |
| 161 | +- Add a **build-only** job: `GOOS=ios GOARCH=arm64 CGO_ENABLED=0 go build .` |
| 162 | + (the `std` half of this already passes; see §1/§2). This is the proof the |
| 163 | + toolchain is gone and the silent-degradation bug is fixed. |
| 164 | +- Add a pure-Go unit test for the byte round-trip / use-after-free fix where it |
| 165 | + can run without a device — i.e. a small helper test, since the live pasteboard |
| 166 | + path itself is device-only. |
| 167 | +- **Runtime verification is device-only**, via the `gclip-gui` demo on a real |
| 168 | + iOS device. Flag at impl time: confirm `UIPasteboard`/`NSString` are reachable |
| 169 | + through purego/objc on-device (cannot be exercised in CI). |
| 170 | +- Land the binding and its test/CI adjustments in the **same PR** — no untested |
| 171 | + intermediate state. |
| 172 | + |
| 173 | +## 7. Android — why it stays Cgo (non-goal, with the analysis) |
| 174 | + |
| 175 | +Android cannot reach the goal, and the reason is a hard dependency chain, not an |
| 176 | +implementation gap: |
| 177 | + |
| 178 | +1. **The package's JNI bridge is Cgo by construction.** `clipboard_android.c` |
| 179 | + makes JNI calls (`GetMethodID`, `CallObjectMethod`, …), and it needs a live |
| 180 | + `JNIEnv` + app `Context`. Those come from |
| 181 | + `golang.org/x/mobile/app.RunOnJVM`, which delegates to |
| 182 | + `internal/mobileinit.RunOnJVM` — implemented with **`C.lockJNI` / `C.unlockJNI` |
| 183 | + / `C.checkException`** (`ctx_android.go`, `import "C"`). So the backend pulls |
| 184 | + Cgo transitively even before its own C file. |
| 185 | +2. **The gomobile app model is Cgo by construction.** Android gomobile apps are |
| 186 | + built `-buildmode=c-shared` and loaded by a `NativeActivity` through the NDK — |
| 187 | + `CGO_ENABLED=0` is not a supported gomobile Android flow at all. The C |
| 188 | + toolchain (NDK) is required to *package* the app regardless of how the |
| 189 | + clipboard is bound. Empirically, `GOOS=android CGO_ENABLED=0 go build .` |
| 190 | + already fails on the x/mobile/app import. |
| 191 | + |
| 192 | +**Considered and rejected — purego-over-JNI.** purego supports Android `dlopen` |
| 193 | +(`dlfcn_android.go`), so in principle one could replace `clipboard_android.c` by |
| 194 | +calling the `JNIEnv` function-table pointers directly from Go. Rejected: it would |
| 195 | +**not** remove the C-toolchain requirement (x/mobile/app stays Cgo; the NDK |
| 196 | +c-shared build stays). It is churn and added `unsafe` risk with **zero** movement |
| 197 | +toward the goal. Revisit only if/when the JVM/Context lifecycle can be obtained |
| 198 | +without a cgo x/mobile dependency — out of scope here. (Tracks the upstream |
| 199 | +constraint noted around #70.) |
| 200 | + |
| 201 | +**Net for Android:** no source change. It continues to require Cgo + the NDK, and |
| 202 | +`clipboard_nocgo.go` keeps its `!android` exclusion so a `CGO_ENABLED=0` build of |
| 203 | +the library on a non-mobile host still resolves to the graceful stubs. |
| 204 | + |
| 205 | +## 8. Implementation phases |
| 206 | + |
| 207 | +1. **iOS binding** — port `clipboard_ios.go` to purego/objc on `UIPasteboard` |
| 208 | + (text read/write/watch); delete `clipboard_ios.m`; factor the shared Apple |
| 209 | + helpers (§4); flip the `nocgo` tag (`!ios`); fix the affected test |
| 210 | + assumptions. Verify `GOOS=ios CGO_ENABLED=0 go build .` is green. |
| 211 | +2. **CI** — add the `ios_build` build-only job (§6). |
| 212 | +3. **Docs** — README iOS line (no-Cgo at library level) + Android rationale (§7); |
| 213 | + note iOS is now Cgo-free for the library in the package doc. |
| 214 | + |
| 215 | +## 9. Risk & effort |
| 216 | + |
| 217 | +- **Effort:** small. iOS is a strict subset of the already-shipped darwin binding |
| 218 | + (text-only, no image, no TIFF, no `Dlsym`); much of the code is shared verbatim. |
| 219 | +- **Risk:** |
| 220 | + - *On-device reachability* of `UIPasteboard` via purego/objc — cannot be |
| 221 | + CI-verified; mitigated by the `gclip-gui` device check (§6). |
| 222 | + - *Autorelease leaks* in long-running `watch` — mitigated by reusing darwin's |
| 223 | + `newAutoreleasePool` (§4), and invisible to CI, hence flagged. |
| 224 | + - *Overclaiming the benefit* — mitigated by the explicit library-vs-app framing |
| 225 | + (§3); the spec deliberately does not promise a Cgo-free gomobile app. |
| 226 | +- **Upside:** iOS joins darwin/Linux/Windows/BSD as a Cgo-free library backend; |
| 227 | + cross-compiling the library to iOS without a toolchain becomes possible; the |
| 228 | + iOS silent-degradation + use-after-free bugs are fixed. Android's status is now |
| 229 | + documented rather than ambiguous. |
0 commit comments