Skip to content

Wire the shared Kotlin framework into the iOS app - #35

Merged
baijum merged 2 commits into
mainfrom
claude/github-issue-14-953184
Sep 13, 2026
Merged

baijum merged 2 commits into
mainfrom
claude/github-issue-14-953184

Conversation

@baijum

@baijum baijum commented Sep 13, 2026

Copy link
Copy Markdown
Member

Fixes #14.

What was wrong

docs/architecture.md said the iOS app "consumes :shared as a static framework", but nothing linked it: no Swift file imported it, project.yml had no framework dependency, and iOS CI never ran Gradle. shared/src/iosMain was compiled by no one, and the iOS data layer was a hand-maintained Swift copy of the Kotlin code (Examples.swift, a FileManager-based FileBrowserViewModel, a UserDefaults-based SettingsViewModel). That duplication is how the examples drifted in #4, and it meant the contract work in #17 never reached the iOS app.

What this PR does

The issue offered two fixes; this takes the first one and makes the documented design true.

Build wiring

  • iosApp/project.yml: a "Build shared Kotlin framework" pre-build script phase runs ./gradlew :shared:embedAndSignAppleFrameworkForXcode; FRAMEWORK_SEARCH_PATHS points at shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME) and OTHER_LDFLAGS links -framework shared. The framework is static, so nothing is embedded or signed. The script falls back to /usr/libexec/java_home when Xcode has no JAVA_HOME. project.pbxproj is regenerated.
  • iOS CI sets up JDK 17 and Gradle, caches ~/.konan, and links the simulator framework in its own step first so an iosMain compile error produces a readable Gradle log before xcodebuild runs.

Swift migration

  • ExamplesView reads ExampleRepository.shared.examples; Examples.swift is deleted, along with scripts/check-examples-parity.py and its Android CI step.
  • FileBrowserViewModel and SettingsViewModel are thin @MainActor wrappers over the Kotlin FileRepository / SettingsRepository. The file view model's API becomes async throws; FileBrowserView, EditorView and ContentView are adjusted accordingly, and file deletion now surfaces failures in an alert like Android does.
  • Helpers/SharedModels.swift adds Identifiable conformances and a Date accessor for the Kotlin models.

Kotlin contract

  • Only exceptions listed in @Throws become Swift errors; anything else crossing the bridge terminates the process. The FileRepository expect/actuals and SchemeFileNames.sanitize now declare theirs (Kotlin requires CancellationException on suspend functions).
  • The iOS SettingsRepository uppercases the stored theme name, so the value the old Swift code wrote ("Light"/"Dark"/"System") still reads after the upgrade instead of silently resetting to System.

Tests

  • SharedFrameworkTests (replaces ExamplesTests): examples, enum entries/labels and thrown Kotlin errors arrive in Swift.
  • FileBrowserViewModelTests: save → list → read → delete round trip through the Kotlin repository on the simulator, plus invalid-name, missing-file and failed-delete error paths.

Docs: architecture.md, development.md, getting-started.md, troubleshooting.md, README.md, AGENTS.md and the release skills describe the build as it now is, including the new JDK requirement for iOS builds.

Trade-off to be aware of

Every iOS build (Xcode, CI, xcodebuild archive) now needs a JDK and runs Gradle. Locally the first build downloads the Kotlin/Native toolchain; afterwards the phase is a few seconds when nothing changed. If you would rather keep iOS Gradle-free, the alternative is the issue's option 2 (delete the iOS targets and iosMain); the docs half of this PR would still apply in spirit but would need rewording.

