├── .github
└── workflows
│ ├── ci.yaml
│ └── pre-release.yaml
├── .gitignore
├── .gitmodules
├── .spi.yml
├── .vscode
└── settings.json
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── Loro
│ ├── Container.swift
│ ├── Ephemeral.swift
│ ├── Event.swift
│ ├── Loro.swift
│ ├── LoroFFI.swift
│ ├── Value.swift
│ └── Version.swift
├── Tests
└── LoroTests
│ ├── EphemeralStoreTests.swift
│ └── LoroTests.swift
└── scripts
├── build_macos.sh
├── build_swift_ffi.sh
└── refine_trait.sh
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: build-test
2 | on:
3 | pull_request:
4 | branches: [main]
5 |
6 | jobs:
7 | build-test:
8 | runs-on: macos-14
9 | env:
10 | LOCAL_BUILD: true
11 | DEVELOPER_DIR: /Applications/Xcode_15.4.app
12 | steps:
13 | - uses: actions/checkout@v3
14 | with:
15 | submodules: recursive
16 | - uses: actions-rs/toolchain@v1
17 | with:
18 | profile: minimal
19 | toolchain: 1.82.0
20 | default: true
21 | - name: get xcode information
22 | run: |
23 | xcodebuild -version
24 | swift --version
25 | - name: build xcframework
26 | run: ./scripts/build_macos.sh
27 | - name: Swift tests
28 | run: LOCAL_BUILD=true swift test
29 |
--------------------------------------------------------------------------------
/.github/workflows/pre-release.yaml:
--------------------------------------------------------------------------------
1 | name: Pre-release Build and Publish
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*-pre-release'
7 |
8 | jobs:
9 | build-and-release:
10 | runs-on: macos-latest
11 |
12 | steps:
13 | - name: Checkout repository
14 | uses: actions/checkout@v4
15 | with:
16 | submodules: recursive
17 | token: ${{ secrets.GITHUB_TOKEN }}
18 |
19 | - name: Extract version from tag
20 | id: get_version
21 | run: |
22 | TAG_NAME=${GITHUB_REF#refs/tags/}
23 | VERSION=${TAG_NAME%-pre-release}
24 | echo "version=$VERSION" >> $GITHUB_OUTPUT
25 |
26 | - name: Build Swift FFI
27 | run: |
28 | chmod +x scripts/build_swift_ffi.sh
29 | ./scripts/build_swift_ffi.sh
30 |
31 | - name: Calculate SHA256
32 | id: sha256
33 | run: |
34 | CHECKSUM=$(openssl dgst -sha256 loroFFI.xcframework.zip | awk '{print $2}')
35 | echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
36 |
37 | - name: Update Package.swift
38 | run: |
39 | VERSION=${{ steps.get_version.outputs.version }}
40 | CHECKSUM=${{ steps.sha256.outputs.checksum }}
41 | sed -i '' \
42 | -e "s|url: \"https://github.com/.*/loroFFI.xcframework.zip\"|url: \"https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/loroFFI.xcframework.zip\"|" \
43 | -e "s|checksum: \"[a-f0-9]*\"|checksum: \"${CHECKSUM}\"|" \
44 | Package.swift
45 |
46 | - name: Update README.md
47 | run: |
48 | VERSION=${{ steps.get_version.outputs.version }}
49 | sed -i '' \
50 | -e "s|\"https://github.com/loro-dev/loro-swift.git\", from: \"[0-9.]*\"|\"https://github.com/loro-dev/loro-swift.git\", from: \"${VERSION}\"|" \
51 | README.md
52 |
53 | - name: Commit and push changes
54 | run: |
55 | VERSION=${{ steps.get_version.outputs.version }}
56 | git config --local user.email "github-actions[bot]@users.noreply.github.com"
57 | git config --local user.name "github-actions[bot]"
58 | git add Package.swift README.md
59 | git commit -m "chore: update version to ${VERSION}"
60 | git tag -a "${VERSION}" -m "Release version ${VERSION}"
61 | git push origin HEAD:main
62 | git push origin "${VERSION}"
63 |
64 | - name: Create Release
65 | uses: softprops/action-gh-release@v1
66 | with:
67 | tag_name: ${{ steps.get_version.outputs.version }}
68 | name: Release ${{ steps.get_version.outputs.version }}
69 | files: loroFFI.xcframework.zip
70 | draft: false
71 | prerelease: false
72 | generate_release_notes: true
73 | env:
74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
75 |
76 | - name: Delete pre-release tag
77 | run: |
78 | VERSION=${{ steps.get_version.outputs.version }}
79 | git push origin :refs/tags/${VERSION}-pre-release
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 | /gen-swift
10 | loroFFI.xcframework
11 | loroFFI.xcframework.zip
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "loro-ffi"]
2 | path = loro-ffi
3 | url = https://github.com/loro-dev/loro-ffi.git
4 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: ["Loro"]
5 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": ["oplog", "uniffi", "xcframework", "Zhao"]
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Loro
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import Foundation
5 | import PackageDescription
6 |
7 | let FFIbinaryTarget: PackageDescription.Target
8 |
9 | if ProcessInfo.processInfo.environment["LOCAL_BUILD"] != nil {
10 | FFIbinaryTarget = .binaryTarget(name: "LoroFFI", path: "./loroFFI.xcframework.zip")
11 | }else {
12 | FFIbinaryTarget = .binaryTarget(
13 | name: "LoroFFI",
14 | url: "https://github.com/loro-dev/loro-swift/releases/download/1.5.1/loroFFI.xcframework.zip",
15 | checksum: "a0617a8cb1beec5704849223a489d68b086be9a372affc3beb46a6c59a0892d1"
16 | )
17 | }
18 |
19 | let package = Package(
20 | name: "Loro",
21 | platforms: [
22 | .iOS(.v13),
23 | .macOS(.v10_15),
24 | .visionOS(.v1)
25 | ],
26 | products: [
27 | // Products define the executables and libraries a package produces, making them visible to other packages.
28 | .library(
29 | name: "Loro",
30 | targets: ["Loro"]),
31 | ],
32 | targets: [
33 | // Targets are the basic building blocks of a package, defining a module or a test suite.
34 | // Targets can depend on other targets in this package and products from dependencies.
35 | FFIbinaryTarget,
36 | .target(
37 | name: "Loro",
38 | dependencies: ["LoroFFI"]
39 | ),
40 | .testTarget(
41 | name: "LoroTests",
42 | dependencies: ["Loro"]),
43 | ]
44 | )
45 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://swiftpackageindex.com/loro-dev/loro-swift)
2 | [](https://swiftpackageindex.com/loro-dev/loro-swift)
3 |
4 |
loro-swift
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | This repository contains experimental Swift bindings for
16 | [Loro CRDT](https://github.com/loro-dev/loro).
17 |
18 | If you have any suggestions for API, please feel free to create an issue or join
19 | our [Discord](https://discord.gg/tUsBSVfqzf) community.
20 |
21 |
22 | ## Usage
23 |
24 | Add the dependency in your `Package.swift`.
25 |
26 | ```swift
27 | let package = Package(
28 | name: "your-project",
29 | products: [......],
30 | dependencies:[
31 | ...,
32 | .package(url: "https://github.com/loro-dev/loro-swift.git", from: "1.5.1")
33 | ],
34 | targets:[
35 | .executableTarget(
36 | ...,
37 | dependencies:[.product(name: "Loro", package: "loro-swift")],
38 | )
39 | ]
40 | )
41 | ```
42 |
43 | ## Examples
44 |
45 | ```swift
46 | import Loro
47 |
48 | // create a Loro document
49 | let doc = LoroDoc()
50 |
51 | // create Root Container by getText, getList, getMap, getTree, getMovableList, getCounter
52 | let text = doc.getText(id: "text")
53 |
54 | try! text.insert(pos: 0, s: "abc")
55 | try! text.delete(pos: 0, len: 1)
56 | let s = text.toString()
57 | // XCTAssertEqual(s, "bc")
58 |
59 | // subscribe the event
60 | let sub = doc.subscribeRoot{ diffEvent in
61 | print(diffEvent)
62 | }
63 |
64 | // export updates or snapshot
65 | let doc2 = LoroDoc()
66 | let snapshot = doc.export(mode: ExportMode.snapshot)
67 | let updates = doc.export(mode: ExportMode.updates(from: VersionVector()))
68 |
69 | // import updates or snapshot
70 | let status = try! doc2.import(snapshot)
71 | let status2 = try! doc2.import(updates)
72 | // import batch of updates or snapshot
73 | try! doc2.importBatch(bytes: [snapshot, updates])
74 |
75 | // checkout to any version
76 | let startFrontiers = doc.oplogFrontiers()
77 | try! doc.checkout(frontiers: startFrontiers)
78 | doc.checkoutToLatest()
79 | ```
80 |
81 | ## Develop
82 |
83 | If you wanna build and develop this project with MacOS, you need first run this
84 | script:
85 |
86 | ```bash
87 | sh ./scripts/build_macos.sh
88 | LOCAL_BUILD=1 swift test
89 | ```
90 |
91 | The script will run `uniffi` and generate the `loroFFI.xcframework.zip`.
92 |
93 | # Credits
94 | - [uniffi-rs](https://github.com/mozilla/uniffi-rs): a multi-language bindings generator for rust
95 | - [Automerge-swift](https://github.com/automerge/automerge-swift): `loro-swift`
96 | uses many of `automerge-swift`'s scripts for building and CI.
97 |
--------------------------------------------------------------------------------
/Sources/Loro/Container.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Container.swift
3 | //
4 | //
5 | // Created by Leon Zhao on 2024/8/6.
6 | //
7 |
8 |
9 | extension String: ContainerIdLike{
10 | public func asContainerId(ty: ContainerType) -> ContainerId{
11 | return ContainerId.root(name: self, containerType: ty)
12 | }
13 | }
14 |
15 | extension ContainerId: ContainerIdLike{
16 | public func asContainerId(ty: ContainerType) -> ContainerId{
17 | return self
18 | }
19 | }
20 |
21 | public protocol ContainerLike: LoroValueLike{
22 | func id()->ContainerId;
23 | }
24 |
25 | extension ContainerLike{
26 | public func asLoroValue()->LoroValue{
27 | return LoroValue.container(value: self.id())
28 | }
29 | }
30 |
31 | extension LoroText: ContainerLike{}
32 | extension LoroMap: ContainerLike{
33 | public func insertContainer(key: String, child: T) throws -> T {
34 | let result: ContainerLike
35 | if let list = child as? LoroList{
36 | result = try self.insertListContainer(key: key, child: list)
37 | }else if let map = child as? LoroMap{
38 | result = try self.insertMapContainer(key: key, child: map)
39 | }else if let text = child as? LoroText{
40 | result = try self.insertTextContainer(key: key, child: text)
41 | }else if let tree = child as? LoroTree{
42 | result = try self.insertTreeContainer(key: key, child: tree)
43 | }else if let list = child as? LoroMovableList{
44 | result = try self.insertMovableListContainer(key: key, child: list)
45 | }else if let counter = child as? LoroCounter{
46 | result = try self.insertCounterContainer(key: key, child: counter)
47 | }else{
48 | fatalError()
49 | }
50 | guard let typedResult = result as? T else {
51 | fatalError("Type mismatch: expected \(T.self), got \(type(of: result))")
52 | }
53 | return typedResult
54 | }
55 |
56 | public func getOrCreateContainer(key: String, child: T) throws -> T {
57 | let result: ContainerLike
58 | if let list = child as? LoroList{
59 | result = try self.getOrCreateListContainer(key: key, child: list)
60 | }else if let map = child as? LoroMap{
61 | result = try self.getOrCreateMapContainer(key: key, child: map)
62 | }else if let text = child as? LoroText{
63 | result = try self.getOrCreateTextContainer(key: key, child: text)
64 | }else if let tree = child as? LoroTree{
65 | result = try self.getOrCreateTreeContainer(key: key, child: tree)
66 | }else if let list = child as? LoroMovableList{
67 | result = try self.getOrCreateMovableListContainer(key: key, child: list)
68 | }else if let counter = child as? LoroCounter{
69 | result = try self.getOrCreateCounterContainer(key: key, child: counter)
70 | }else{
71 | fatalError()
72 | }
73 | guard let typedResult = result as? T else {
74 | fatalError("Type mismatch: expected \(T.self), got \(type(of: result))")
75 | }
76 | return typedResult
77 | }
78 | }
79 | extension LoroTree: ContainerLike{}
80 | extension LoroMovableList: ContainerLike{
81 | public func pushContainer(child: T) throws -> T{
82 | let idx = self.len()
83 | return try self.insertContainer(pos: idx, child: child)
84 | }
85 |
86 | public func insertContainer(pos: UInt32, child: T) throws -> T {
87 | let result: ContainerLike
88 | if let list = child as? LoroList{
89 | result = try self.insertListContainer(pos: pos, child: list)
90 | }else if let map = child as? LoroMap{
91 | result = try self.insertMapContainer(pos: pos, child: map)
92 | }else if let text = child as? LoroText{
93 | result = try self.insertTextContainer(pos: pos, child: text)
94 | }else if let tree = child as? LoroTree{
95 | result = try self.insertTreeContainer(pos: pos, child: tree)
96 | }else if let list = child as? LoroMovableList{
97 | result = try self.insertMovableListContainer(pos: pos, child: list)
98 | }else if let counter = child as? LoroCounter{
99 | result = try self.insertCounterContainer(pos: pos, child: counter)
100 | }else{
101 | fatalError()
102 | }
103 | guard let typedResult = result as? T else {
104 | fatalError("Type mismatch: expected \(T.self), got \(type(of: result))")
105 | }
106 | return typedResult
107 | }
108 |
109 | public func setContainer(pos: UInt32, child: T) throws -> T{
110 | let result: ContainerLike
111 | if let list = child as? LoroList{
112 | result = try self.setListContainer(pos: pos, child: list)
113 | }else if let map = child as? LoroMap{
114 | result = try self.setMapContainer(pos: pos, child: map)
115 | }else if let text = child as? LoroText{
116 | result = try self.setTextContainer(pos: pos, child: text)
117 | }else if let tree = child as? LoroTree{
118 | result = try self.setTreeContainer(pos: pos, child: tree)
119 | }else if let list = child as? LoroMovableList{
120 | result = try self.setMovableListContainer(pos: pos, child: list)
121 | }else if let counter = child as? LoroCounter{
122 | result = try self.setCounterContainer(pos: pos, child: counter)
123 | }else{
124 | fatalError()
125 | }
126 | guard let typedResult = result as? T else {
127 | fatalError("Type mismatch: expected \(T.self), got \(type(of: result))")
128 | }
129 | return typedResult
130 | }
131 | }
132 |
133 |
134 | extension LoroList: ContainerLike{
135 | public func pushContainer(child: T) throws -> T{
136 | let idx = self.len()
137 | return try self.insertContainer(pos: idx, child: child)
138 | }
139 |
140 | public func insertContainer(pos: UInt32, child: T) throws -> T {
141 | let result: ContainerLike
142 | if let list = child as? LoroList{
143 | result = try self.insertListContainer(pos: pos, child: list)
144 | }else if let map = child as? LoroMap{
145 | result = try self.insertMapContainer(pos: pos, child: map)
146 | }else if let text = child as? LoroText{
147 | result = try self.insertTextContainer(pos: pos, child: text)
148 | }else if let tree = child as? LoroTree{
149 | result = try self.insertTreeContainer(pos: pos, child: tree)
150 | }else if let list = child as? LoroMovableList{
151 | result = try self.insertMovableListContainer(pos: pos, child: list)
152 | }else if let counter = child as? LoroCounter{
153 | result = try self.insertCounterContainer(pos: pos, child: counter)
154 | }else{
155 | fatalError()
156 | }
157 | guard let typedResult = result as? T else {
158 | fatalError("Type mismatch: expected \(T.self), got \(type(of: result))")
159 | }
160 | return typedResult
161 | }
162 | }
163 |
164 | extension LoroCounter: ContainerLike{}
165 | extension LoroUnknown: ContainerLike{}
166 |
167 | // Extension for handling nil input
168 | // Although we extend Optional, we still need to specify the type explicitly
169 | // e.g. `nil as String?`. This is not convenient in some scenarios.
170 | extension LoroList{
171 | public func insert(pos: UInt32, v: LoroValueLike?) throws {
172 | try self.insert(pos: pos, v: v?.asLoroValue() ?? .null)
173 | }
174 |
175 | public func push(v: LoroValueLike?) throws {
176 | try self.push(v: v?.asLoroValue() ?? .null)
177 | }
178 | }
179 |
180 | extension LoroMap{
181 | public func insert(key: String, v: LoroValueLike?) throws {
182 | try self.insert(key: key, v: v?.asLoroValue() ?? .null)
183 | }
184 | }
185 |
186 | extension LoroMovableList{
187 | public func insert(pos: UInt32, v: LoroValueLike?) throws {
188 | try self.insert(pos: pos, v: v?.asLoroValue() ?? .null)
189 | }
190 |
191 | public func push(v: LoroValueLike?) throws {
192 | try self.push(v: v?.asLoroValue() ?? .null)
193 | }
194 |
195 | public func set(pos: UInt32, v: LoroValueLike?) throws {
196 | try self.set(pos: pos, value: v?.asLoroValue() ?? .null)
197 | }
198 | }
199 |
200 | extension LoroText{
201 | public func mark(from: UInt32, to: UInt32, key: String, value: LoroValueLike?) throws {
202 | try self.mark(from: from, to: to, key: key, value: value?.asLoroValue() ?? .null)
203 | }
204 | }
205 |
206 | extension Awareness{
207 | public func setLocalState(value: LoroValueLike?){
208 | self.setLocalState(value: value?.asLoroValue() ?? .null)
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/Sources/Loro/Ephemeral.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Ephemeral.swift
3 | //
4 | //
5 | // Created by Leon Zhao on 2025/6/4.
6 | //
7 |
8 | import Foundation
9 |
10 | class ClosureEphemeralSubscriber: EphemeralSubscriber {
11 | private let closure: (EphemeralStoreEvent) -> Void
12 |
13 | public init(closure: @escaping (EphemeralStoreEvent) -> Void) {
14 | self.closure = closure
15 | }
16 |
17 | public func onEphemeralEvent(event: EphemeralStoreEvent) {
18 | closure(event)
19 | }
20 | }
21 |
22 | class ClosureLocalEphemeralListener:LocalEphemeralListener{
23 |
24 | private let closure: (Data) -> Void
25 |
26 | public init(closure: @escaping (Data) -> Void) {
27 | self.closure = closure
28 | }
29 |
30 | public func onEphemeralUpdate(update: Data) {
31 | closure(update)
32 | }
33 | }
34 |
35 | extension EphemeralStore{
36 | public func subscribe(cb: @escaping (EphemeralStoreEvent) -> Void) -> Subscription{
37 | let closureSubscriber = ClosureEphemeralSubscriber(closure: cb)
38 | return self.subscribe(listener: closureSubscriber)
39 | }
40 |
41 | public func subscribeLocalUpdate(cb: @escaping (Data) -> Void) -> Subscription{
42 | let closureListener = ClosureLocalEphemeralListener(closure: cb)
43 | return self.subscribeLocalUpdate(listener: closureListener)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/Loro/Event.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class ClosureSubscriber: Subscriber {
4 | private let closure: (DiffEvent) -> Void
5 |
6 | public init(closure: @escaping (DiffEvent) -> Void) {
7 | self.closure = closure
8 | }
9 |
10 | public func onDiff(diff: DiffEvent) {
11 | closure(diff)
12 | }
13 | }
14 |
15 | class ClosureLocalUpdate: LocalUpdateCallback{
16 | private let closure: (Data) -> Void
17 |
18 | public init(closure: @escaping (Data) -> Void) {
19 | self.closure = closure
20 | }
21 |
22 | public func onLocalUpdate(update: Data) {
23 | closure(update)
24 | }
25 | }
26 |
27 | extension LoroDoc{
28 | /** Subscribe all the events.
29 | *
30 | * The callback will be invoked when any part of the [DocState] is changed.
31 | * Returns a subscription id that can be used to unsubscribe.
32 | */
33 | public func subscribeRoot(callback: @escaping (DiffEvent)->Void) -> Subscription {
34 | let closureSubscriber = ClosureSubscriber(closure: callback)
35 | return self.subscribeRoot(subscriber: closureSubscriber)
36 | }
37 |
38 | /** Subscribe the events of a container.
39 | *
40 | * The callback will be invoked when the container is changed.
41 | * Returns a subscription id that can be used to unsubscribe.
42 | *
43 | * The events will be emitted after a transaction is committed. A transaction is committed when:
44 | * - `doc.commit()` is called.
45 | * - `doc.exportFrom(version)` is called.
46 | * - `doc.import(data)` is called.
47 | * - `doc.checkout(version)` is called.
48 | */
49 | public func subscribe(containerId: ContainerId, callback: @escaping (DiffEvent)->Void) -> Subscription {
50 | let closureSubscriber = ClosureSubscriber(closure: callback)
51 | return self.subscribe(containerId: containerId, subscriber: closureSubscriber)
52 | }
53 |
54 | /**
55 | * Subscribe the local update of the document.
56 | */
57 | public func subscribeLocalUpdate(callback: @escaping (Data)->Void)->Subscription{
58 | let closureLocalUpdate = ClosureLocalUpdate(closure: callback)
59 | return self.subscribeLocalUpdate(callback: closureLocalUpdate)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Sources/Loro/Loro.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Loro.swift
3 | //
4 | //
5 | // Created by Leon Zhao on 2024/8/6.
6 | //
7 |
8 |
9 |
10 | import Foundation
11 |
12 | public enum ExportMode {
13 | case snapshot
14 | case updates(from: VersionVector)
15 | case updatesInRange(spans: [IdSpan])
16 | case shallowSnapshot(Frontiers)
17 | case stateOnly(Frontiers?)
18 | case snapshotAt(version: Frontiers)
19 | }
20 |
21 | extension LoroDoc{
22 | public func export(mode: ExportMode) throws -> Data{
23 | switch mode {
24 | case .snapshot:
25 | return try self.exportSnapshot()
26 | case .updates(let from):
27 | return try self.exportUpdates(vv: from)
28 | case .updatesInRange(let spans):
29 | return try self.exportUpdatesInRange(spans: spans)
30 | case .shallowSnapshot(let frontiers):
31 | return try self.exportShallowSnapshot(frontiers: frontiers)
32 | case .stateOnly(let frontiers):
33 | return try self.exportStateOnly(frontiers: frontiers)
34 | case .snapshotAt(let frontiers):
35 | return try self.exportSnapshotAt(frontiers: frontiers)
36 | }
37 | }
38 |
39 | public func travelChangeAncestors(ids: [Id], f: @escaping (ChangeMeta)->Bool) throws {
40 | let closureSubscriber = ChangeAncestorsTravel(closure: f)
41 | try self.travelChangeAncestors(ids: ids, f: closureSubscriber)
42 | }
43 | }
44 |
45 |
46 | class ClosureOnPush: OnPush {
47 | private let closure: (UndoOrRedo, CounterSpan, DiffEvent?) ->UndoItemMeta
48 |
49 | public init(closure: @escaping (UndoOrRedo, CounterSpan, DiffEvent?) ->UndoItemMeta) {
50 | self.closure = closure
51 | }
52 |
53 | public func onPush(undoOrRedo: UndoOrRedo, span: CounterSpan, diffEvent: DiffEvent?) -> UndoItemMeta{
54 | closure(undoOrRedo, span, diffEvent)
55 | }
56 | }
57 |
58 | class ClosureOnPop: OnPop {
59 | private let closure: (UndoOrRedo, CounterSpan,UndoItemMeta)->Void
60 |
61 | public init(closure: @escaping (UndoOrRedo, CounterSpan,UndoItemMeta)->Void) {
62 | self.closure = closure
63 | }
64 |
65 | public func onPop(undoOrRedo: UndoOrRedo, span: CounterSpan, undoMeta: UndoItemMeta) {
66 | closure(undoOrRedo, span, undoMeta)
67 | }
68 | }
69 |
70 | extension UndoManager{
71 | public func setOnPush(callback: ((UndoOrRedo, CounterSpan, DiffEvent?) ->UndoItemMeta)?){
72 | if let onPush = callback{
73 | let closureOnPush = ClosureOnPush(closure: onPush)
74 | self.setOnPush(onPush: closureOnPush)
75 | }else{
76 | self.setOnPush(onPush: nil)
77 | }
78 | }
79 |
80 | public func setOnPop(callback: ( (UndoOrRedo, CounterSpan,UndoItemMeta)->Void)?){
81 | if let onPop = callback{
82 | let closureOnPop = ClosureOnPop(closure: onPop)
83 | self.setOnPop(onPop: closureOnPop)
84 | }else{
85 | self.setOnPop(onPop: nil)
86 | }
87 | }
88 | }
89 |
90 |
91 | class ChangeAncestorsTravel: ChangeAncestorsTraveler{
92 | private let closure: (ChangeMeta)->Bool
93 |
94 | public init(closure: @escaping (ChangeMeta)->Bool) {
95 | self.closure = closure
96 | }
97 |
98 | func travel(change: ChangeMeta) -> Bool {
99 | closure(change)
100 | }
101 | }
102 |
103 |
--------------------------------------------------------------------------------
/Sources/Loro/Value.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Value.swift
3 | //
4 | //
5 | // Created by Leon Zhao on 2024/8/6.
6 | //
7 | import Foundation
8 |
9 | extension LoroValue: LoroValueLike {
10 | public func asLoroValue() -> LoroValue {
11 | return self
12 | }
13 | }
14 |
15 | extension Optional: LoroValueLike where Wrapped: LoroValueLike {
16 | public func asLoroValue() -> LoroValue {
17 | if let value = self {
18 | return value.asLoroValue()
19 | } else {
20 | return .null
21 | }
22 | }
23 | }
24 |
25 | extension Bool: LoroValueLike{
26 | public func asLoroValue() -> LoroValue {
27 | return LoroValue.bool(value: self)
28 | }
29 | }
30 |
31 | extension Float:LoroValueLike{
32 | public func asLoroValue() -> LoroValue {
33 | return LoroValue.double(value: Float64(self))
34 | }
35 | }
36 |
37 | extension Double:LoroValueLike{
38 | public func asLoroValue() -> LoroValue {
39 | return LoroValue.double(value: self)
40 | }
41 | }
42 |
43 | extension UInt8:LoroValueLike{
44 | public func asLoroValue() -> LoroValue {
45 | return LoroValue.i64(value: Int64(self))
46 | }
47 | }
48 |
49 |
50 | extension UInt16:LoroValueLike{
51 | public func asLoroValue() -> LoroValue {
52 | return LoroValue.i64(value: Int64(self))
53 | }
54 | }
55 |
56 | extension UInt32:LoroValueLike{
57 | public func asLoroValue() -> LoroValue {
58 | return LoroValue.i64(value: Int64(self))
59 | }
60 | }
61 |
62 | extension UInt64:LoroValueLike{
63 | public func asLoroValue() -> LoroValue {
64 | return LoroValue.i64(value: Int64(self))
65 | }
66 | }
67 |
68 | extension UInt:LoroValueLike{
69 | public func asLoroValue() -> LoroValue {
70 | return LoroValue.i64(value: Int64(self))
71 | }
72 | }
73 |
74 | extension Int:LoroValueLike{
75 | public func asLoroValue() -> LoroValue {
76 | return LoroValue.i64(value: Int64(self))
77 | }
78 | }
79 |
80 | extension Int8:LoroValueLike{
81 | public func asLoroValue() -> LoroValue {
82 | return LoroValue.i64(value: Int64(self))
83 | }
84 | }
85 |
86 | extension Int16:LoroValueLike{
87 | public func asLoroValue() -> LoroValue {
88 | return LoroValue.i64(value: Int64(self))
89 | }
90 | }
91 |
92 | extension Int32:LoroValueLike{
93 | public func asLoroValue() -> LoroValue {
94 | return LoroValue.i64(value: Int64(self))
95 | }
96 | }
97 |
98 | extension Int64:LoroValueLike{
99 | public func asLoroValue() -> LoroValue {
100 | return LoroValue.i64(value: Int64(self))
101 | }
102 | }
103 |
104 | extension String:LoroValueLike{
105 | public func asLoroValue() -> LoroValue {
106 | return LoroValue.string(value: self)
107 | }
108 | }
109 |
110 | extension Array: LoroValueLike where Element:LoroValueLike{
111 | public func asLoroValue() -> LoroValue {
112 | if let uint8Array = self as? [UInt8] {
113 | return LoroValue.binary(value: Data(uint8Array))
114 | } else {
115 | let loroValues = self.map { $0.asLoroValue() }
116 | return LoroValue.list(value: loroValues)
117 | }
118 | }
119 | }
120 |
121 |
122 | extension Dictionary: LoroValueLike where Key == String, Value:LoroValueLike{
123 | public func asLoroValue() -> LoroValue {
124 | let mapValues = self.mapValues{ $0.asLoroValue() }
125 | return LoroValue.map(value: mapValues)
126 | }
127 | }
128 |
129 | extension ContainerId:LoroValueLike{
130 | public func asLoroValue() -> LoroValue {
131 | return LoroValue.container(value: self)
132 | }
133 | }
134 |
135 | extension Data: LoroValueLike{
136 | public func asLoroValue() -> LoroValue {
137 | return LoroValue.binary(value: self)
138 | }
139 | }
--------------------------------------------------------------------------------
/Sources/Loro/Version.swift:
--------------------------------------------------------------------------------
1 | extension VersionVector: Equatable{
2 | static public func == (lhs: VersionVector, rhs: VersionVector) -> Bool {
3 | return lhs.eq(other: rhs)
4 | }
5 | }
6 |
7 | extension Frontiers: Equatable{
8 | static public func == (lhs: Frontiers, rhs: Frontiers) -> Bool {
9 | return lhs.eq(other: rhs)
10 | }
11 | }
--------------------------------------------------------------------------------
/Tests/LoroTests/EphemeralStoreTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Loro
3 |
4 | final class EphemeralStoreTests: XCTestCase {
5 |
6 | func testBasicSetAndGet() {
7 | let store = EphemeralStore(timeout: 60000)
8 |
9 | // Test basic set and get
10 | store.set(key: "key1", value: "value1")
11 | store.set(key: "key2", value: 42)
12 | store.set(key: "key3", value: true)
13 |
14 | XCTAssertEqual(store.get(key: "key1"), LoroValue.string(value: "value1"))
15 | XCTAssertEqual(store.get(key: "key2"), LoroValue.i64(value: Int64(42)))
16 | XCTAssertEqual(store.get(key: "key3"), LoroValue.bool(value: true))
17 | XCTAssertNil(store.get(key: "nonexistent"))
18 | }
19 |
20 | func testKeys() {
21 | let store = EphemeralStore(timeout: 60000)
22 |
23 | // Initial state should have no keys
24 | XCTAssertEqual(store.keys().count, 0)
25 |
26 | // Add some keys
27 | store.set(key: "key1", value: "value1")
28 | store.set(key: "key2", value: "value2")
29 | store.set(key: "key3", value: "value3")
30 |
31 | let keys = store.keys()
32 | XCTAssertEqual(keys.count, 3)
33 | XCTAssertTrue(keys.contains("key1"))
34 | XCTAssertTrue(keys.contains("key2"))
35 | XCTAssertTrue(keys.contains("key3"))
36 | }
37 |
38 | func testGetAllStates() {
39 | let store = EphemeralStore(timeout: 60000)
40 |
41 | store.set(key: "key1", value: "value1")
42 | store.set(key: "key2", value: 42)
43 |
44 | let allStates = store.getAllStates()
45 | XCTAssertEqual(allStates.count, 2)
46 | XCTAssertEqual(allStates["key1"], LoroValue.string(value: "value1"))
47 | XCTAssertEqual(allStates["key2"], LoroValue.i64(value: Int64(42)))
48 | }
49 |
50 | func testDelete() {
51 | let store = EphemeralStore(timeout: 60000)
52 |
53 | store.set(key: "key1", value: "value1")
54 | store.set(key: "key2", value: "value2")
55 |
56 | XCTAssertNotNil(store.get(key: "key1"))
57 |
58 | store.delete(key: "key1")
59 |
60 | XCTAssertNil(store.get(key: "key1"))
61 | XCTAssertNotNil(store.get(key: "key2"))
62 |
63 | let keys = store.keys()
64 | XCTAssertEqual(keys.count, 1)
65 | XCTAssertFalse(keys.contains("key1"))
66 | XCTAssertTrue(keys.contains("key2"))
67 | }
68 |
69 | func testEphemeralEventSubscription() {
70 | let store = EphemeralStore(timeout: 60000)
71 |
72 | var receivedEvents: [EphemeralStoreEvent] = []
73 |
74 | // Subscribe to events
75 | let subscription = store.subscribe { event in
76 | receivedEvents.append(event)
77 | }
78 |
79 | // Adding a key should trigger an event
80 | store.set(key: "key1", value: "value1")
81 |
82 | // Wait a short time for event processing to complete
83 | let expectation = XCTestExpectation(description: "Event received")
84 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
85 | expectation.fulfill()
86 | }
87 | wait(for: [expectation], timeout: 1.0)
88 |
89 | XCTAssertGreaterThan(receivedEvents.count, 0)
90 |
91 | let lastEvent = receivedEvents.last!
92 | XCTAssertEqual(lastEvent.by, .local)
93 | XCTAssertTrue(lastEvent.added.contains("key1"))
94 |
95 | // Clear received events
96 | receivedEvents.removeAll()
97 |
98 | // Updating a key should trigger an event
99 | store.set(key: "key1", value: "updated_value")
100 |
101 | // Wait for event
102 | let updateExpectation = XCTestExpectation(description: "Update event received")
103 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
104 | updateExpectation.fulfill()
105 | }
106 | wait(for: [updateExpectation], timeout: 1.0)
107 |
108 | XCTAssertGreaterThan(receivedEvents.count, 0)
109 | let updateEvent = receivedEvents.last!
110 | XCTAssertEqual(updateEvent.by, .local)
111 | XCTAssertTrue(updateEvent.updated.contains("key1"))
112 |
113 | // Clear received events
114 | receivedEvents.removeAll()
115 |
116 | // Deleting a key should trigger an event
117 | store.delete(key: "key1")
118 |
119 | // Wait for event
120 | let deleteExpectation = XCTestExpectation(description: "Delete event received")
121 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
122 | deleteExpectation.fulfill()
123 | }
124 | wait(for: [deleteExpectation], timeout: 1.0)
125 |
126 | XCTAssertGreaterThan(receivedEvents.count, 0)
127 | let deleteEvent = receivedEvents.last!
128 | XCTAssertEqual(deleteEvent.by, .local)
129 | XCTAssertTrue(deleteEvent.removed.contains("key1"))
130 |
131 | // Unsubscribe
132 | subscription.detach()
133 | }
134 |
135 | func testLocalUpdateSubscription() {
136 | let store = EphemeralStore(timeout: 60000)
137 |
138 | var receivedUpdates: [Data] = []
139 |
140 | // Subscribe to local updates
141 | let subscription = store.subscribeLocalUpdate { updateData in
142 | receivedUpdates.append(updateData)
143 | }
144 |
145 | // Set some data
146 | store.set(key: "key1", value: "value1")
147 | store.set(key: "key2", value: 42)
148 |
149 | // Wait for update events
150 | let expectation = XCTestExpectation(description: "Local update received")
151 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
152 | expectation.fulfill()
153 | }
154 | wait(for: [expectation], timeout: 1.0)
155 |
156 | // Should receive update data
157 | XCTAssertGreaterThan(receivedUpdates.count, 0)
158 |
159 | // Verify data is not empty
160 | for updateData in receivedUpdates {
161 | XCTAssertGreaterThan(updateData.count, 0)
162 | }
163 |
164 | // Unsubscribe
165 | subscription.detach()
166 | }
167 |
168 | func testEncodeAndApply() {
169 | let store1 = EphemeralStore(timeout: 60000)
170 | let store2 = EphemeralStore(timeout: 60000)
171 |
172 | // Set data in store1
173 | store1.set(key: "key1", value: "value1")
174 | store1.set(key: "key2", value: 42)
175 |
176 | // Encode all data
177 | let encodedData = store1.encodeAll()
178 |
179 | // Apply data to store2
180 | store2.apply(data: encodedData)
181 |
182 | // Verify store2 has the same data
183 | XCTAssertEqual(store2.get(key: "key1"), LoroValue.string(value: "value1"))
184 | XCTAssertEqual(store2.get(key: "key2"), LoroValue.i64(value: Int64(42)))
185 |
186 | let store2Keys = store2.keys()
187 | XCTAssertEqual(store2Keys.count, 2)
188 | XCTAssertTrue(store2Keys.contains("key1"))
189 | XCTAssertTrue(store2Keys.contains("key2"))
190 | }
191 |
192 | func testEncodeSpecificKey() {
193 | let store = EphemeralStore(timeout: 60000)
194 |
195 | store.set(key: "key1", value: "value1")
196 | store.set(key: "key2", value: "value2")
197 |
198 | // Encode specific key
199 | let encodedKey1 = store.encode(key: "key1")
200 | XCTAssertGreaterThan(encodedKey1.count, 0)
201 |
202 | // Create new store and apply specific key data
203 | let newStore = EphemeralStore(timeout: 60000)
204 | newStore.apply(data: encodedKey1)
205 |
206 | // Should only have key1, not key2
207 | XCTAssertNotNil(newStore.get(key: "key1"))
208 | XCTAssertNil(newStore.get(key: "key2"))
209 | }
210 |
211 | func testMultipleSubscriptions() {
212 | let store = EphemeralStore(timeout: 60000)
213 |
214 | var events1: [EphemeralStoreEvent] = []
215 | var events2: [EphemeralStoreEvent] = []
216 |
217 | // Create two subscriptions
218 | let subscription1 = store.subscribe { event in
219 | events1.append(event)
220 | }
221 |
222 | let subscription2 = store.subscribe { event in
223 | events2.append(event)
224 | }
225 |
226 | // Set data
227 | store.set(key: "test", value: "value")
228 |
229 | // Wait for events
230 | let expectation = XCTestExpectation(description: "Multiple subscriptions received")
231 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
232 | expectation.fulfill()
233 | }
234 | wait(for: [expectation], timeout: 1.0)
235 |
236 | // Both subscriptions should receive events
237 | XCTAssertGreaterThan(events1.count, 0)
238 | XCTAssertGreaterThan(events2.count, 0)
239 |
240 | // Unsubscribe
241 | subscription1.detach()
242 | subscription2.detach()
243 | }
244 | }
--------------------------------------------------------------------------------
/Tests/LoroTests/LoroTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Loro
3 |
4 | final class LoroTests: XCTestCase {
5 | func testEvent(){
6 | let doc = LoroDoc()
7 | var num = 0
8 | let sub = doc.subscribeRoot{ diffEvent in
9 | num += 1
10 | }
11 | let list = doc.getList(id: "list")
12 | try! list.insert(pos: 0, v: 123)
13 | doc.commit()
14 | sub.detach()
15 | XCTAssertEqual(num, 1)
16 | }
17 |
18 | func testOptional(){
19 | let doc = LoroDoc()
20 | let list = doc.getList(id: "list")
21 | try! list.insert(pos: 0, v: nil)
22 | let map = doc.getMap(id: "map")
23 | try! map.insert(key: "key", v: nil)
24 | let movableList = doc.getMovableList(id: "movableList")
25 | try! movableList.insert(pos: 0, v: nil)
26 | try! movableList.set(pos: 0, v: nil)
27 | doc.commit()
28 | XCTAssertEqual(list.get(index: 0)!.asValue()!, LoroValue.null)
29 | XCTAssertEqual(map.get(key: "key")!.asValue()!, LoroValue.null)
30 | XCTAssertEqual(movableList.get(index: 0)!.asValue()!, LoroValue.null)
31 | }
32 |
33 | func testText(){
34 | let doc = LoroDoc()
35 | let text = doc.getText(id: "text")
36 | try! text.insert(pos: 0, s: "abc")
37 | try! text.delete(pos: 0, len: 1)
38 | let s = text.toString()
39 | XCTAssertEqual(s, "bc")
40 | }
41 |
42 | func testMovableList(){
43 | let doc = LoroDoc()
44 | let movableList = doc.getMovableList(id: "movableList")
45 | XCTAssertTrue(movableList.isAttached())
46 | XCTAssertFalse(LoroMovableList().isAttached())
47 | }
48 |
49 | func testMap(){
50 | let doc = LoroDoc()
51 | let map = doc.getMap(id: "map")
52 | let _ = try! map.getOrCreateContainer(key: "list", child: LoroList())
53 | try! map.insert(key: "key", v: "value")
54 | XCTAssertEqual(map.get(key: "key")!.asValue()!, LoroValue.string(value:"value"))
55 | }
56 |
57 | func testSync(){
58 | let doc = LoroDoc()
59 | try! doc.setPeerId(peer: 0)
60 | let text = doc.getText(id: "text")
61 | try! text.insert(pos: 0, s: "abc")
62 | try! text.delete(pos: 0, len: 1)
63 | let s = text.toString()
64 | XCTAssertEqual(s, "bc")
65 |
66 | let doc2 = LoroDoc()
67 | try! doc2.setPeerId(peer: 1)
68 | let text2 = doc2.getText(id: "text")
69 | try! text2.insert(pos: 0, s:"123")
70 | let _ = try! doc2.import(bytes: doc.export(mode:ExportMode.snapshot))
71 | let _ = try! doc2.importBatch(bytes: [doc.exportSnapshot(), doc.export(mode: ExportMode.updates(from: VersionVector()))])
72 | XCTAssertEqual(text2.toString(), "bc123")
73 | }
74 |
75 | func testCheckout(){
76 | let doc = LoroDoc()
77 | let text = doc.getText(id: "text")
78 | try! text.insert(pos: 0, s: "abc")
79 | try! text.delete(pos: 0, len: 1)
80 |
81 | let startFrontiers = doc.oplogFrontiers()
82 | try! doc.checkout(frontiers: startFrontiers)
83 | doc.checkoutToLatest()
84 | }
85 |
86 | func testUndo(){
87 | let doc = LoroDoc()
88 | let undoManager = UndoManager(doc:doc)
89 |
90 | var n = 0
91 | undoManager.setOnPop{ (undoOrRedo,span, item) in
92 | n+=1
93 | }
94 | let text = doc.getText(id: "text")
95 | try! text.insert(pos: 0, s: "abc")
96 | doc.commit()
97 | try! text.delete(pos: 0, len: 1)
98 | doc.commit()
99 | let s = text.toString()
100 | XCTAssertEqual(s, "bc")
101 | let _ = try! undoManager.undo()
102 | XCTAssertEqual(text.toString(), "abc")
103 | XCTAssertEqual(n, 1)
104 | }
105 |
106 | func testApplyDelta(){
107 | let doc = LoroDoc()
108 | let text = doc.getText(id: "text")
109 | try! text.insert(pos: 0, s: "abc")
110 | try! text.applyDelta(delta: [TextDelta.delete(delete: 1), TextDelta.retain(retain: 2, attributes: nil), TextDelta.insert(insert: "def", attributes: nil)])
111 | let s = text.toString()
112 | XCTAssertEqual(s, "bcdef")
113 | }
114 |
115 | func testOrigin(){
116 | do{
117 | let localDoc = LoroDoc()
118 | let remoteDoc = LoroDoc()
119 | try localDoc.setPeerId(peer: 1)
120 | let localMap = localDoc.getMap(id: "properties")
121 | try localMap.insert(key: "x", v: "42")
122 |
123 | // Take a snapshot of the localDoc's content.
124 | let snapshot = try localDoc.exportSnapshot()
125 |
126 | // Set up and watch for changes in an initially empty remoteDoc.
127 | try remoteDoc.setPeerId(peer: 2)
128 | let expectedOriginString = "expectedOriginString"
129 | let subscription = remoteDoc.subscribeRoot { event in
130 | // Apparent bug: The event carries an empty origin string, instead of the origin string we passed into importWith(bytes:origin:).
131 | print("Got event for remoteDoc, with event.origin=\"\(event.origin)\"")
132 | if event.origin != expectedOriginString {
133 | XCTFail("Expected origin '\(expectedOriginString)' but got '\(event.origin)'")
134 | }
135 | }
136 | // Import the snapshot into a new LoroDoc, specifying an origin string. THis should the closure we registeredd with subscribeRoot, above, to be invoked.
137 | let _ = try remoteDoc.importWith(bytes: snapshot, origin: expectedOriginString)
138 | subscription.detach()
139 | }catch {
140 | print("ERROR: \(error)")
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/scripts/build_macos.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # This script was cribbed from https://github.com/automerge/automerge-swift/blob/main/scripts/build-xcframework.sh
4 | # which was cribbed from https://github.com/y-crdt/y-uniffi/blob/7cd55266c11c424afa3ae5b3edae6e9f70d9a6bb/lib/build-xcframework.sh
5 | # which was written by Joseph Heck and Aidar Nugmanoff and licensed under the MIT license.
6 |
7 | set -euxo pipefail
8 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
9 | LIB_NAME="libloro.a"
10 | RUST_FOLDER="$THIS_SCRIPT_DIR/../loro-ffi"
11 | FRAMEWORK_NAME="loroFFI"
12 |
13 | SWIFT_FOLDER="$THIS_SCRIPT_DIR/../gen-swift"
14 | BUILD_FOLDER="$RUST_FOLDER/target"
15 |
16 | XCFRAMEWORK_FOLDER="$THIS_SCRIPT_DIR/../${FRAMEWORK_NAME}.xcframework"
17 |
18 |
19 | echo "▸ Install toolchains"
20 | rustup target add aarch64-apple-darwin # macOS ARM/M1
21 | rustup target add x86_64-apple-darwin # macOS Intel/x86
22 | cargo_build="cargo build --manifest-path $RUST_FOLDER/Cargo.toml"
23 |
24 | echo "▸ Clean state"
25 | rm -rf "${XCFRAMEWORK_FOLDER}"
26 | rm -rf "${SWIFT_FOLDER}"
27 | mkdir -p "${SWIFT_FOLDER}"
28 | echo "▸ Generate Swift Scaffolding Code"
29 | cargo run --manifest-path "$RUST_FOLDER/Cargo.toml" \
30 | --features=cli \
31 | --bin uniffi-bindgen generate \
32 | "$RUST_FOLDER/src/loro.udl" \
33 | --language swift \
34 | --out-dir "${SWIFT_FOLDER}"
35 |
36 | bash "${THIS_SCRIPT_DIR}/refine_trait.sh"
37 |
38 | echo "▸ Building for aarch64-apple-darwin"
39 | CFLAGS_aarch64_apple_darwin="-target aarch64-apple-darwin" \
40 | $cargo_build --target aarch64-apple-darwin --locked --release
41 |
42 | echo "▸ Building for x86_64-apple-darwin"
43 | CFLAGS_x86_64_apple_darwin="-target x86_64-apple-darwin" \
44 | $cargo_build --target x86_64-apple-darwin --locked --release
45 |
46 | # copies the generated header into the build folder structure for local XCFramework usage
47 | mkdir -p "${BUILD_FOLDER}/includes/loroFFI"
48 | cp "${SWIFT_FOLDER}/loroFFI.h" "${BUILD_FOLDER}/includes/loroFFI"
49 | cp "${SWIFT_FOLDER}/loroFFI.modulemap" "${BUILD_FOLDER}/includes/loroFFI/module.modulemap"
50 | cp -f "${SWIFT_FOLDER}/loro.swift" "${THIS_SCRIPT_DIR}/../Sources/Loro/LoroFFI.swift"
51 |
52 | echo "▸ Lipo (merge) x86 and arm macOS static libraries into a fat static binary"
53 | mkdir -p "${BUILD_FOLDER}/apple-darwin/release"
54 | lipo -create \
55 | "${BUILD_FOLDER}/x86_64-apple-darwin/release/${LIB_NAME}" \
56 | "${BUILD_FOLDER}/aarch64-apple-darwin/release/${LIB_NAME}" \
57 | -output "${BUILD_FOLDER}/apple-darwin/release/${LIB_NAME}"
58 |
59 | xcodebuild -create-xcframework \
60 | -library "$BUILD_FOLDER/apple-darwin/release/$LIB_NAME" \
61 | -headers "${BUILD_FOLDER}/includes" \
62 | -output "${XCFRAMEWORK_FOLDER}"
63 |
64 | echo "▸ Compress xcframework"
65 | ditto -c -k --sequesterRsrc --keepParent "$XCFRAMEWORK_FOLDER" "$XCFRAMEWORK_FOLDER.zip"
66 |
--------------------------------------------------------------------------------
/scripts/build_swift_ffi.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # This script was cribbed from https://github.com/automerge/automerge-swift/blob/main/scripts/build-xcframework.sh
4 | # which was cribbed from https://github.com/y-crdt/y-uniffi/blob/7cd55266c11c424afa3ae5b3edae6e9f70d9a6bb/lib/build-xcframework.sh
5 | # which was written by Joseph Heck and Aidar Nugmanoff and licensed under the MIT license.
6 |
7 | set -euxo pipefail
8 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
9 | LIB_NAME="libloro.a"
10 | RUST_FOLDER="$THIS_SCRIPT_DIR/../loro-ffi"
11 | FRAMEWORK_NAME="loroFFI"
12 |
13 | SWIFT_FOLDER="$THIS_SCRIPT_DIR/../gen-swift"
14 | BUILD_FOLDER="$RUST_FOLDER/target"
15 |
16 | XCFRAMEWORK_FOLDER="$THIS_SCRIPT_DIR/../${FRAMEWORK_NAME}.xcframework"
17 |
18 | # The specific issue with an earlier nightly version and linking into an
19 | # XCFramework appears to be resolved with latest versions of +nightly toolchain
20 | # (as of 10/10/23), but leaving it open to float seems less useful than
21 | # moving the pinning forward, since Catalyst support (target macabi) still
22 | # requires an active, nightly toolchain.
23 | RUST_NIGHTLY="nightly-2024-10-09"
24 |
25 | echo "Install nightly and rust-src for Catalyst"
26 | rustup toolchain install ${RUST_NIGHTLY}
27 | rustup component add rust-src --toolchain ${RUST_NIGHTLY}
28 | rustup update
29 | rustup default ${RUST_NIGHTLY}
30 |
31 |
32 | echo "▸ Install toolchains"
33 | rustup target add x86_64-apple-ios # iOS Simulator (Intel)
34 | rustup target add aarch64-apple-ios-sim # iOS Simulator (M1)
35 | rustup target add aarch64-apple-ios # iOS Device
36 | rustup target add aarch64-apple-darwin # macOS ARM/M1
37 | rustup target add x86_64-apple-darwin # macOS Intel/x86
38 | rustup target add wasm32-wasi # WebAssembly
39 | cargo_build="cargo build --manifest-path $RUST_FOLDER/Cargo.toml"
40 | cargo_build_nightly="cargo +${RUST_NIGHTLY} build --manifest-path $RUST_FOLDER/Cargo.toml"
41 | cargo_build_nightly_with_std="cargo -Zbuild-std build --manifest-path $RUST_FOLDER/Cargo.toml"
42 |
43 | echo "▸ Clean state"
44 | rm -rf "${XCFRAMEWORK_FOLDER}"
45 | rm -rf "${SWIFT_FOLDER}"
46 | mkdir -p "${SWIFT_FOLDER}"
47 | echo "▸ Generate Swift Scaffolding Code"
48 | cargo run --manifest-path "$RUST_FOLDER/Cargo.toml" \
49 | --features=cli \
50 | --bin uniffi-bindgen generate \
51 | "$RUST_FOLDER/src/loro.udl" \
52 | --no-format \
53 | --language swift \
54 | --out-dir "${SWIFT_FOLDER}"
55 |
56 | bash "${THIS_SCRIPT_DIR}/refine_trait.sh"
57 |
58 | echo "▸ Building for x86_64-apple-ios"
59 | CFLAGS_x86_64_apple_ios="-target x86_64-apple-ios" \
60 | $cargo_build --target x86_64-apple-ios --locked --release
61 |
62 | echo "▸ Building for aarch64-apple-ios-sim"
63 | CFLAGS_aarch64_apple_ios="-target aarch64-apple-ios-sim" \
64 | $cargo_build --target aarch64-apple-ios-sim --locked --release
65 |
66 | echo "▸ Building for aarch64-apple-ios"
67 | CFLAGS_aarch64_apple_ios="-target aarch64-apple-ios" \
68 | $cargo_build --target aarch64-apple-ios --locked --release
69 |
70 | echo "▸ Building for aarch64-apple-visionos-sim"
71 | CFLAGS_aarch64_apple_visionos="-target aarch64-apple-visionos-sim" \
72 | $cargo_build_nightly_with_std --target aarch64-apple-visionos-sim --locked --release
73 |
74 | echo "▸ Building for aarch64-apple-visionos"
75 | CFLAGS_aarch64_apple_visionos="-target aarch64-apple-visionos" \
76 | $cargo_build_nightly_with_std --target aarch64-apple-visionos --locked --release
77 |
78 | echo "▸ Building for aarch64-apple-darwin"
79 | CFLAGS_aarch64_apple_darwin="-target aarch64-apple-darwin" \
80 | $cargo_build --target aarch64-apple-darwin --locked --release
81 |
82 | echo "▸ Building for x86_64-apple-darwin"
83 | CFLAGS_x86_64_apple_darwin="-target x86_64-apple-darwin" \
84 | $cargo_build --target x86_64-apple-darwin --locked --release
85 |
86 | echo "▸ Building for aarch64-apple-ios-macabi"
87 | $cargo_build_nightly -Z build-std --target aarch64-apple-ios-macabi --locked --release
88 |
89 | echo "▸ Building for x86_64-apple-ios-macabi"
90 | $cargo_build_nightly -Z build-std --target x86_64-apple-ios-macabi --locked --release
91 |
92 | echo "▸ Building for wasm32-wasi"
93 | $cargo_build --target wasm32-wasi --locked --release
94 |
95 | # echo "▸ Consolidating the headers and modulemaps for XCFramework generation"
96 | # copies the generated header from AutomergeUniffi/automergeFFI.h to
97 | # Sources/_CAutomergeUniffi/include/automergeFFI.h within the project
98 | # cp "${SWIFT_FOLDER}/automergeFFI.h" "${SWIFT_FOLDER}/../Sources/_CAutomergeUniffi/include"
99 | # cp "${SWIFT_FOLDER}/automergeFFI.modulemap" "${SWIFT_FOLDER}/../Sources/_CAutomergeUniffi/include/module.modulemap"
100 |
101 | # copies the generated header into the build folder structure for local XCFramework usage
102 | mkdir -p "${BUILD_FOLDER}/includes/loroFFI"
103 | cp "${SWIFT_FOLDER}/loroFFI.h" "${BUILD_FOLDER}/includes/loroFFI"
104 | cp "${SWIFT_FOLDER}/loroFFI.modulemap" "${BUILD_FOLDER}/includes/loroFFI/module.modulemap"
105 | cp -f "${SWIFT_FOLDER}/loro.swift" "${THIS_SCRIPT_DIR}/../Sources/Loro/LoroFFI.swift"
106 |
107 | echo "▸ Lipo (merge) x86 and arm simulator static libraries into a fat static binary"
108 | mkdir -p "${BUILD_FOLDER}/ios-simulator/release"
109 | lipo -create \
110 | "${BUILD_FOLDER}/x86_64-apple-ios/release/${LIB_NAME}" \
111 | "${BUILD_FOLDER}/aarch64-apple-ios-sim/release/${LIB_NAME}" \
112 | -output "${BUILD_FOLDER}/ios-simulator/release/${LIB_NAME}"
113 |
114 | echo "▸ arm simulator static libraries into a static binary"
115 | mkdir -p "${BUILD_FOLDER}/visionos-simulator/release"
116 | lipo -create \
117 | "${BUILD_FOLDER}/aarch64-apple-visionos-sim/release/${LIB_NAME}" \
118 | -output "${BUILD_FOLDER}/visionos-simulator/release/${LIB_NAME}"
119 |
120 | echo "▸ Lipo (merge) x86 and arm macOS static libraries into a fat static binary"
121 | mkdir -p "${BUILD_FOLDER}/apple-darwin/release"
122 | lipo -create \
123 | "${BUILD_FOLDER}/x86_64-apple-darwin/release/${LIB_NAME}" \
124 | "${BUILD_FOLDER}/aarch64-apple-darwin/release/${LIB_NAME}" \
125 | -output "${BUILD_FOLDER}/apple-darwin/release/${LIB_NAME}"
126 |
127 | echo "▸ Lipo (merge) x86 and arm macOS Catalyst static libraries into a fat static binary"
128 | mkdir -p "${BUILD_FOLDER}/apple-macabi/release"
129 | lipo -create \
130 | "${BUILD_FOLDER}/x86_64-apple-ios-macabi/release/${LIB_NAME}" \
131 | "${BUILD_FOLDER}/aarch64-apple-ios-macabi/release/${LIB_NAME}" \
132 | -output "${BUILD_FOLDER}/apple-macabi/release/${LIB_NAME}"
133 |
134 |
135 |
136 | xcodebuild -create-xcframework \
137 | -library "$BUILD_FOLDER/aarch64-apple-visionos/release/$LIB_NAME" \
138 | -headers "${BUILD_FOLDER}/includes" \
139 | -library "${BUILD_FOLDER}/visionos-simulator/release/${LIB_NAME}" \
140 | -headers "${BUILD_FOLDER}/includes" \
141 | -library "$BUILD_FOLDER/aarch64-apple-ios/release/$LIB_NAME" \
142 | -headers "${BUILD_FOLDER}/includes" \
143 | -library "${BUILD_FOLDER}/ios-simulator/release/${LIB_NAME}" \
144 | -headers "${BUILD_FOLDER}/includes" \
145 | -library "$BUILD_FOLDER/apple-darwin/release/$LIB_NAME" \
146 | -headers "${BUILD_FOLDER}/includes" \
147 | -library "$BUILD_FOLDER/apple-macabi/release/$LIB_NAME" \
148 | -headers "${BUILD_FOLDER}/includes" \
149 | -output "${XCFRAMEWORK_FOLDER}"
150 |
151 | echo "▸ Compress xcframework"
152 | ditto -c -k --sequesterRsrc --keepParent "$XCFRAMEWORK_FOLDER" "$XCFRAMEWORK_FOLDER.zip"
153 |
154 | echo "▸ Compute checksum"
155 | openssl dgst -sha256 "$XCFRAMEWORK_FOLDER.zip"
156 |
--------------------------------------------------------------------------------
/scripts/refine_trait.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euxo pipefail
4 | THIS_SCRIPT_DIR="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )"
5 | SWIFT_FOLDER="$THIS_SCRIPT_DIR/../gen-swift"
6 | file_path="$SWIFT_FOLDER/loro.swift"
7 |
8 | search_string="public protocol LoroValueLike[[:space:]]*:[[:space:]]*AnyObject {"
9 | replace_string="public protocol LoroValueLike: Any {"
10 |
11 |
12 | sed -i '' "s/$search_string/$replace_string/g" "$file_path"
13 |
14 | search_string="public protocol ContainerIdLike[[:space:]]*:[[:space:]]*AnyObject {"
15 | replace_string="public protocol ContainerIdLike: Any {"
16 |
17 | sed -i '' "s/$search_string/$replace_string/g" "$file_path"
18 |
--------------------------------------------------------------------------------