├── .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://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Floro-dev%2Floro-swift%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/loro-dev/loro-swift) 2 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Floro-dev%2Floro-swift%2Fbadge%3Ftype%3Dplatforms)](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 | --------------------------------------------------------------------------------