Verification

  • xcodebuild build and xcodebuild test on an iPhone 17 Pro simulator (Xcode 26.4): 13 tests pass, including the Kotlin round trip.
  • Confirmed the Kotlin classes are statically linked (_OBJC_CLASS_$_SharedFileRepository etc. in the app's debug dylib, no dynamic shared.framework dependency).
  • Ran the app on the simulator: examples list from the Kotlin repository in category order, the theme picker writes DARK through the Kotlin repository, and the new-file flow creates Documents/schemes/<name>.scm via the Kotlin actual and opens it in the editor.
  • ./gradlew assembleDebug test :shared:testAndroidHostTest :app:detekt and scripts/check-webview-assets.sh pass.

🤖 Generated with Claude Code

The docs said iOS "consumes :shared as a static framework", but nothing
linked it: no Swift file imported it, project.yml had no framework
dependency, and iOS CI never ran Gradle. shared/src/iosMain was compiled
by no one, and the iOS data layer was a hand-maintained Swift copy of the
Kotlin code (Examples.swift, the FileManager-based FileBrowserViewModel,
the UserDefaults-based SettingsViewModel), which is how the examples
drifted in #4 and why the contract work in #17 never reached the app.

Take the issue's first option and make the claim true. A pre-build script
phase in project.yml runs embedAndSignAppleFrameworkForXcode, the target
links the static `shared` framework from shared/build/xcode-frameworks,
and the Swift view models now wrap the Kotlin FileRepository and
SettingsRepository; ExamplesView reads ExampleRepository directly. The
Swift duplicates, the examples-parity script and its CI step go away.

Two bridge details are part of the contract now. Only exceptions listed
in @throws become Swift errors (an unlisted Kotlin exception terminates
the process), so the FileRepository expect/actuals and
SchemeFileNames.sanitize declare theirs, with CancellationException on
the suspend functions as Kotlin requires. And the iOS SettingsRepository
uppercases the stored theme name so the value the old Swift code wrote
("Light"/"Dark"/"System") still reads after the upgrade instead of
falling back to SYSTEM.

Every iOS build (local, CI, archive) now needs a JDK, so iOS CI sets up
Java and Gradle, caches ~/.konan, and links the simulator framework in
its own step first so an iosMain compile error shows a readable Gradle
log. New simulator tests cover the bridge: SharedFrameworkTests checks
the examples, enums and thrown errors arrive in Swift, and
FileBrowserViewModelTests round-trips save/list/read/delete through the
Kotlin repository.

Docs, AGENTS.md and the release skills describe the build as it is now.

Fixes #14

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Baiju Muthukadan <baiju.m.mail@gmail.com>

@baijum baijum left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall: this looks good — nothing blocking from me. All inline comments are minor nits, doc suggestions, or praise.

What I verified beyond reading the diff:

  • xcodegen generate on this branch reproduces the committed project.pbxproj exactly — the generated project is in sync with project.yml.
  • The Gradle phase really does produce the directory FRAMEWORK_SEARCH_PATHS points at: shared/build/xcode-frameworks/Debug/iphonesimulator26.4/shared.framework exists from a real build, and the framework is isStatic = true as the docs now claim.
  • Both CI workflows are green, including xcodebuild test, so the new simulator tests (bridge round trip + error paths) ran in CI, not just locally.
  • :shared:testAndroidHostTest passes; the @Throws additions are the only shared/ change that touches compiled code and are JVM-inert, so Android behavior is unchanged.
  • The updated docs check out: ExampleRepositoryTest really does enforce the per-example code-size cap (so AGENTS.md's new attribution is correct), the test inventory in docs/development.md matches iosApp/KaappiStudioTests/, and the stale "plain jvm() target" claim in architecture.md is correctly gone (build.gradle.kts uses android { withHostTest { } }).

The migration handling deserves a call-out: uppercasing the stored theme keeps every legacy value ("Light"/"Dark"/"System", any casing) readable, and existing files in Documents/schemes/ are path-compatible with the Kotlin actual — an upgrading user keeps both. Deleting check-examples-parity.py is the right end state: the invariant it policed is now structural (one list, one definition, compiled into both apps).

func refresh() async {
// listFiles only throws for cancellation; an unreadable directory
// lists as empty.
files = (try? await repository.listFiles()) ?? []

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, non-blocking: since cancellation is the only thing listFiles() can throw across the bridge, this try? maps a cancelled refresh to files = [] rather than leaving the list untouched. Harmless today — the only caller is .task, which cancels on disappear — but if a future caller refreshes from a context that gets cancelled mid-flight, the view could briefly show an empty file list. A do/catch that only falls back to [] for non-cancellation errors (or just not catching at all, since nothing else can be thrown) would make that contract explicit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2b239d0: refresh() now returns without touching files when listFiles throws (which can only be cancellation), so a cancelled refresh leaves the list as it was.

let base = try SchemeFileNames.sanitize(name)
let url = directory.appendingPathComponent(SchemeFileNames.withExtension(base))
return FileManager.default.fileExists(atPath: url.path)
func fileExists(name: String) async throws -> Bool {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct per the doc comment (it checks the disk, not a possibly-stale files), two observations for the record rather than change requests:

  • it now costs a full directory listing (with an mtime stat per entry) where the old Swift code was a single fileExists probe — fine at current scale, but if this ever gets hot, a suspend fun fileExists(name: String): Boolean on the shared repository would give both platforms a one-stat path;
  • callers already pass a sanitized base and this sanitizes again (idempotent, so just belt-and-braces) — fine either way, just worth knowing which layer owns validation if the rule is ever relaxed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is, deliberately. The Swift fileExists mirrors the Android FileBrowserViewModel.fileExists (sanitize, then scan listFiles), so both platforms own the same rule at the same layer today; the second sanitize is there so a caller passing a raw name still gets a correct answer. A suspend fun fileExists(name) on the shared repository is the right one-stat path if this ever gets hot, and it would move validation into the repository for both platforms at once — I'd rather land that as its own small change than fold it into this one.

* terminates the process.
*/
expect class FileRepository {
suspend fun listFiles(): List<SchemeFile>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Swift view model now leans on an unwritten rule: "listFiles only throws for cancellation; an unreadable directory lists as empty." Both actuals obey it (?: emptyList() on Android, ?: return@withContext emptyList() on iOS), but the expect KDoc pins down never-reads-contents and not this. Since it's also the one method without @Throws, a future actual that instead throws FileRepositoryException on an unreadable directory would cross the bridge unannotated and terminate the iOS app. A one-line KDoc addition here would lock the rule in.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 2b239d0: the listFiles bullet in the expect KDoc now states it never throws (a missing or unreadable directory lists as empty) and explains why: it is the one operation without @Throws, so an actual that threw would terminate the iOS app.

// same key ("Light"/"Dark"/"System") readable after the upgrade.
return try {
ThemeMode.valueOf(name)
ThemeMode.valueOf(name.uppercase())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice migration detail: any legacy casing ("Light", "dark", "SYSTEM") survives, and the catch still covers genuinely corrupted values. Combined with the unchanged Documents/schemes/ location, an upgrading user keeps both their theme and their files.

Comment thread .github/workflows/ios.yml
# readable Gradle log instead of buried inside xcodebuild output. The
# Xcode run-script phase then finds the task up to date.
- name: Build shared Kotlin framework (simulator)
run: ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building the simulator framework as its own step before xcodebuild is a thoughtful touch — an iosMain compile error fails with a readable Gradle log instead of a script-phase error buried in xcodebuild output. And the ~/.konan cache keyed on libs.versions.toml is the right key, since that file carries the Kotlin version.

…les never throws

`refresh()` mapped a cancelled listFiles to an empty list. Cancellation is
the only error that can cross the bridge there, so leave the published
list untouched instead of blanking it mid-flight.

The Swift view model relies on listFiles never throwing for I/O, but the
expect KDoc only pinned down never-reads-contents. Record the rule next
to the others so a future actual does not throw an unannotated exception
into the iOS app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Baiju Muthukadan <baiju.m.mail@gmail.com>
@baijum
baijum merged commit 75f4069 into main Sep 13, 2026
3 checks passed
@baijum
baijum deleted the claude/github-issue-14-953184 branch September 13, 2026 05:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

iOS app does not consume the shared KMP framework — shared/iosMain is dead code and docs claim otherwise

1 participant