Skip to content

Commit 39ec2ca

Browse files
committed
specs: design for dropping Cgo on mobile (iOS purego, Android non-goal)
1 parent 1f198eb commit 39ec2ca

1 file changed

Lines changed: 229 additions & 0 deletions

File tree

specs/mobile-remove-cgo.md

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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

Comments
 (0)