Replace SKIE with Kotlin's Swift export - #8
Open
solcott wants to merge 8 commits into
Open
Conversation
Probe only. SKIE is removed from :apple and a swiftExport block added, but the
XCFramework binaries stay exactly as they were, so the Obj-C framework iosApp/
links is untouched and nothing in Swift has been asked to change yet.
Deliberately still on Kotlin 2.4.10. Swift export has run on stable Kotlin since
2.2.20, so "does swift export cope with this API surface" and "does this repo
survive a Kotlin beta" are separable questions; fusing them would make a failure
uninterpretable.
Findings, from apple/build/SwiftExport/<target>/Debug/files:
- macOS works. `diff -r` between the macosArm64 and iosSimulatorArm64 output is
empty — byte-identical Swift. That was the open question the docs never answer:
they never mention macOS, and every JetBrains example and their own sample
declares iOS targets only. It is undocumented, not unsupported.
- ContentState survives but is fully erased: `public final class ContentState`
with no type parameter, and `data: (any KotlinRuntimeSupport._KotlinBridgeable)?`.
Obj-C at least bridged it far enough for `as? [Country]` to work.
- Concrete List<T> in a signature is fine — `selectedContinents` comes through as
a real `Swift.Array<Continent>`. The erasure is a generic-class problem, not a
collection problem.
- eventSink survives as an honest Swift closure taking `any ...Event`.
- The Kotlin extension properties invisible to Obj-C — isLoading, errorOrNull —
are exported as free functions taking the receiver. LoadPhase.swift exists
partly to reimplement those.
- TextFieldState exports rather than erroring, dragging Compose UI text, graphics,
geometry and unit along: ~8k lines of generated Swift for a property Swift
cannot use.
- DataError and LoadStatus are bare empty protocols with no cases, and the
generated code carries an explicit `fatalError("Inheritance from exported Kotlin
classes is not supported yet")`. Both are the 2.4.10 behaviour and both are what
the 2.4.20-Beta2 bump is meant to fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ssing
The bump on its own, so that if it has to be reverted the Swift export work
survives. This is the change SKIE cannot coexist with: SKIE 0.10.14 supports
2.0.0 through 2.4.10 only, which is why it came out in the previous commit
rather than this one.
Both of the limitations the docs still list as current are gone here, confirmed
by regenerating the export and diffing against the 2.4.10 output:
- Sealed types now map to real Swift enums. DataError gets a generated
`sealedType()` returning `DataError_SealedType` with all five cases
(api/http/network/serialization/unknown) and a `.value` accessor; LoadStatus
likewise. That is a like-for-like replacement for SKIE's `onEnum(of:)`,
including the exhaustiveness that made it worth having.
- Cross-language inheritance is supported. The generated code on 2.4.10 carried
an explicit `fatalError("Inheritance from exported Kotlin classes is not
supported yet")` in every class initialiser; on Beta2 there are zero
occurrences.
Still erased, as the docs say: ContentState exports without its type parameter
and `data` is `(any KotlinRuntimeSupport._KotlinBridgeable)?`. Not the blocker it
first looks like — `__createBridgeable` unboxes a Kotlin List to `[Any]`
(KotlinRuntimeSupport case 13) and Swift downcasts arrays element-wise, so the
`data as? [Country]` the app already performs under Obj-C keeps working. Same
ergonomics as today, so no DTO facade is needed.
Verified on the beta: :build-logic:build, :presenter:compileKotlinIosSimulatorArm64
(Metro, redacted and Compose together — the real risk, since Metro backs nine
modules), 🍎allTests on both simulator and macOS, and `test assembleDebug`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running the real pipeline (:apple:embedSwiftExportForXcode, which generates the
Swift, builds a synthetic SPM package with xcodebuild, and copies the static
library and .swiftmodules into BUILT_PRODUCTS_DIR) turns up three things the
docs do not mention.
1. Swift export's coroutine support requires iOS 18 / macOS 15. The generated
KotlinCoroutineSupport.swift uses Synchronization.Mutex:
error: 'Mutex' is only available in iOS 18.0 or newer
This app's floor is iOS 17.0, so the app cannot adopt Swift export's flow
support without raising it. macOS is already at 15.0 and unaffected. Nothing
in the Swift export documentation states a minimum OS.
2. `export(project(...))` is a trap here. It does not mean what it means on an
Obj-C framework: Swift export already emits everything reachable from this
module's public API, so :model and :presenter come through either way. Naming
them exports their public API *in full*, which pulled in `Outcome<out T>` — a
generic sealed interface no Swift code touches — and 2.4.20-Beta2's new
sealed-enum codegen emits invalid Swift for it. Dropping the explicit exports
drops `Outcome` and fixes it. So the feature that makes the beta worth having
is also the one that breaks on a generic sealed type.
3. What remains is TextFieldState, and only TextFieldState. Compose's
`Saver.save` is a method with an extension receiver; the reverse-interop thunk
generated for it passes the receiver as the `value:` argument and omits the
receiver entirely, so the generated Swift does not compile. Confirmed by
probe: with `CountryListScreen.State.nameStartsWithText` temporarily retyped
to String, the Compose error disappears and the whole Compose UI tree
(text/graphics/geometry/unit, ~8k lines of generated Swift) stops being
exported. That probe was reverted; :presenter is untouched.
Also confirmed here, and better than the plan assumed: `StateFlow` is *not*
erased. It exports as `any KotlinTypedStateFlow<CountryListScreen.State>` with a
typed non-optional `value` and `asAsyncSequence()` — a direct replacement for
SkieSwiftStateFlow, so no Kotlin-side currentState/states split is needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last blocker. CountryListScreen.State carries a Compose TextFieldState, and Swift export generates invalid Swift for Compose's Saver — its `save` takes an extension receiver and the reverse-interop thunk drops it, passing the receiver as `value:`. That is unfixable from here, so the Circuit state stops crossing the boundary. :apple now publishes CountryListUiState and CountryDetailUiState: no generics, no Compose, no Circuit. What still crosses is :model's data classes and LoadStatus, which is a plain non-generic sealed interface and so exactly the shape 2.4.20-Beta2's sealed-enum support handles well. eventSink is kept `internal` and replaced on the public API by named methods — search/toggleContinent/retry, back/retry. Partly ergonomics, but mainly safety: CountryListScreen.Event is a sibling nested type of CountryListScreen.State, and exporting one risks dragging the other, and TextFieldState with it, straight back in. This also answers the ContentState<T> question by making it moot. Nothing generic crosses now, so the erased `data` and the unchecked `as? [Country]` the Obj-C build needed both disappear; countries and continents arrive as typed Swift arrays. Effect on the export surface: Compose is gone from it entirely. The generated Swift no longer contains ui-text, ui-graphics, ui-geometry, ui-unit or foundation — about 8,000 lines that existed only to describe a property Swift could never use. Both slices now build end to end through 🍎embedSwiftExportForXcode, including macOS, which was the stop condition for this spike. 🍎allTests passes; PresenterHolderTest reads the facade's own fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Xcode now runs 🍎embedSwiftExportForXcode. There is no framework to embed
or sign, so FRAMEWORK_SEARCH_PATHS is gone and OTHER_LDFLAGS loses
`-framework CountriesKit`, gaining `-lsqlite3` in its place — the static library
records no linker options, so SQLiter's symbols resolve at the app link.
IPHONEOS_DEPLOYMENT_TARGET goes 17.0 -> 18.0. That is a real cost, not a tidy-up:
Swift export's generated coroutine support uses Synchronization.Mutex, so
adopting its Flow support means dropping iOS 17. macOS was already at 15.0.
The Swift port itself is small, because Swift export turned out to cover both
things SKIE was here for:
- `StateFlow` reaches Swift as `KotlinTypedStateFlow<T>`, with a typed
non-optional `value` and `asAsyncSequence()`. `PresenterHolding` survives
almost verbatim; only the flow type and a `try` on the iteration changed.
- `onEnum(of:)` becomes `sealedType()`, and both switches in LoadPhase.swift
still have no `default:` case.
Navigation moved behind the facade too. SwiftNavigator now takes and returns
country codes rather than Circuit Screens, and builds the two Screens itself.
Swift no longer names Screen, Navigator, CountryListScreen or
CountryDetailScreen at all.
Two things are worse than under SKIE, both recorded in KotlinTypes.swift:
- Nothing is flattened. `flattenPackage` only applies inside an
`export(project(…))`, which is exactly what cannot be used here, so the app
declares its own typealiases onto ExportedKotlinPackages.
- Sealed members are worse still: Swift export generates the nested
`DataError.Network` convenience names without an access modifier, so they are
internal to the generated module and unreachable. Only the mangled top-level
class is public. Flattening those by hand reproduces exactly the names Obj-C
interop used to mangle for free. Construction only — reading goes through
`sealedType()` and needs none of it.
macOS needed one extra linker flag. Apple's system libsqlite3 omits
sqlite3_load_extension and sqlite3_enable_load_extension, which SQLiter
references and never calls; the old dynamic framework never had to resolve them,
a static library linked into the app does. Allowed as undefined by name rather
than by loosening the whole link.
Verified: iPhone 17 Pro and iPad mini 18.4 both green — 7 XCUITests and 8 Swift
Testing tests each — plus a macOS build, 🍎allTests, and ktfmtCheck. The
XCUITests were not edited, which is the point: they contain no Kotlin, so they
only pass if the behaviour is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing applies the plugin now. The entry stays so reverting the spike is a two-line change, but its old comment described behaviour that is no longer wired up, and it did not say the thing that now matters most: SKIE caps at Kotlin 2.4.10, so going back to it means giving up 2.4.20-Beta2 and with it the sealed-enum and cross-language-inheritance support this spike was for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ft shim
Two changes that turn out to be the same change.
**The layering.** `:model` was meant for domain nouns and had drifted: alongside
Country and Continent it held DataError, Origin and Outcome, which describe not a
thing in the domain but how a read of it went. Those move to `:dataresult`, at the
bottom of the graph with no dependencies. ContentState and LoadStatus move out of
`:presenter` into `:uistate`, which depends only on `:dataresult`. `:model` is now
Country.kt and Continent.kt and nothing else.
`applyEmission` stays behind in `:presenter` as ApplyEmission.kt, next to its only
two callers. It is the one member of ContentState.kt that touches Outcome, and
leaving it out is what keeps `:uistate` free of any dependency but `:dataresult`.
**The export.** That split is exactly the line Swift export needed. `export(project(…))`
is the only way to set `flattenPackage`, but it exports a module's public API *in
full*, so it is only usable on a module where nothing breaks the generator. Three
such modules now exist, all three are exported and flattened, and `:presenter` —
which still carries TextFieldState — is not. Nothing reaches into it anyway; the
facade in AppleUiState.kt already saw to that.
Outcome, DataError and LoadStatus become sealed **classes**. Both Swift export
problems turned out to be sealed-*interface* problems, verified against the
generated Swift:
- A generic sealed interface emits invalid Swift: the erased `Outcome_Data` does
not conform to the erased `Outcome` protocol, and there is no way for it to.
As a sealed class it is plain subclassing, which survives erasure —
`public final class Data: …Outcome`.
- Sealed interface members reach Swift as a mangled top-level class plus a nested
typealias emitted with **no access modifier**, so they are internal to the
generated module and unreachable from the app. Sealed class members are
genuinely nested public classes: `DataError.Network` is just there.
`sealedType()` and its exhaustive switch survive both conversions, which is the
property worth protecting. The cost is that `sealed interface` is the more
idiomatic Kotlin for a stateless hierarchy, and `any DataError` becomes plain
`DataError` in Swift now that it is a class rather than a protocol.
Net effect: KotlinTypes.swift is **deleted** — all 12 typealiases gone, five to
flattening and seven to the sealed-class conversion.
Verified: ktfmtCheck, test, assembleDebug, 🍎allTests, :desktop and :web
compiles, and an iPhone build of the SwiftUI app.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md's module list goes 11 -> 13 and states the rule the split encodes: `model` is domain nouns, `dataresult` is how a read went, `uistate` is view state. It also says the part that is not obvious from the code — those three modules are exported to Swift *in full*, so adding a Compose type or a generic sealed type to any of them breaks the iOS build with nothing to warn you. The `apple` section is rewritten off SKIE. The rule worth having in writing is that a sealed type crossing to Swift must be a `sealed class`: a sealed *interface* member reaches Swift only through a typealias emitted with no access modifier, and a *generic* sealed interface does not compile at all. Also recorded: that `export()` here exists to set `flattenPackage` rather than to make types visible, that the iOS floor is 18 because the generated coroutine support uses Mutex, and where `-lsqlite3` went. Added a "Swift export bugs worth reporting" list with the four undocumented behaviours found while porting. A YouTrack search turned up no existing report for the first two, though the search was shallow enough that this is "not found" rather than "does not exist". Replaced the stale assembleCountriesKitDebugXCFramework command with the one that is actually useful now: generating the Swift without going through Xcode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 4/4 — base
feature/iosApp(#7).An experiment: can JetBrains' own Swift export replace SKIE? It can, on all three Apple
destinations. This documents what that costs.
The docs are out of date
Two limitations the Swift export page still
lists as current are gone in Kotlin 2.4.20-Beta2:
fatalError("Inheritance from exported Kotlin classes is not supported yet")in every initialiser; on Beta2 there are zero occurrencessealedType()returns a real Swift enum with all casesmacOS also works, which the docs never mention:
diff -rbetween the macOS and iOS generatedSwift is empty, and both link end to end. Every official example and JetBrains' own sample is iOS-only,
so this was the stop condition for the spike.
Four undocumented behaviours
Written up under "Swift export bugs worth reporting" in AGENTS.md. A YouTrack search found no existing
report for the first two, though that search was shallow enough to be "not found" rather than "absent":
made to conform to the erased parent protocol.
DataError.Networkconvenience names are emitted as typealiases with no access modifier, so only the mangled
top-level class is public.
Saver.savegenerates a malformed reverse-interop thunk — it takes an extension receiver, andthe generated code passes the receiver as the
value:argument. This is what makes any Composetype in the exported API fatal.
Synchronization.Mutex), with no documentedminimum OS.
Both (1) and (2) turn out to be sealed-interface problems: a sealed class exports its members as
genuinely nested public classes and survives erasure by plain subclassing.
Outcome,DataErrorandLoadStatusare sealed classes for that reason.The module split
:modelhad drifted — alongsideCountryandContinentit heldDataError,OriginandOutcome,which describe not a thing in the domain but how a read of it went. Those move to
:dataresult;ContentStateandLoadStatusmove to:uistate. That is the right layering on its own, and itis also exactly the line Swift export needed:
export(project(…))exports a module's API in full, soit is only usable where nothing breaks the generator.
Net effect:
KotlinTypes.swiftis deleted — all 12 typealiases gone.What it costs
sealed interface→sealed classon three shared types, which is the less idiomatic Kotlin.Verification
ktfmtCheck,test,assembleDebug,:apple:allTests,:desktopand:webcompiles; iPhone andiPad both green (7 UI + 8 unit tests each); macOS builds.
The XCUITests were never edited. They contain no Kotlin, so a 13-module reshuffle and a full interop
replacement passing them unchanged is the real evidence behaviour is identical.
🤖 Generated with Claude Code