Skip to content

Commit de08a67

Browse files
authored
feat(shared-core): wrap the framework in a Swift target (#1297)
* 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. * 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.
1 parent efa39b4 commit de08a67

8 files changed

Lines changed: 175 additions & 11 deletions

File tree

‎.github/workflows/publish-shared-core.yml‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ jobs:
6767
echo 'COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000'
6868
} > ./local.properties
6969
70+
# The Swift half of the package lives here, next to the Kotlin it wraps, so the
71+
# two move in one commit; the Swift Package repo is a publish target, not a place
72+
# to edit. `Package.swift` has to be in place before Gradle runs, since KMMBridge
73+
# only rewrites the variables block inside it.
74+
- name: Stage the Swift package sources
75+
run: |
76+
set -euo pipefail
77+
rm -rf spm-repo/Sources spm-repo/Tests
78+
cp -R kmp/shared-core/spm/Package.swift kmp/shared-core/spm/Sources kmp/shared-core/spm/Tests spm-repo/
79+
7080
- name: Build the XCFramework, upload it, and update Package.swift
7181
env:
7282
# Gradle reads ORG_GRADLE_PROJECT_-prefixed vars as project properties,
@@ -78,16 +88,31 @@ jobs:
7888
-PsharedCoreVersion=${{ inputs.version }} \
7989
-PspmRepoDir=$GITHUB_WORKSPACE/spm-repo
8090
91+
# The release asset is up by now but the tag still points at the old
92+
# Package.swift, so nothing consumes this build until the next step. Compiling
93+
# the Swift glue against the framework we just uploaded is the last moment a
94+
# mismatch between the two is cheap to fix — after the tag moves, it's a
95+
# published-and-broken version.
96+
- name: Verify the package builds against the uploaded framework
97+
working-directory: spm-repo
98+
run: |
99+
set -euo pipefail
100+
xcodebuild -scheme SharedCore \
101+
-destination 'generic/platform=iOS Simulator' \
102+
-clonedSourcePackagesDirPath "$RUNNER_TEMP/spm-verify" \
103+
-quiet \
104+
build
105+
81106
- name: Commit and tag the Swift Package
82107
working-directory: spm-repo
83108
run: |
84109
set -euo pipefail
85110
git config user.name "github-actions[bot]"
86111
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
87112
88-
git add Package.swift
113+
git add -A Package.swift Sources Tests
89114
if git diff --cached --quiet; then
90-
echo "Package.swift is unchanged — the upload produced the same URL and checksum."
115+
echo "Nothing to commit — the upload produced the same URL and checksum, and the Swift sources are unchanged."
91116
exit 1
92117
fi
93118
git commit -m "SharedCore ${{ inputs.version }}"

‎kmp/shared-core/build.gradle.kts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ group = "com.flipcash"
1111
// release version in; the fallback only matters for local builds.
1212
version = findProperty("sharedCoreVersion") as String? ?: "0.1.0"
1313

14-
// Where `Package.swift` is written. CI points this at a checkout of
15-
// `code-payments/flipcash-shared-core-spm`; locally it lands under the root
16-
// build directory so `spmDevBuild` has somewhere to write without dirtying the
17-
// repo. Left unset, KMMBridge would write it to this repo's root.
14+
// Where the published `Package.swift` lives. CI points this at a checkout of
15+
// `code-payments/flipcash-shared-core-spm` that already holds a copy of
16+
// `spm/`; locally it falls back to the root build directory so a publish run
17+
// can't dirty the repo. Left unset, KMMBridge would write it to this repo's root.
1818
val spmPackageDir = findProperty("spmRepoDir") as String?
1919
?: rootProject.layout.buildDirectory.dir("spm").get().asFile.path
2020

