Repository navigation
Wire the shared Kotlin framework into the iOS app - #35
Conversation
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
left a comment
There was a problem hiding this comment.
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 generateon this branch reproduces the committedproject.pbxprojexactly — the generated project is in sync withproject.yml.- The Gradle phase really does produce the directory
FRAMEWORK_SEARCH_PATHSpoints at:shared/build/xcode-frameworks/Debug/iphonesimulator26.4/shared.frameworkexists from a real build, and the framework isisStatic = trueas 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:testAndroidHostTestpasses; the@Throwsadditions are the onlyshared/change that touches compiled code and are JVM-inert, so Android behavior is unchanged.- The updated docs check out:
ExampleRepositoryTestreally does enforce the per-example code-size cap (so AGENTS.md's new attribution is correct), the test inventory indocs/development.mdmatchesiosApp/KaappiStudioTests/, and the stale "plainjvm()target" claim in architecture.md is correctly gone (build.gradle.ktsusesandroid { 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()) ?? [] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
fileExistsprobe — fine at current scale, but if this ever gets hot, asuspend fun fileExists(name: String): Booleanon 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.
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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>
Fixes #14.
What was wrong
docs/architecture.mdsaid the iOS app "consumes:sharedas a static framework", but nothing linked it: no Swift file imported it,project.ymlhad no framework dependency, and iOS CI never ran Gradle.shared/src/iosMainwas compiled by no one, and the iOS data layer was a hand-maintained Swift copy of the Kotlin code (Examples.swift, aFileManager-basedFileBrowserViewModel, aUserDefaults-basedSettingsViewModel). 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_PATHSpoints atshared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)andOTHER_LDFLAGSlinks-framework shared. The framework is static, so nothing is embedded or signed. The script falls back to/usr/libexec/java_homewhen Xcode has noJAVA_HOME.project.pbxprojis regenerated.~/.konan, and links the simulator framework in its own step first so aniosMaincompile error produces a readable Gradle log beforexcodebuildruns.Swift migration
ExamplesViewreadsExampleRepository.shared.examples;Examples.swiftis deleted, along withscripts/check-examples-parity.pyand its Android CI step.FileBrowserViewModelandSettingsViewModelare thin@MainActorwrappers over the KotlinFileRepository/SettingsRepository. The file view model's API becomesasync throws;FileBrowserView,EditorViewandContentVieware adjusted accordingly, and file deletion now surfaces failures in an alert like Android does.Helpers/SharedModels.swiftaddsIdentifiableconformances and aDateaccessor for the Kotlin models.Kotlin contract
@Throwsbecome Swift errors; anything else crossing the bridge terminates the process. TheFileRepositoryexpect/actuals andSchemeFileNames.sanitizenow declare theirs (Kotlin requiresCancellationExceptiononsuspendfunctions).SettingsRepositoryuppercases 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(replacesExamplesTests): 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.mdand 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 andiosMain); the docs half of this PR would still apply in spirit but would need rewording.Verification
xcodebuild buildandxcodebuild teston an iPhone 17 Pro simulator (Xcode 26.4): 13 tests pass, including the Kotlin round trip._OBJC_CLASS_$_SharedFileRepositoryetc. in the app's debug dylib, no dynamicshared.frameworkdependency).DARKthrough the Kotlin repository, and the new-file flow createsDocuments/schemes/<name>.scmvia the Kotlin actual and opens it in the editor../gradlew assembleDebug test :shared:testAndroidHostTest :app:detektandscripts/check-webview-assets.shpass.🤖 Generated with Claude Code