From b367aa2611e1350f4733362294527adbad66b312 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 14:03:34 -0400 Subject: [PATCH 1/2] fix(ed25519): embed the C library into the cinterop klib `binaries.linkerOpts` only reaches the binaries this module itself produces, so the ed25519 objects never travelled into a downstream framework -- the shared-core XCFramework exported `_ed25519_sign`/`_ed25519_verify` as undefined and left every consumer to supply them. The iOS app happens to compile the same C reference implementation, which is why nothing had failed yet; a standalone SPM consumer fails to link. `-staticLibrary`/`-libraryPath` on the cinterop puts the archive in the klib, so anything built from it carries the symbols. --- libs/encryption/ed25519/build.gradle.kts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/libs/encryption/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts index d724516e4e..b7548dd6b6 100644 --- a/libs/encryption/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -85,6 +85,15 @@ kotlin { compilations["main"].cinterops.create("ed25519") { definitionFile = file("cinterop/ed25519.def") includeDirs(ed25519SrcDir) + // Embed the archive in the klib rather than leaving it to each binary's + // linker flags: a static framework built from this module ships the C + // objects inside it, so consumers link one artifact and nothing else. + // Without this the framework exports `ed25519_*` as undefined symbols and + // only links inside an app that happens to compile the same C itself. + extraOpts( + "-staticLibrary", "libored25519.a", + "-libraryPath", libDir.get().asFile.absolutePath, + ) } // Both the cinterop binding task and the Kotlin compile task need the @@ -95,10 +104,6 @@ kotlin { tasks.matching { it.name == "compileKotlin${name.replaceFirstChar { it.uppercaseChar() }}" }.configureEach { dependsOn(compileTaskName) } - - binaries.configureEach { - linkerOpts("-L${libDir.get().asFile.absolutePath}", "-lored25519") - } } sourceSets { From 2ce9f3435324e33f6ba17a5ada8dd050ed2aeff6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 14:03:34 -0400 Subject: [PATCH 2/2] feat(shared-core): wrap the framework in a Swift target The Kotlin/Native ObjC surface is not something an app should have to hold: byte payloads arrive as `KotlinByteArray`, objects come through `.shared`, and default arguments don't survive the export at all. Adding a `SharedCoreKit` Swift target over the binary framework moves that translation into the package -- iOS calls `KikCode.svg(payload:)` with `Data` and named defaults, and the framework itself is no longer a product. The Swift sources live here rather than in the Swift Package repo so the glue and the Kotlin it wraps move in one commit; the publish job copies them across, which also means `Package.swift` is ours now (KMMBridge rewrites only its variables block). The job builds the staged package against the framework it just uploaded before moving the tag, so a mismatch between the two can't reach a consumer. --- .github/workflows/publish-shared-core.yml | 29 ++++++++++++- kmp/shared-core/build.gradle.kts | 14 ++++--- kmp/shared-core/spm/Package.swift | 42 +++++++++++++++++++ .../SharedCoreKit/Data+KotlinByteArray.swift | 15 +++++++ .../spm/Sources/SharedCoreKit/KikCode.swift | 32 ++++++++++++++ .../SharedCoreKit/SharedCoreInfo.swift | 10 +++++ .../SharedCoreKitTests.swift | 31 ++++++++++++++ 7 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 kmp/shared-core/spm/Package.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift diff --git a/.github/workflows/publish-shared-core.yml b/.github/workflows/publish-shared-core.yml index 55473eb703..aca60d2f44 100644 --- a/.github/workflows/publish-shared-core.yml +++ b/.github/workflows/publish-shared-core.yml @@ -67,6 +67,16 @@ jobs: echo 'COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000' } > ./local.properties + # The Swift half of the package lives here, next to the Kotlin it wraps, so the + # two move in one commit; the Swift Package repo is a publish target, not a place + # to edit. `Package.swift` has to be in place before Gradle runs, since KMMBridge + # only rewrites the variables block inside it. + - name: Stage the Swift package sources + run: | + set -euo pipefail + rm -rf spm-repo/Sources spm-repo/Tests + cp -R kmp/shared-core/spm/Package.swift kmp/shared-core/spm/Sources kmp/shared-core/spm/Tests spm-repo/ + - name: Build the XCFramework, upload it, and update Package.swift env: # Gradle reads ORG_GRADLE_PROJECT_-prefixed vars as project properties, @@ -78,6 +88,21 @@ jobs: -PsharedCoreVersion=${{ inputs.version }} \ -PspmRepoDir=$GITHUB_WORKSPACE/spm-repo + # The release asset is up by now but the tag still points at the old + # Package.swift, so nothing consumes this build until the next step. Compiling + # the Swift glue against the framework we just uploaded is the last moment a + # mismatch between the two is cheap to fix — after the tag moves, it's a + # published-and-broken version. + - name: Verify the package builds against the uploaded framework + working-directory: spm-repo + run: | + set -euo pipefail + xcodebuild -scheme SharedCore \ + -destination 'generic/platform=iOS Simulator' \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/spm-verify" \ + -quiet \ + build + - name: Commit and tag the Swift Package working-directory: spm-repo run: | @@ -85,9 +110,9 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Package.swift + git add -A Package.swift Sources Tests if git diff --cached --quiet; then - echo "Package.swift is unchanged — the upload produced the same URL and checksum." + echo "Nothing to commit — the upload produced the same URL and checksum, and the Swift sources are unchanged." exit 1 fi git commit -m "SharedCore ${{ inputs.version }}" diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index 8b39599dd4..641f31bca3 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -11,10 +11,10 @@ group = "com.flipcash" // release version in; the fallback only matters for local builds. version = findProperty("sharedCoreVersion") as String? ?: "0.1.0" -// Where `Package.swift` is written. CI points this at a checkout of -// `code-payments/flipcash-shared-core-spm`; locally it lands under the root -// build directory so `spmDevBuild` has somewhere to write without dirtying the -// repo. Left unset, KMMBridge would write it to this repo's root. +// Where the published `Package.swift` lives. CI points this at a checkout of +// `code-payments/flipcash-shared-core-spm` that already holds a copy of +// `spm/`; locally it falls back to the root build directory so a publish run +// can't dirty the repo. Left unset, KMMBridge would write it to this repo's root. val spmPackageDir = findProperty("spmRepoDir") as String? ?: rootProject.layout.buildDirectory.dir("spm").get().asFile.path @@ -59,7 +59,11 @@ kmmbridge { // `Package.swift` that references it is committed there. iOS then depends on // a small public repo instead of the whole Android app. gitHubReleaseArtifacts(repository = "code-payments/flipcash-shared-core-spm") - spm(spmDirectory = spmPackageDir, swiftToolVersion = "5.9") { + // `useCustomPackageFile` keeps `spm/Package.swift` — which adds the `SharedCoreKit` + // Swift target over the framework — and rewrites only the variables block inside it. + // The platform and tools version below are what KMMBridge would generate on its own; + // with a custom file it's `spm/Package.swift` that decides, so keep the two in step. + spm(spmDirectory = spmPackageDir, useCustomPackageFile = true, swiftToolVersion = "5.9") { iOS { v("15") } } } diff --git a/kmp/shared-core/spm/Package.swift b/kmp/shared-core/spm/Package.swift new file mode 100644 index 0000000000..bb4746055e --- /dev/null +++ b/kmp/shared-core/spm/Package.swift @@ -0,0 +1,42 @@ +// swift-tools-version:5.9 +import PackageDescription + +// The publish job rewrites this block — everything else in this file is ours. Note the +// tags are load-bearing: KMMBridge looks for them verbatim and fails the publish if +// they've drifted. +// BEGIN KMMBRIDGE VARIABLES BLOCK (do not edit) +let remoteKotlinUrl = "https://api.github.com/repos/code-payments/flipcash-shared-core-spm/releases/assets/524049263.zip" +let remoteKotlinChecksum = "b86241d770fa186e44eec4c9ff0ff092d54cf66329828485d243cb7b6cf588b5" +let packageName = "SharedCore" +// END KMMBRIDGE BLOCK + +let package = Package( + name: packageName, + platforms: [ + .iOS(.v15) + ], + products: [ + // The only product on purpose. Callers get Swift types; the Kotlin framework's + // own surface — `KotlinByteArray`, `.shared` singletons, no default arguments — + // stays behind this target. + .library( + name: "SharedCoreKit", + targets: ["SharedCoreKit"] + ), + ], + targets: [ + .binaryTarget( + name: packageName, + url: remoteKotlinUrl, + checksum: remoteKotlinChecksum + ), + .target( + name: "SharedCoreKit", + dependencies: [.target(name: packageName)] + ), + .testTarget( + name: "SharedCoreKitTests", + dependencies: ["SharedCoreKit"] + ), + ] +) diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift new file mode 100644 index 0000000000..55480c9159 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift @@ -0,0 +1,15 @@ +import Foundation +import SharedCore + +extension Data { + + /// Kotlin's `ByteArray` has no `Data` bridge of its own, so every exported function taking + /// bytes needs this copy. + var kotlinByteArray: KotlinByteArray { + let array = KotlinByteArray(size: Int32(count)) + for (offset, byte) in enumerated() { + array.set(index: Int32(offset), value: Int8(bitPattern: byte)) + } + return array + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift new file mode 100644 index 0000000000..87e2d62284 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode.swift @@ -0,0 +1,32 @@ +import Foundation +import SharedCore + +/// Renders scannable codes from the shared Kotlin implementation. +public enum KikCode { + + /// The export size used when none is given; SVG scales losslessly, so this only sets the + /// numbers in the document. + public static var defaultDimension: Double { KikCodeSvg.shared.DEFAULT_DIMENSION } + + /// Renders `payload` as a standalone SVG document, byte-for-byte identical to Android's. + /// + /// - Parameters: + /// - background: the surface color the code sits on, or `nil` for a transparent document. + /// Codes are light-on-dark, so a transparent export is invisible on light surfaces. + /// - includeBadge: whether to embed the logo in the middle well. + public static func svg( + payload: Data, + dimension: Double = KikCode.defaultDimension, + foreground: String = "#FFFFFF", + background: String? = nil, + includeBadge: Bool = true + ) -> String { + KikCodeSvg.shared.render( + payload: payload.kotlinByteArray, + dimension: dimension, + foreground: foreground, + background: background, + includeBadge: includeBadge + ) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift new file mode 100644 index 0000000000..d989a7ef37 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift @@ -0,0 +1,10 @@ +import SharedCore + +/// Identifies the Kotlin framework this package was built from. +public enum SharedCoreInfo { + + /// The `:kmp:shared-core` version the linked XCFramework was published at. + // Unqualified on purpose: inside this module the name `SharedCore` resolves to the + // Kotlin object, not the framework it lives in. + public static var version: String { SharedCore.shared.version } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift new file mode 100644 index 0000000000..ffc323f898 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/SharedCoreKitTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +@testable import SharedCoreKit + +@Suite struct SharedCoreKitTests { + + @Test func reachesTheKotlinFramework() { + #expect(!SharedCoreInfo.version.isEmpty) + } + + /// Kotlin bytes are signed, so a naive copy mangles anything above 0x7F. + @Test func highBitBytesSurviveTheByteArrayCopy() { + let payload = Data([0x00, 0x7F, 0x80, 0xFF] + Array(repeating: UInt8(0xAB), count: 31)) + + let array = payload.kotlinByteArray + + #expect(array.size == Int32(payload.count)) + for (offset, byte) in payload.enumerated() { + #expect(UInt8(bitPattern: array.get(index: Int32(offset))) == byte) + } + } + + @Test func rendersAnSvgDocument() { + let svg = KikCode.svg(payload: Data(repeating: 0xAB, count: 35), dimension: 512, background: "#000000") + + #expect(svg.hasPrefix("\n")) + } +}