@@ -59,7 +59,11 @@ kmmbridge {
5959
// `Package.swift` that references it is committed there. iOS then depends on
6060
// a small public repo instead of the whole Android app.
6161
gitHubReleaseArtifacts(repository = "code-payments/flipcash-shared-core-spm")
62-
spm(spmDirectory = spmPackageDir, swiftToolVersion = "5.9") {
62+
// `useCustomPackageFile` keeps `spm/Package.swift` — which adds the `SharedCoreKit`
63+
// Swift target over the framework — and rewrites only the variables block inside it.
64+
// The platform and tools version below are what KMMBridge would generate on its own;
65+
// with a custom file it's `spm/Package.swift` that decides, so keep the two in step.
66+
spm(spmDirectory = spmPackageDir, useCustomPackageFile = true, swiftToolVersion = "5.9") {
6367
iOS { v("15") }
6468
}
6569
}

‎kmp/shared-core/spm/Package.swift‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// swift-tools-version:5.9
2+
import PackageDescription
3+
4+
// The publish job rewrites this block — everything else in this file is ours. Note the
5+
// tags are load-bearing: KMMBridge looks for them verbatim and fails the publish if
6+
// they've drifted.
7+
// BEGIN KMMBRIDGE VARIABLES BLOCK (do not edit)
8+
let remoteKotlinUrl = "https://api.github.com/repos/code-payments/flipcash-shared-core-spm/releases/assets/524049263.zip"
9+
let remoteKotlinChecksum = "b86241d770fa186e44eec4c9ff0ff092d54cf66329828485d243cb7b6cf588b5"
10+
let packageName = "SharedCore"
11+
// END KMMBRIDGE BLOCK
12+
13+
let package = Package(
14+
name: packageName,
15+
platforms: [
16+
.iOS(.v15)
17+
],
18+
products: [
19+
// The only product on purpose. Callers get Swift types; the Kotlin framework's
20+
// own surface — `KotlinByteArray`, `.shared` singletons, no default arguments —
21+
// stays behind this target.
22+
.library(
23+
name: "SharedCoreKit",
24+
targets: ["SharedCoreKit"]
25+
),
26+
],
27+
targets: [
28+
.binaryTarget(
29+
name: packageName,
30+
url: remoteKotlinUrl,
31+
checksum: remoteKotlinChecksum
32+
),
33+
.target(
34+
name: "SharedCoreKit",
35+
dependencies: [.target(name: packageName)]
36+
),
37+
.testTarget(
38+
name: "SharedCoreKitTests",
39+
dependencies: ["SharedCoreKit"]
40+
),
41+
]
42+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
import SharedCore
3+
4+
extension Data {
5+
6+
/// Kotlin's `ByteArray` has no `Data` bridge of its own, so every exported function taking
7+
/// bytes needs this copy.
8+
var kotlinByteArray: KotlinByteArray {
9+
let array = KotlinByteArray(size: Int32(count))
10+
for (offset, byte) in enumerated() {
11+
array.set(index: Int32(offset), value: Int8(bitPattern: byte))
12+
}
13+
return array
14+
}
15+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Foundation
2+
import SharedCore
3+
4+
/// Renders scannable codes from the shared Kotlin implementation.
5+
public enum KikCode {
6+
7+
/// The export size used when none is given; SVG scales losslessly, so this only sets the
8+
/// numbers in the document.
9+
public static var defaultDimension: Double { KikCodeSvg.shared.DEFAULT_DIMENSION }
10+
11+
/// Renders `payload` as a standalone SVG document, byte-for-byte identical to Android's.
12+
///
13+
/// - Parameters:
14+
/// - background: the surface color the code sits on, or `nil` for a transparent document.
15+
/// Codes are light-on-dark, so a transparent export is invisible on light surfaces.
16+
/// - includeBadge: whether to embed the logo in the middle well.
17+
public static func svg(
18+
payload: Data,
19+
dimension: Double = KikCode.defaultDimension,
20+
foreground: String = "#FFFFFF",
21+
background: String? = nil,
22+
includeBadge: Bool = true
23+
) -> String {
24+
KikCodeSvg.shared.render(
25+
payload: payload.kotlinByteArray,
26+
dimension: dimension,
27+
foreground: foreground,
28+
background: background,
29+
includeBadge: includeBadge
30+
)
31+
}
32+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SharedCore
2+
3+
/// Identifies the Kotlin framework this package was built from.
4+
public enum SharedCoreInfo {
5+
6+
/// The `:kmp:shared-core` version the linked XCFramework was published at.
7+
// Unqualified on purpose: inside this module the name `SharedCore` resolves to the
8+
// Kotlin object, not the framework it lives in.
9+
public static var version: String { SharedCore.shared.version }
10+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Foundation
2+
import Testing
3+
@testable import SharedCoreKit
4+
5+
@Suite struct SharedCoreKitTests {
6+
7+
@Test func reachesTheKotlinFramework() {
8+
#expect(!SharedCoreInfo.version.isEmpty)
9+
}
10+
11+
/// Kotlin bytes are signed, so a naive copy mangles anything above 0x7F.
12+
@Test func highBitBytesSurviveTheByteArrayCopy() {
13+
let payload = Data([0x00, 0x7F, 0x80, 0xFF] + Array(repeating: UInt8(0xAB), count: 31))
14+
15+
let array = payload.kotlinByteArray
16+
17+
#expect(array.size == Int32(payload.count))
18+
for (offset, byte) in payload.enumerated() {
19+
#expect(UInt8(bitPattern: array.get(index: Int32(offset))) == byte)
20+
}
21+
}
22+
23+
@Test func rendersAnSvgDocument() {
24+
let svg = KikCode.svg(payload: Data(repeating: 0xAB, count: 35), dimension: 512, background: "#000000")
25+
26+
#expect(svg.hasPrefix("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"512\""))
27+
#expect(svg.contains("<circle"))
28+
#expect(svg.contains("#000000"))
29+
#expect(svg.hasSuffix("</svg>\n"))
30+
}
31+
}

‎libs/encryption/ed25519/build.gradle.kts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ kotlin {
8585
compilations["main"].cinterops.create("ed25519") {
8686
definitionFile = file("cinterop/ed25519.def")
8787
includeDirs(ed25519SrcDir)
88+
// Embed the archive in the klib rather than leaving it to each binary's
89+
// linker flags: a static framework built from this module ships the C
90+
// objects inside it, so consumers link one artifact and nothing else.
91+
// Without this the framework exports `ed25519_*` as undefined symbols and
92+
// only links inside an app that happens to compile the same C itself.
93+
extraOpts(
94+
"-staticLibrary", "libored25519.a",
95+
"-libraryPath", libDir.get().asFile.absolutePath,
96+
)
8897
}
8998

9099
// Both the cinterop binding task and the Kotlin compile task need the
@@ -95,10 +104,6 @@ kotlin {
95104
tasks.matching { it.name == "compileKotlin${name.replaceFirstChar { it.uppercaseChar() }}" }.configureEach {
96105
dependsOn(compileTaskName)
97106
}
98-
99-
binaries.configureEach {
100-
linkerOpts("-L${libDir.get().asFile.absolutePath}", "-lored25519")
101-
}
102107
}
103108

104109
sourceSets {

0 commit comments

Comments
 (0)