diff --git a/bindings/swift/OuisyncLib/OVERVIEW.md b/bindings/swift/OuisyncLib/OVERVIEW.md new file mode 100644 index 000000000..0bc206e1c --- /dev/null +++ b/bindings/swift/OuisyncLib/OVERVIEW.md @@ -0,0 +1,246 @@ +# OuisyncLib — Swift Bindings Overview + +Swift bindings for the [ouisync](https://github.com/equalitie/ouisync) P2P file-sync library. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ OuisyncLib (SourcesFFI/OuisyncFFI.swift) │ +│ @_exported import OuisyncLibCore │ +│ Wraps start_service / stop_service via CheckedContinuation │ +│ Links: OuisyncLibFFI.xcframework + SystemConfiguration.fw │ +└────────────────────┬────────────────────────────────────────────┘ + │ depends on +┌────────────────────▼────────────────────────────────────────────┐ +│ OuisyncLibCore (Sources/) │ +│ Session, Repository, File, OuisyncError, … │ +│ Client: TCP + HMAC-SHA256 auth + MessagePack framing │ +│ Api.swift: generated — do not edit by hand │ +└────────────────────┬────────────────────────────────────────────┘ + │ uses at link time +┌────────────────────▼────────────────────────────────────────────┐ +│ OuisyncLibFFI.xcframework (output/) │ +│ libouisync_service.a ← ouisync-service Rust crate │ +│ bindings.h ← generated by cbindgen │ +│ Exports: start_service, stop_service, init_log │ +└─────────────────────────────────────────────────────────────────┘ +``` + +The Rust service owns all state and runs in a background thread. Swift talks to it +over a local TCP socket using HMAC-SHA256 authentication and MessagePack-encoded +request/response frames. The generated `Api.swift` file models every request and +response type; `Client.swift` handles the framing and multiplexing. + +## Repository layout + +``` +bindings/swift/OuisyncLib/ +├── Package.swift SPM package definition +├── build-xcframework.sh One-shot build script (all configured platforms) +├── build-xcframework-macos.sh One-shot build script (macOS host only) +├── config.sh Target list for build-xcframework.sh +├── Sources/ OuisyncLibCore target +│ ├── Client.swift TCP client, auth, framing +│ ├── Session.swift Session lifecycle +│ ├── StateMonitor.swift State monitoring helpers +│ ├── NotificationStream.swift Async notification stream +│ └── generated/ +│ └── Api.swift ⚠ GENERATED — do not edit +├── SourcesFFI/ OuisyncLib target (wraps C FFI) +│ └── OuisyncFFI.swift OuisyncService start/stop async wrappers +├── Tests/ +│ └── OuisyncLibTests.swift Integration tests (Swift Testing) +├── Plugins/ +│ ├── Builder/builder.swift SPM build plugin (invoked by Xcode/SPM) +│ └── Updater/updater.swift SPM command plugin (cargo fetch) +└── output/ + └── OuisyncLibFFI.xcframework ⚠ BUILT ARTIFACT — not committed +``` + +Other relevant paths in the repo root: + +``` +service/cbindgen.toml cbindgen config for the C header +utils/bindgen/src/swift.rs Code-generator backend (produces Api.swift) +utils/generate-swift-api.sh Convenience wrapper around the generator +``` + +## Prerequisites + +| Tool | How to install | +|---|---| +| Rust + Cargo | `curl https://sh.rustup.rs -sSf \| sh` | +| cbindgen | `cargo install cbindgen` | +| Xcode (not just CLT) | From the Mac App Store; then `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer` | + +Verify the active developer tools point at Xcode (not CommandLineTools): + +```sh +xcode-select -p # must print …/Xcode.app/Contents/Developer +``` + +To build for iOS targets, add the required Rust toolchains (one-time setup): + +```sh +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios +``` + +## 1. Build the xcframework + +The xcframework (`OuisyncLibFFI.xcframework`) bundles the compiled Rust static library +together with the C header so SPM and Xcode can consume it. + +```sh +# From anywhere inside the repo — builds all targets in config.sh: +bash bindings/swift/OuisyncLib/build-xcframework.sh +``` + +This script: +1. Reads the enabled targets from `config.sh` (macOS, iOS device, iOS simulator) +2. Runs `cargo build --package ouisync-service --release --target ` for each +3. Runs `cbindgen` to generate `target/swift-include/bindings.h` from the Rust sources +4. Combines simulator slices with `lipo` where needed +5. Creates `OuisyncLib/output/OuisyncLibFFI.xcframework` via `xcodebuild -create-xcframework` + +The resulting xcframework contains slices for all enabled platforms, so the same artifact +works for both macOS and iOS builds in Xcode. + +> Re-run this script any time the Rust service API changes. + +For macOS-only development (faster, skips iOS compilation): + +```sh +bash bindings/swift/OuisyncLib/build-xcframework-macos.sh +``` + +## 2. Regenerate Api.swift + +`Sources/generated/Api.swift` is machine-generated from the Rust type definitions. It +is committed to the repository, but must be regenerated after any change to the ouisync +API types or after modifying `utils/bindgen/src/swift.rs`. + +```sh +# From the repo root: +bash utils/generate-swift-api.sh +``` + +This runs `cargo run --package ouisync-bindgen -- swift` and writes the output to +`bindings/swift/OuisyncLib/Sources/generated/Api.swift`. + +> Commit the regenerated file together with the Rust changes that prompted it. + +## 3. Build and test + +```sh +cd bindings/swift/OuisyncLib + +# Build only (no tests): +swift build --target OuisyncLib + +# Run integration tests (requires the xcframework to be built first): +swift test +``` + +The test suite (`OuisyncLibTests`) uses **Swift Testing** (`import Testing`). All tests +are end-to-end: each one starts a real Rust service in a temp directory, exercises the +Swift API, then shuts the service down. + +## Using the library + +Add `OuisyncLib` as a Swift package dependency or embed the package directly. The +xcframework must be present at `output/OuisyncLibFFI.xcframework` for SPM to resolve +the binary target. + +Typical lifecycle: + +```swift +import OuisyncLib + +// 1. Initialise logging (once, before starting the service) +OuisyncService.initLog() + +// 2. Start the Rust service +let service = try await OuisyncService.start(configDir: "/path/to/config") + +// 3. Open a session (connects to the running service) +let session = try await Session.create(configPath: "/path/to/config") + +// 4. Configure storage +try await session.setStoreDirs(["/path/to/store"]) + +// 5. Use the API +let repo = try await session.createRepository("my-repo") +let file = try await repo.createFile("hello.txt") +try await file.write(0, Data("hello".utf8)) +try await file.flush() +try await file.close() + +// 6. Tear down +await session.close() +try await service.stop() +``` + +### Error handling + +All async methods throw `OuisyncError` (or a typed subclass). Each error code from the +Rust service maps to a concrete subclass: + +```swift +do { + let file = try await repo.openFile("missing.txt") +} catch is OuisyncError.NotFound { + // handle not found +} catch let err as OuisyncError { + print("code=\(err.code), message=\(err.message ?? "-")") +} +``` + +### Key types + +| Type | Description | +|---|---| +| `OuisyncService` | Rust service lifecycle (start / stop) | +| `Session` | Connection to a running service | +| `Repository` | A single ouisync repository | +| `File` | An open file handle within a repository | +| `NetworkSocket` | UDP socket for custom transports | +| `NetworkStream` | TCP stream for custom transports | +| `ShareToken` | Capability token for sharing repositories | +| `OuisyncError` | Base error class with typed subclasses | + +## How the code generator works + +`utils/bindgen/src/swift.rs` reads the ouisync API definition (request/response enums +and data types) and emits idiomatic Swift: + +- **Structs / enums** — mirror the Rust types with `encodeToMsgPack` / `decodeFromMsgPack` +- **`Request` enum** — all named-field cases use `_ label:` parameters (unlabeled call sites) +- **`Response` enum** — decoded from server replies +- **Wrapper classes** (`Session`, `Repository`, `File`, …) — async methods that send a + `Request` and decode the matching `Response` + +After editing `swift.rs`, regenerate and verify: + +```sh +bash utils/generate-swift-api.sh +swift build --target OuisyncLib +swift test +``` + +## Known limitations / TODOs + +- The `OuisyncLibFFI` binary target path is hard-coded; it should move to a separate + Swift package so downstream apps can integrate without the full repo. +- **`subscribe()` / streaming API is not yet implemented.** `Repository` and `Session` + have no `subscribe()` method even though the underlying protocol supports it + (`Request.repositorySubscribe`, `Request.sessionSubscribeToNetwork` are generated). + The blocker is that `Client.invoke()` resolves a single `CheckedContinuation` per + message ID, so subsequent event notifications are silently dropped. A second + registration map (keyed message ID → `AsyncStream.Continuation`) needs to be added + to the receive loop, and manual `subscribe() -> NotificationStream` methods added to + `Repository` and `Session` — exactly as Kotlin does with its `channels` map and + `Flow`-returning extension functions. `NotificationStream.swift` is already scaffolded + and waiting for this work. +- The SPM `FFIBuilder` plugin rebuilds the Rust library on Xcode/SPM builds; the + `build-xcframework.sh` script is the simpler path for development. diff --git a/bindings/swift/OuisyncLib/Package.resolved b/bindings/swift/OuisyncLib/Package.resolved new file mode 100644 index 000000000..6651d5099 --- /dev/null +++ b/bindings/swift/OuisyncLib/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "messagepack.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/a2/MessagePack.swift.git", + "state" : { + "revision" : "27b35fd49e92fcae395bf8ccb233499d89cc7890", + "version" : "4.0.0" + } + } + ], + "version" : 2 +} diff --git a/bindings/swift/OuisyncLib/Package.swift b/bindings/swift/OuisyncLib/Package.swift index 592ff50cf..8f89b45a8 100644 --- a/bindings/swift/OuisyncLib/Package.swift +++ b/bindings/swift/OuisyncLib/Package.swift @@ -9,17 +9,26 @@ let package = Package( .library(name: "OuisyncLib", type: .static, targets: ["OuisyncLib"]), + .library(name: "OuisyncLibCore", + type: .static, + targets: ["OuisyncLibCore"]), ], dependencies: [ .package(url: "https://github.com/a2/MessagePack.swift.git", from: "4.0.0"), ], targets: [ - .target(name: "OuisyncLib", + .target(name: "OuisyncLibCore", dependencies: [.product(name: "MessagePack", - package: "MessagePack.swift"), + package: "MessagePack.swift")], + path: "Sources"), + .target(name: "OuisyncLib", + dependencies: ["OuisyncLibCore", "FFIBuilder", "OuisyncLibFFI"], - path: "Sources"), + path: "SourcesFFI", + linkerSettings: [ + .linkedFramework("SystemConfiguration"), + ]), .testTarget(name: "OuisyncLibTests", dependencies: ["OuisyncLib"], path: "Tests"), diff --git a/bindings/swift/OuisyncLib/Plugins/Builder/builder.swift b/bindings/swift/OuisyncLib/Plugins/Builder/builder.swift index 99d8b8f22..33a21418a 100644 --- a/bindings/swift/OuisyncLib/Plugins/Builder/builder.swift +++ b/bindings/swift/OuisyncLib/Plugins/Builder/builder.swift @@ -31,7 +31,15 @@ import PackagePlugin .removingLastComponent() // ouisync.output .appending("Update rust dependencies.output") - guard FileManager.default.fileExists(atPath: update.string) else { + // Check whether a pre-built xcframework already exists. When it does, the Rust + // toolchain is not needed for this build even if the cargo home directory hasn't + // been populated yet (e.g. when building an external package that depends on + // OuisyncLibCore but not OuisyncLib itself). + let xcframework = context.package.directory + .appending(["output", "OuisyncLibFFI.xcframework"]) + let xcframeworkExists = FileManager.default.fileExists(atPath: xcframework.string) + + guard xcframeworkExists || FileManager.default.fileExists(atPath: update.string) else { Diagnostics.error("Please run `Update rust dependencies` on the OuisyncLib package") fatalError("Unable to build LibOuisyncFFI.xcframework") } diff --git a/bindings/swift/OuisyncLib/Plugins/build.sh b/bindings/swift/OuisyncLib/Plugins/build.sh index a08e07b3c..80db7f1e6 100755 --- a/bindings/swift/OuisyncLib/Plugins/build.sh +++ b/bindings/swift/OuisyncLib/Plugins/build.sh @@ -51,7 +51,7 @@ cd $PROJECT_HOME for TARGET in ${(k)TARGETS}; do "$CARGO_HOME/bin/cross" build \ --frozen \ - --package ouisync-ffi \ + --package ouisync-service \ --target $TARGET \ --target-dir "$BUILD_OUTPUT" \ $FLAGS || exit 1 @@ -64,7 +64,7 @@ echo "module OuisyncLibFFI { header \"bindings.h\" export * }" > "$INCLUDE/module.modulemap" -"$CARGO_HOME/bin/cbindgen" --lang C --crate ouisync-ffi > "$INCLUDE/bindings.h" || exit 2 +"$CARGO_HOME/bin/cbindgen" --lang C --crate ouisync-service --config "$PROJECT_HOME/service/cbindgen.toml" > "$INCLUDE/bindings.h" || exit 2 # delete previous framework (possibly a stub) and replace with new one that contains the archive # TODO: some symlinks would be lovely here instead, cargo already create two copies @@ -86,7 +86,7 @@ for PLATFORM OUTPUTS in ${(kv)TREE}; do MATCHED=() # list of libraries compiled for this platform for TARGET in ${=OUTPUTS}; do if [[ -v TARGETS[$TARGET] ]]; then - MATCHED+="$BUILD_OUTPUT/$TARGET/$CONFIGURATION/libouisync_ffi.a" + MATCHED+="$BUILD_OUTPUT/$TARGET/$CONFIGURATION/libouisync_service.a" fi done if [ $#MATCHED -eq 0 ]; then # platform not enabled @@ -94,7 +94,7 @@ for PLATFORM OUTPUTS in ${(kv)TREE}; do elif [ $#MATCHED -eq 1 ]; then # single architecture: skip lipo and link directly LIBRARY=$MATCHED else # at least two architectures; run lipo on all matches and link the output instead - LIBRARY="$BUILD_OUTPUT/$PLATFORM/libouisync_ffi.a" + LIBRARY="$BUILD_OUTPUT/$PLATFORM/libouisync_service.a" mkdir -p "$(dirname "$LIBRARY")" lipo -create $MATCHED[@] -output $LIBRARY || exit 3 fi diff --git a/bindings/swift/OuisyncLib/Sources/Client.swift b/bindings/swift/OuisyncLib/Sources/Client.swift new file mode 100644 index 000000000..f76d4e7df --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Client.swift @@ -0,0 +1,369 @@ +import Foundation +import Network +import CryptoKit +import MessagePack + +// ResponseResult wraps server replies: either a successful Response or an OuisyncError. +private enum ResponseResult { + case success(Response) + case failure(OuisyncError) +} + +internal actor Client { + private let connection: NWConnection + private var nextId: UInt64 = 1 + private var pending: [UInt64: CheckedContinuation] = [:] + private var subscriptions: [UInt64: AsyncStream.Continuation] = [:] + + private init(_ connection: NWConnection) { + self.connection = connection + } + + // MARK: - Public API + + static func connect(configPath: String) async throws -> Client { + // Read endpoint config + let configFile = (configPath as NSString).appendingPathComponent("local_endpoint.conf") + let configData = try Data(contentsOf: URL(fileURLWithPath: configFile)) + + struct LocalEndpoint: Decodable { + let port: Int + let auth_key: String + } + let endpoint = try JSONDecoder().decode(LocalEndpoint.self, from: configData) + + guard endpoint.auth_key.count == 64, + let authKeyData = Data(hexString: endpoint.auth_key) + else { + throw CocoaError(.fileReadCorruptFile) + } + let authKey = SymmetricKey(data: authKeyData) + + // Connect + let host = NWEndpoint.Host("127.0.0.1") + let port = NWEndpoint.Port(integerLiteral: UInt16(endpoint.port)) + let conn = NWConnection(host: host, port: port, using: .tcp) + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + conn.stateUpdateHandler = { state in + switch state { + case .ready: + conn.stateUpdateHandler = nil + cont.resume() + case .failed(let error): + conn.stateUpdateHandler = nil + cont.resume(throwing: error) + case .cancelled: + conn.stateUpdateHandler = nil + cont.resume(throwing: CocoaError(.userCancelled)) + default: + break + } + } + conn.start(queue: .global()) + } + + let client = Client(conn) + try await client.runAuth(authKey: authKey) + Task { await client.receiveLoop() } + return client + } + + func invoke(_ request: Request) async throws -> Response { + let id = nextId + nextId &+= 1 + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { cont in + pending[id] = cont + Task { + do { + try await sendFrame(id: id, request: request) + } catch { + if let c = pending.removeValue(forKey: id) { + c.resume(throwing: error) + } + } + } + } + } onCancel: { + Task { + try? await sendCancel(id: id) + } + } + } + + func subscribe(_ request: Request) async throws -> AsyncStream { + let id = nextId + nextId &+= 1 + + var streamContinuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: .bufferingNewest(16)) { cont in + streamContinuation = cont + } + subscriptions[id] = streamContinuation + streamContinuation.onTermination = { [weak self] _ in + Task { [weak self] in await self?.unsubscribe(id: id) } + } + + do { + return try await withTaskCancellationHandler { + _ = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + pending[id] = cont + Task { + do { + try await sendFrame(id: id, request: request) + } catch { + if let c = pending.removeValue(forKey: id) { + subscriptions.removeValue(forKey: id)?.finish() + c.resume(throwing: error) + } + } + } + } + return stream + } onCancel: { + Task { [weak self] in await self?.cancelSubscribe(id: id) } + } + } catch { + subscriptions.removeValue(forKey: id)?.finish() + throw error + } + } + + func close() { + connection.cancel() + } + + // MARK: - Auth + + private func runAuth(authKey: SymmetricKey) async throws { + // 1. Send 256-byte client challenge + var clientChallenge = [UInt8](repeating: 0, count: 256) + for i in 0..<256 { clientChallenge[i] = UInt8.random(in: 0...255) } + try await send(Data(clientChallenge)) + + // 2. Receive 32-byte server proof + 256-byte server challenge + let serverBytes = try await receiveExact(32 + 256) + let serverProof = serverBytes.prefix(32) + let serverChallenge = serverBytes.dropFirst(32) + + // 3. Verify server proof + let expectedProof = HMAC.authenticationCode( + for: Data(clientChallenge), + using: authKey + ) + guard Data(expectedProof) == Data(serverProof) else { + connection.cancel() + throw CocoaError(.fileReadNoPermission) + } + + // 4. Send client proof + let clientProof = HMAC.authenticationCode( + for: Data(serverChallenge), + using: authKey + ) + try await send(Data(clientProof)) + } + + // MARK: - Frame encoding + + private func sendFrame(id: UInt64, request: Request) async throws { + let payload = pack(request.encodeToMsgPack()) + let length = UInt32(8 + payload.count) + + var frame = Data(capacity: 4 + 8 + payload.count) + frame.appendBigEndian(length) + frame.appendBigEndian(id) + frame.append(payload) + try await send(frame) + } + + private func sendCancel(id: UInt64) async throws { + try await sendFrame(id: id, request: .cancel(MessageId(value: id))) + } + + // MARK: - Receive loop + + private func receiveLoop() async { + while true { + do { + // Read 4-byte length + let lenData = try await receiveExact(4) + let length = lenData.bigEndianUInt32 + + // Read length bytes (contains 8-byte messageId + payload) + guard length >= 8 else { continue } + let body = try await receiveExact(Int(length)) + let messageId = body.prefix(8).bigEndianUInt64 + let payload = body.dropFirst(8) + + // Decode ResponseResult from msgpack + guard let (value, _) = try? unpack(Data(payload)) else { continue } + let result = decodeResponseResult(value) + + // Route to pending one-shot or active subscription stream + if let cont = pending.removeValue(forKey: messageId) { + switch result { + case .success(let response): + cont.resume(returning: response) + case .failure(let error): + cont.resume(throwing: error) + } + } else if let cont = subscriptions[messageId] { + switch result { + case .success(let response): + cont.yield(response) + case .failure: + cont.finish() + subscriptions.removeValue(forKey: messageId) + } + } + } catch { + // Connection closed or error: fail all pending and close all streams + let all = pending + pending.removeAll() + for cont in all.values { + cont.resume(throwing: error) + } + let allSubs = subscriptions + subscriptions.removeAll() + for cont in allSubs.values { + cont.finish() + } + return + } + } + } + + // MARK: - Subscription helpers + + private func cancelSubscribe(id: UInt64) async { + if let c = pending.removeValue(forKey: id) { + subscriptions.removeValue(forKey: id)?.finish() + c.resume(throwing: CancellationError()) + } + try? await sendCancel(id: id) + } + + private func unsubscribe(id: UInt64) async { + guard subscriptions.removeValue(forKey: id) != nil else { return } + try? await sendCancel(id: id) + } + + // MARK: - NWConnection helpers + + private func send(_ data: Data) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let e = error { + cont.resume(throwing: e) + } else { + cont.resume() + } + }) + } + } + + private func receiveExact(_ n: Int) async throws -> Data { + var buf = Data() + while buf.count < n { + let chunk = try await receiveChunk(max: n - buf.count) + buf.append(chunk) + } + return buf + } + + private func receiveChunk(max: Int) async throws -> Data { + try await withCheckedThrowingContinuation { cont in + connection.receive(minimumIncompleteLength: 1, maximumLength: max) { data, _, isComplete, error in + if let e = error { + cont.resume(throwing: e) + } else if let d = data, !d.isEmpty { + cont.resume(returning: d) + } else if isComplete { + cont.resume(throwing: CocoaError(.fileReadUnknown)) + } else { + cont.resume(throwing: CocoaError(.fileReadUnknown)) + } + } + } + } +} + +// MARK: - ResponseResult decoding + +private func decodeResponseResult(_ v: MessagePackValue) -> ResponseResult { + guard case .map(let m) = v, m.count == 1, + let entry = m.first, + case .string(let key) = entry.key + else { + return .failure(OuisyncError(.invalidData, "malformed response")) + } + + switch key { + case "Success": + if let response = Response.decodeFromMsgPack(entry.value) { + return .success(response) + } else { + return .failure(OuisyncError(.invalidData, "cannot decode response")) + } + case "Failure": + guard case .array(let arr) = entry.value, arr.count >= 2, + let codeRaw = { () -> UInt64? in + switch arr[0] { case .uint(let n): return n; case .int(let n): return UInt64(bitPattern: n); default: return nil } + }(), + let codeRepr = UInt16(exactly: codeRaw), + let code = ErrorCode(rawValue: codeRepr), + case .string(let message) = arr[1] + else { + return .failure(OuisyncError(.invalidData, "malformed failure response")) + } + var sources: [String] = [] + if arr.count > 2, case .array(let srcArr) = arr[2] { + for s in srcArr { + if case .string(let str) = s { sources.append(str) } + } + } + return .failure(OuisyncError.dispatch(code, message, sources: sources)) + default: + return .failure(OuisyncError(.invalidData, "unknown response key: \(key)")) + } +} + +// MARK: - Data extensions + +private extension Data { + init?(hexString: String) { + let chars = Array(hexString) + guard chars.count % 2 == 0 else { return nil } + var bytes: [UInt8] = [] + for i in stride(from: 0, to: chars.count, by: 2) { + guard let byte = UInt8(String(chars[i...i+1]), radix: 16) else { return nil } + bytes.append(byte) + } + self = Data(bytes) + } + + mutating func appendBigEndian(_ value: UInt32) { + var v = value.bigEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } + + mutating func appendBigEndian(_ value: UInt64) { + var v = value.bigEndian + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } + + var bigEndianUInt32: UInt32 { + var value: UInt32 = 0 + _ = Swift.withUnsafeMutableBytes(of: &value) { copyBytes(to: $0) } + return value.bigEndian + } + + var bigEndianUInt64: UInt64 { + var value: UInt64 = 0 + _ = Swift.withUnsafeMutableBytes(of: &value) { copyBytes(to: $0) } + return value.bigEndian + } +} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift b/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift deleted file mode 100644 index f7bf5a4e8..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// File.swift -// -// -// Created by Peter Jankuliak on 23/07/2024. -// - -import Foundation -import OuisyncLibFFI - -public class OuisyncClient { - var clientHandle: SessionHandle - let ffi: OuisyncFFI - public var onReceiveFromBackend: OuisyncOnReceiveFromBackend? = nil - - public static func create(_ configPath: String, _ logPath: String, _ ffi: OuisyncFFI) throws -> OuisyncClient { - // Init with an invalid sessionHandle because we need the OuisyncSession instance to - // create the callback, which is in turn needed to create the proper sessionHandle. - let client = OuisyncClient(0, ffi) - - let logTag = "ouisync-backend" - let result = ffi.ffiSessionCreate(ffi.sessionKindShared, configPath, logPath, logTag, - .init(mutating: OuisyncFFI.toUnretainedPtr(obj: client))) { - context, dataPointer, size in - let client: OuisyncClient = OuisyncFFI.fromUnretainedPtr(ptr: context!) - guard let onReceive = client.onReceiveFromBackend else { - fatalError("OuisyncClient has no onReceive handler set") - } - onReceive(Array(UnsafeBufferPointer(start: dataPointer, count: Int(exactly: size)!))) - } - - if result.error_code != 0 { - throw SessionCreateError("Failed to create session, code:\(result.error_code), message:\(result.error_message!)") - } - - client.clientHandle = result.session - return client - } - - fileprivate init(_ clientHandle: SessionHandle, _ ffi: OuisyncFFI) { - self.clientHandle = clientHandle - self.ffi = ffi - } - - public func sendToBackend(_ data: [UInt8]) { - let count = data.count; - data.withUnsafeBufferPointer({ maybePointer in - if let pointer = maybePointer.baseAddress { - ffi.ffiSessionChannelSend(clientHandle, .init(mutating: pointer), UInt64(count)) - } - }) - } - - public func close() async { - typealias Continuation = CheckedContinuation - - class Context { - let clientHandle: SessionHandle - let continuation: Continuation - init(_ clientHandle: SessionHandle, _ continuation: Continuation) { - self.clientHandle = clientHandle - self.continuation = continuation - } - } - - await withCheckedContinuation(function: "FFI.closeSession", { continuation in - let context = OuisyncFFI.toRetainedPtr(obj: Context(clientHandle, continuation)) - let callback: FFICallback = { context, dataPointer, size in - let context: Context = OuisyncFFI.fromRetainedPtr(ptr: context!) - context.continuation.resume() - } - ffi.ffiSessionClose(clientHandle, .init(mutating: context), callback) - }) - } -} - -public typealias OuisyncOnReceiveFromBackend = ([UInt8]) -> Void diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift b/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift deleted file mode 100644 index 1bbd29827..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift +++ /dev/null @@ -1,166 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System - -public enum OuisyncEntryType { - case file - case directory -} - -public enum OuisyncEntry { - case file(OuisyncFileEntry) - case directory(OuisyncDirectoryEntry) - - public func name() -> String { - switch self { - case .file(let e): return e.name() - case .directory(let e): return e.name() - } - } - - public func type() -> OuisyncEntryType { - switch self { - case .file: return .file - case .directory: return .directory - } - } - - public func parent() -> OuisyncEntry? { - switch self { - case .file(let file): return .directory(file.parent()) - case .directory(let directory): - guard let parent = directory.parent() else { - return nil - } - return .directory(parent) - } - } -} - -public class OuisyncFileEntry { - public let path: FilePath - public let repository: OuisyncRepository - - public init(_ path: FilePath, _ repository: OuisyncRepository) { - self.path = path - self.repository = repository - } - - public func parent() -> OuisyncDirectoryEntry { - return OuisyncDirectoryEntry(Self.parent(path), repository) - } - - public func name() -> String { - return Self.name(path) - } - - public static func name(_ path: FilePath) -> String { - return path.lastComponent!.string - } - - public func exists() async throws -> Bool { - return try await repository.session.sendRequest(.fileExists(repository.handle, path)).toBool() - } - - public func delete() async throws { - try await repository.deleteFile(path) - } - - public func getVersionHash() async throws -> Data { - try await repository.getEntryVersionHash(path) - } - - public static func parent(_ path: FilePath) -> FilePath { - var parentPath = path - parentPath.components.removeLast() - return parentPath - } - - public func open() async throws -> OuisyncFile { - try await repository.openFile(path) - } - - public func create() async throws -> OuisyncFile { - try await repository.createFile(path) - } -} - -public class OuisyncDirectoryEntry: CustomDebugStringConvertible { - public let repository: OuisyncRepository - public let path: FilePath - - public init(_ path: FilePath, _ repository: OuisyncRepository) { - self.repository = repository - self.path = path - } - - public func name() -> String { - return OuisyncDirectoryEntry.name(path) - } - - public static func name(_ path: FilePath) -> String { - if let c = path.lastComponent { - return c.string - } - return "/" - } - - public func listEntries() async throws -> [OuisyncEntry] { - let response = try await repository.session.sendRequest(OuisyncRequest.listEntries(repository.handle, path)) - let entries = response.value.arrayValue! - return entries.map({entry in - let name: String = entry[0]!.stringValue! - let typeNum = entry[1]!.uint8Value! - - switch typeNum { - case 1: return .file(OuisyncFileEntry(path.appending(name), repository)) - case 2: return .directory(OuisyncDirectoryEntry(path.appending(name), repository)) - default: - fatalError("Invalid EntryType returned from OuisyncLib \(typeNum)") - } - }) - } - - public func isRoot() -> Bool { - return path.components.isEmpty - } - - public func parent() -> OuisyncDirectoryEntry? { - guard let parentPath = OuisyncDirectoryEntry.parent(path) else { - return nil - } - return OuisyncDirectoryEntry(parentPath, repository) - } - - public func exists() async throws -> Bool { - let response = try await repository.session.sendRequest(OuisyncRequest.directoryExists(repository.handle, path)) - return response.value.boolValue! - } - - public func delete(recursive: Bool) async throws { - try await repository.deleteDirectory(path, recursive: recursive) - } - - public func getVersionHash() async throws -> Data { - try await repository.getEntryVersionHash(path) - } - - public static func parent(_ path: FilePath) -> FilePath? { - if path.components.isEmpty { - return nil - } else { - var parentPath = path - parentPath.components.removeLast() - return parentPath - } - } - - public var debugDescription: String { - return "OuisyncDirectory(\(path), \(repository))" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncError.swift b/bindings/swift/OuisyncLib/Sources/OuisyncError.swift deleted file mode 100644 index 1061c8182..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncError.swift +++ /dev/null @@ -1,84 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation - -public enum OuisyncErrorCode: UInt16 { - /// Store error - case Store = 1 - /// Insuficient permission to perform the intended operation - case PermissionDenied = 2 - /// Malformed data - case MalformedData = 3 - /// Entry already exists - case EntryExists = 4 - /// Entry doesn't exist - case EntryNotFound = 5 - /// Multiple matching entries found - case AmbiguousEntry = 6 - /// The intended operation requires the directory to be empty but it isn't - case DirectoryNotEmpty = 7 - /// The indended operation is not supported - case OperationNotSupported = 8 - /// Failed to read from or write into the config file - case Config = 10 - /// Argument passed to a function is not valid - case InvalidArgument = 11 - /// Request or response is malformed - case MalformedMessage = 12 - /// Storage format version mismatch - case StorageVersionMismatch = 13 - /// Connection lost - case ConnectionLost = 14 - /// Invalid handle to a resource (e.g., Repository, File, ...) - case InvalidHandle = 15 - /// Entry has been changed and no longer matches the expected value - case EntryChanged = 16 - - // These can't happen and apple devices - // case VfsInvalidMountPoint = 2048 - // case VfsDriverInstall = 2049 - // case VfsBackend = 2050 - - /// Unspecified error - case Other = 65535 -} - -public class OuisyncError : Error, CustomDebugStringConvertible { - public let code: OuisyncErrorCode - public let message: String - - init(_ code: OuisyncErrorCode, _ message: String) { - self.code = code - self.message = message - } - - public var debugDescription: String { - let codeStr: String - - switch code { - case .Store: codeStr = "Store error" - case .PermissionDenied: codeStr = "Insuficient permission to perform the intended operation" - case .MalformedData: codeStr = "Malformed data" - case .EntryExists: codeStr = "Entry already exists" - case .EntryNotFound: codeStr = "Entry doesn't exist" - case .AmbiguousEntry: codeStr = "Multiple matching entries found" - case .DirectoryNotEmpty: codeStr = "The intended operation requires the directory to be empty but it isn't" - case .OperationNotSupported: codeStr = "The indended operation is not supported" - case .Config: codeStr = "Failed to read from or write into the config file" - case .InvalidArgument: codeStr = "Argument passed to a function is not valid" - case .MalformedMessage: codeStr = "Request or response is malformed" - case .StorageVersionMismatch: codeStr = "Storage format version mismatch" - case .ConnectionLost: codeStr = "Connection lost" - case .InvalidHandle: codeStr = "Invalid handle to a resource (e.g., Repository, File, ...)" - case .EntryChanged: codeStr = "Entry has been changed and no longer matches the expected value" - - case .Other: codeStr = "Unspecified error" - } - - return "OuisyncError(code:\(code), codeStr:\"\(codeStr)\", message:\"\(message)\")" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift b/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift deleted file mode 100644 index 36eb00ad0..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// File.swift -// -// -// Created by Peter Jankuliak on 19/07/2024. -// - -import Foundation -import OuisyncLibFFI - - -/* TODO: ⬇️ - - Since we're now linking statically and both rust-cbindgen and swift do a reasonable job at guessing - the intended types, I don't expect these types to ever make it to the main branch because this file - will most likely go away. For now they are kept to avoid touching too much code in a single commit. - */ -typealias FFISessionKind = UInt8 // swift gets confused here and imports a UInt32 enum as well as a UInt8 typealias -typealias FFIContext = UnsafeMutableRawPointer // exported as `* mut ()` in rust so this is correct, annoyingly -typealias FFICallback = @convention(c) (FFIContext?, UnsafePointer?, UInt64) -> Void; -typealias FFISessionCreate = @convention(c) (FFISessionKind, UnsafePointer?, UnsafePointer?, UnsafePointer?, FFIContext?, FFICallback?) -> SessionCreateResult; -typealias FFISessionGrab = @convention(c) (FFIContext?, FFICallback?) -> SessionCreateResult; -typealias FFISessionClose = @convention(c) (SessionHandle, FFIContext?, FFICallback?) -> Void; -typealias FFISessionChannelSend = @convention(c) (SessionHandle, UnsafeMutablePointer?, UInt64) -> Void; - -class SessionCreateError : Error, CustomStringConvertible { - let message: String - init(_ message: String) { self.message = message } - var description: String { message } -} - -public class OuisyncFFI { - // let handle: UnsafeMutableRawPointer - let ffiSessionGrab: FFISessionGrab - let ffiSessionCreate: FFISessionCreate - let ffiSessionChannelSend: FFISessionChannelSend - let ffiSessionClose: FFISessionClose - let sessionKindShared: FFISessionKind = 0; - - public init() { - // The .dylib is created using the OuisyncDyLibBuilder package plugin in this Swift package. - // let libraryName = "libouisync_ffi.dylib" - // let resourcePath = Bundle.main.resourcePath! + "/OuisyncLib_OuisyncLibFFI.bundle/Contents/Resources" - // handle = dlopen("\(resourcePath)/\(libraryName)", RTLD_NOW)! - - ffiSessionGrab = session_grab - ffiSessionChannelSend = session_channel_send - ffiSessionClose = session_close - ffiSessionCreate = session_create - - //ffiSessionGrab = unsafeBitCast(dlsym(handle, "session_grab"), to: FFISessionGrab.self) - //ffiSessionChannelSend = unsafeBitCast(dlsym(handle, "session_channel_send"), to: FFISessionChannelSend.self) - //ffiSessionClose = unsafeBitCast(dlsym(handle, "session_close"), to: FFISessionClose.self) - //ffiSessionCreate = unsafeBitCast(dlsym(handle, "session_create"), to: FFISessionCreate.self) - } - - // Blocks until Dart creates a session, then returns it. - func waitForSession(_ context: FFIContext, _ callback: FFICallback) async throws -> SessionHandle { - // TODO: Might be worth change the ffi function to call a callback when the session becomes created instead of bussy sleeping. - var elapsed: UInt64 = 0; - while true { - let result = ffiSessionGrab(context, callback) - if result.error_code == 0 { - NSLog("😀 Got Ouisync session"); - return result.session - } - NSLog("🤨 Ouisync session not yet ready. Code: \(result.error_code) Message:\(String(cString: result.error_message!))"); - - let millisecond: UInt64 = 1_000_000 - let second: UInt64 = 1000 * millisecond - - var timeout = 200 * millisecond - - if elapsed > 10 * second { - timeout = second - } - - try await Task.sleep(nanoseconds: timeout) - elapsed += timeout; - } - } - - // Retained pointers have their reference counter incremented by 1. - // https://stackoverflow.com/a/33310021/273348 - static func toUnretainedPtr(obj : T) -> UnsafeRawPointer { - return UnsafeRawPointer(Unmanaged.passUnretained(obj).toOpaque()) - } - - static func fromUnretainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeUnretainedValue() - } - - static func toRetainedPtr(obj : T) -> UnsafeRawPointer { - return UnsafeRawPointer(Unmanaged.passRetained(obj).toOpaque()) - } - - static func fromRetainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeRetainedValue() - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift b/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift deleted file mode 100644 index 995a40705..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System - -public class OuisyncFile { - public let repository: OuisyncRepository - let handle: FileHandle - - init(_ handle: FileHandle, _ repository: OuisyncRepository) { - self.repository = repository - self.handle = handle - } - - public func read(_ offset: UInt64, _ length: UInt64) async throws -> Data { - try await session.sendRequest(.fileRead(handle, offset, length)).toData() - } - - public func write(_ offset: UInt64, _ data: Data) async throws { - let _ = try await session.sendRequest(.fileWrite(handle, offset, data)) - } - - public func size() async throws -> UInt64 { - try await session.sendRequest(.fileLen(handle)).toUInt64() - } - - public func truncate(_ len: UInt64) async throws { - let _ = try await session.sendRequest(.fileTruncate(handle, len)) - } - - public func close() async throws { - let _ = try await session.sendRequest(.fileClose(handle)) - } - - var session: OuisyncSession { - repository.session - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift b/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift deleted file mode 100644 index abed67ff0..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift +++ /dev/null @@ -1,11 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation - -public typealias MessageId = UInt64 -public typealias RepositoryHandle = UInt64 -public typealias FileHandle = UInt64 diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift b/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift deleted file mode 100644 index 6aa8f45ef..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift +++ /dev/null @@ -1,399 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import MessagePack -import System - -//-------------------------------------------------------------------- - -public class OuisyncRequest { - let functionName: String - let functionArguments: MessagePackValue - - init(_ functionName: String, _ functionArguments: MessagePackValue) { - self.functionName = functionName - self.functionArguments = functionArguments - } - - public static func listRepositories() -> OuisyncRequest { - return OuisyncRequest("list_repositories", MessagePackValue.nil) - } - - public static func subscribeToRepositoryListChange() -> OuisyncRequest { - return OuisyncRequest("list_repositories_subscribe", MessagePackValue.nil) - } - - public static func subscribeToRepositoryChange(_ handle: RepositoryHandle) -> OuisyncRequest { - return OuisyncRequest("repository_subscribe", MessagePackValue(handle)) - } - - public static func getRepositoryName(_ handle: RepositoryHandle) -> OuisyncRequest { - return OuisyncRequest("repository_name", MessagePackValue(handle)) - } - - public static func repositoryMoveEntry(_ repoHandle: RepositoryHandle, _ srcPath: FilePath, _ dstPath: FilePath) -> OuisyncRequest { - return OuisyncRequest("repository_move_entry", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("src"): MessagePackValue(srcPath.description), - MessagePackValue("dst"): MessagePackValue(dstPath.description), - ])) - } - - public static func listEntries(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_open", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func getEntryVersionHash(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("repository_entry_version_hash", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func directoryExists(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_exists", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func directoryRemove(_ handle: RepositoryHandle, _ path: FilePath, _ recursive: Bool) -> OuisyncRequest { - return OuisyncRequest("directory_remove", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - MessagePackValue("recursive"): MessagePackValue(recursive), - ])) - } - - public static func directoryCreate(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_create", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileOpen(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_open", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileExists(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_exists", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileRemove(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_remove", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileClose(_ fileHandle: FileHandle) -> OuisyncRequest { - return OuisyncRequest("file_close", MessagePackValue(fileHandle)) - } - - public static func fileRead(_ fileHandle: FileHandle, _ offset: UInt64, _ len: UInt64) -> OuisyncRequest { - return OuisyncRequest("file_read", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("offset"): MessagePackValue(offset), - MessagePackValue("len"): MessagePackValue(len), - ])) - } - - public static func fileTruncate(_ fileHandle: FileHandle, _ len: UInt64) -> OuisyncRequest { - return OuisyncRequest("file_truncate", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("len"): MessagePackValue(len), - ])) - } - - public static func fileLen(_ fileHandle: FileHandle) -> OuisyncRequest { - return OuisyncRequest("file_len", MessagePackValue(fileHandle)) - } - - public static func fileCreate(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_create", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileWrite(_ fileHandle: FileHandle, _ offset: UInt64, _ data: Data) -> OuisyncRequest { - return OuisyncRequest("file_write", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("offset"): MessagePackValue(offset), - MessagePackValue("data"): MessagePackValue(data), - ])) - } -} - -//-------------------------------------------------------------------- - -public class OuisyncRequestMessage { - public let messageId: MessageId - public let request: OuisyncRequest - - init(_ messageId: MessageId, _ request: OuisyncRequest) { - self.messageId = messageId - self.request = request - } - - public func serialize() -> [UInt8] { - var message: [UInt8] = [] - message.append(contentsOf: withUnsafeBytes(of: messageId.bigEndian, Array.init)) - let payload = [MessagePackValue.string(request.functionName): request.functionArguments] - message.append(contentsOf: pack(MessagePackValue.map(payload))) - return message - } - - public static func deserialize(_ data: [UInt8]) -> OuisyncRequestMessage? { - guard let (id, data) = readMessageId(data) else { - return nil - } - - let unpacked = (try? unpack(data))?.0 - - guard case let .map(m) = unpacked else { return nil } - if m.count != 1 { return nil } - guard let e = m.first else { return nil } - guard let functionName = e.key.stringValue else { return nil } - let functionArguments = e.value - - return OuisyncRequestMessage(id, OuisyncRequest(functionName, functionArguments)) - } -} - -public class OuisyncResponseMessage { - public let messageId: MessageId - public let payload: OuisyncResponsePayload - - public init(_ messageId: MessageId, _ payload: OuisyncResponsePayload) { - self.messageId = messageId - self.payload = payload - } - - public func serialize() -> [UInt8] { - var message: [UInt8] = [] - message.append(contentsOf: withUnsafeBytes(of: messageId.bigEndian, Array.init)) - let body: MessagePackValue; - switch payload { - case .response(let response): - body = MessagePackValue.map(["success": Self.responseValue(response.value)]) - case .notification(let notification): - body = MessagePackValue.map(["notification": notification.value]) - case .error(let error): - let code = Int64(exactly: error.code.rawValue)! - body = MessagePackValue.map(["failure": .array([.int(code), .string(error.message)])]) - } - message.append(contentsOf: pack(body)) - return message - } - - static func responseValue(_ value: MessagePackValue) -> MessagePackValue { - switch value { - case .nil: return .string("none") - default: - // The flutter code doesn't read the key which is supposed to be a type, - // would still be nice to have a proper mapping. - return .map(["todo-type": value]) - } - } - - public static func deserialize(_ bytes: [UInt8]) -> OuisyncResponseMessage? { - guard let (id, data) = readMessageId(bytes) else { - return nil - } - - let unpacked = (try? unpack(Data(data)))?.0 - - if case let .map(m) = unpacked { - if let success = m[.string("success")] { - if let value = parseResponse(success) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.response(value)) - } - } else if let error = m[.string("failure")] { - if let response = parseFailure(error) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.error(response)) - } - } else if let notification = m[.string("notification")] { - if let value = parseNotification(notification) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.notification(value)) - } - } - } - - return nil - } -} - -extension OuisyncResponseMessage: CustomStringConvertible { - public var description: String { - return "IncomingMessage(\(messageId), \(payload))" - } -} - -fileprivate func readMessageId(_ data: [UInt8]) -> (MessageId, Data)? { - let idByteCount = (MessageId.bitWidth / UInt8.bitWidth) - - if data.count < idByteCount { - return nil - } - - let bigEndianValue = data.withUnsafeBufferPointer { - ($0.baseAddress!.withMemoryRebound(to: MessageId.self, capacity: 1) { $0 }) - }.pointee - - let id = MessageId(bigEndian: bigEndianValue) - - return (id, Data(data[idByteCount...])) -} -//-------------------------------------------------------------------- - -public enum OuisyncResponsePayload { - case response(Response) - case notification(OuisyncNotification) - case error(OuisyncError) -} - -extension OuisyncResponsePayload: CustomStringConvertible { - public var description: String { - switch self { - case .response(let response): - return "response(\(response))" - case .notification(let notification): - return "notification(\(notification))" - case .error(let error): - return "error(\(error))" - } - } -} - -//-------------------------------------------------------------------- - -public enum IncomingSuccessPayload { - case response(Response) - case notification(OuisyncNotification) -} - -extension IncomingSuccessPayload: CustomStringConvertible { - public var description: String { - switch self { - case .response(let value): - return "response(\(value))" - case .notification(let value): - return "notificateion(\(value))" - } - } -} - -//-------------------------------------------------------------------- - -public class Response { - public let value: MessagePackValue - - // Note about unwraps in these methods. It is expected that the - // caller knows what type the response is. If the expected and - // the actual types differ, then it is likely that there is a - // mismatch between the front end and the backend in the FFI API. - - public init(_ value: MessagePackValue) { - self.value = value - } - - public func toData() -> Data { - return value.dataValue! - } - - public func toUInt64Array() -> [UInt64] { - return value.arrayValue!.map({ $0.uint64Value! }) - } - - public func toUInt64() -> UInt64 { - return value.uint64Value! - } - - public func toBool() -> Bool { - return value.boolValue! - } -} - -extension Response: CustomStringConvertible { - public var description: String { - return "Response(\(value))" - } -} - -//-------------------------------------------------------------------- - -public class OuisyncNotification { - let value: MessagePackValue - init(_ value: MessagePackValue) { - self.value = value - } -} - -extension OuisyncNotification: CustomStringConvertible { - public var description: String { - return "Notification(\(value))" - } -} - -//-------------------------------------------------------------------- - -func parseResponse(_ value: MessagePackValue) -> Response? { - if case let .map(m) = value { - if m.count != 1 { - return nil - } - return Response(m.first!.value) - } else if case let .string(str) = value, str == "none" { - // A function was called which has a `void` return value. - return Response(.nil) - } - return nil -} - -func parseFailure(_ value: MessagePackValue) -> OuisyncError? { - if case let .array(arr) = value { - if arr.count != 2 { - return nil - } - if case let .uint(code) = arr[0] { - if case let .string(message) = arr[1] { - guard let codeU16 = UInt16(exactly: code) else { - fatalError("Error code from backend is out of range") - } - guard let codeEnum = OuisyncErrorCode(rawValue: codeU16) else { - fatalError("Invalid error code from backend") - } - return OuisyncError(codeEnum, message) - } - } - } - return nil -} - -func parseNotification(_ value: MessagePackValue) -> OuisyncNotification? { - if case .string(_) = value { - return OuisyncNotification(MessagePackValue.nil) - } - if case let .map(m) = value { - if m.count != 1 { - return nil - } - return OuisyncNotification(m.first!.value) - } - return nil -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift b/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift deleted file mode 100644 index b2cfd6632..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift +++ /dev/null @@ -1,79 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System -import MessagePack - -public class OuisyncRepository: Hashable, CustomDebugStringConvertible { - let session: OuisyncSession - public let handle: RepositoryHandle - - public init(_ handle: RepositoryHandle, _ session: OuisyncSession) { - self.handle = handle - self.session = session - } - - public func getName() async throws -> String { - let data = try await session.sendRequest(.getRepositoryName(handle)).toData() - return String(decoding: data, as: UTF8.self) - } - - public func fileEntry(_ path: FilePath) -> OuisyncFileEntry { - OuisyncFileEntry(path, self) - } - - public func getEntryVersionHash(_ path: FilePath) async throws -> Data { - try await session.sendRequest(.getEntryVersionHash(handle, path)).toData() - } - - public func directoryEntry(_ path: FilePath) -> OuisyncDirectoryEntry { - OuisyncDirectoryEntry(path, self) - } - - public func getRootDirectory() -> OuisyncDirectoryEntry { - return OuisyncDirectoryEntry(FilePath("/"), self) - } - - public func createFile(_ path: FilePath) async throws -> OuisyncFile { - let handle = try await session.sendRequest(.fileCreate(handle, path)).toUInt64() - return OuisyncFile(handle, self) - } - - public func openFile(_ path: FilePath) async throws -> OuisyncFile { - let handle = try await session.sendRequest(.fileOpen(handle, path)).toUInt64() - return OuisyncFile(handle, self) - } - - public func deleteFile(_ path: FilePath) async throws { - let _ = try await session.sendRequest(.fileRemove(handle, path)) - } - - public func createDirectory(_ path: FilePath) async throws { - let _ = try await session.sendRequest(.directoryCreate(handle, path)) - } - - public func deleteDirectory(_ path: FilePath, recursive: Bool) async throws { - let _ = try await session.sendRequest(.directoryRemove(handle, path, recursive)) - } - - public func moveEntry(_ sourcePath: FilePath, _ destinationPath: FilePath) async throws { - let _ = try await session.sendRequest(.repositoryMoveEntry(handle, sourcePath, destinationPath)) - } - - public static func == (lhs: OuisyncRepository, rhs: OuisyncRepository) -> Bool { - return lhs.session === rhs.session && lhs.handle == rhs.handle - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(session)) - hasher.combine(handle) - } - - public var debugDescription: String { - return "OuisyncRepository(handle: \(handle))" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift b/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift deleted file mode 100644 index 9944be5d5..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift +++ /dev/null @@ -1,186 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import MessagePack - -public class OuisyncSession { - let configsPath: String - let logsPath: String - - public let client: OuisyncClient - - var nextMessageId: MessageId = 0 - var pendingResponses: [MessageId: CheckedContinuation] = [:] - var notificationSubscriptions: NotificationStream.State = NotificationStream.State() - - public init(_ configsPath: String, _ logsPath: String, _ ffi: OuisyncFFI) throws { - self.configsPath = configsPath - self.logsPath = logsPath - - client = try OuisyncClient.create(configsPath, logsPath, ffi) - client.onReceiveFromBackend = { [weak self] data in - self?.onReceiveDataFromOuisyncLib(data) - } - } - - public func connectNewClient() throws -> OuisyncClient { - return try OuisyncClient.create(configsPath, logsPath, client.ffi) - } - - // Can be called from a separate thread. - public func invoke(_ requestMsg: OuisyncRequestMessage) async -> OuisyncResponseMessage { - let responsePayload: OuisyncResponsePayload - - do { - responsePayload = .response(try await sendRequest(requestMsg.request)) - } catch let e as OuisyncError { - responsePayload = .error(e) - } catch let e { - fatalError("Unhandled exception in OuisyncSession.invoke: \(e)") - } - - return OuisyncResponseMessage(requestMsg.messageId, responsePayload) - } - - public func listRepositories() async throws -> [OuisyncRepository] { - let response = try await sendRequest(OuisyncRequest.listRepositories()); - let handles = response.toUInt64Array() - return handles.map({ OuisyncRepository($0, self) }) - } - - public func subscribeToRepositoryListChange() async throws -> NotificationStream { - let subscriptionId = try await sendRequest(OuisyncRequest.subscribeToRepositoryListChange()).toUInt64(); - return NotificationStream(subscriptionId, notificationSubscriptions) - } - - public func subscribeToRepositoryChange(_ repo: RepositoryHandle) async throws -> NotificationStream { - let subscriptionId = try await sendRequest(OuisyncRequest.subscribeToRepositoryChange(repo)).toUInt64(); - return NotificationStream(subscriptionId, notificationSubscriptions) - } - - // Can be called from a separate thread. - internal func sendRequest(_ request: OuisyncRequest) async throws -> Response { - let messageId = generateMessageId() - - async let onResponse = withCheckedThrowingContinuation { [weak self] continuation in - guard let session = self else { return } - - synchronized(session) { - session.pendingResponses[messageId] = continuation - let data = OuisyncRequestMessage(messageId, request).serialize() - session.client.sendToBackend(data) - } - } - - return try await onResponse - } - - // Can be called from a separate thread. - fileprivate func generateMessageId() -> MessageId { - synchronized(self) { - let messageId = nextMessageId - nextMessageId += 1 - return messageId - } - } - - // Use this function to pass data from the backend. - // It may be called from a separate thread. - public func onReceiveDataFromOuisyncLib(_ data: [UInt8]) { - let maybe_message = OuisyncResponseMessage.deserialize(data) - - guard let message = maybe_message else { - let hex = data.map({String(format:"%02x", $0)}).joined(separator: ",") - // Likely cause is a version mismatch between the backend (Rust) and frontend (Swift) code. - fatalError("Failed to parse incoming message from OuisyncLib [\(hex)]") - } - - switch message.payload { - case .response(let response): - handleResponse(message.messageId, response) - case .notification(let notification): - handleNotification(message.messageId, notification) - case .error(let error): - handleError(message.messageId, error) - } - } - - fileprivate func handleResponse(_ messageId: MessageId, _ response: Response) { - let maybePendingResponse = synchronized(self) { pendingResponses.removeValue(forKey: messageId) } - - guard let pendingResponse = maybePendingResponse else { - fatalError("❗ Failed to match response to a request. messageId:\(messageId), repsponse:\(response) ") - } - - pendingResponse.resume(returning: response) - } - - fileprivate func handleNotification(_ messageId: MessageId, _ response: OuisyncNotification) { - let maybeTx = synchronized(self) { notificationSubscriptions.registrations[messageId] } - - if let tx = maybeTx { - tx.yield(()) - } else { - NSLog("❗ Received unsolicited notification") - } - } - - fileprivate func handleError(_ messageId: MessageId, _ response: OuisyncError) { - let maybePendingResponse = synchronized(self) { pendingResponses.removeValue(forKey: messageId) } - - guard let pendingResponse = maybePendingResponse else { - fatalError("❗ Failed to match error response to a request. messageId:\(messageId), response:\(response)") - } - - pendingResponse.resume(throwing: response) - } - -} - -fileprivate func synchronized(_ lock: AnyObject, _ closure: () throws -> T) rethrows -> T { - objc_sync_enter(lock) - defer { objc_sync_exit(lock) } - return try closure() -} - -public class NotificationStream { - typealias Id = UInt64 - typealias Rx = AsyncStream<()> - typealias RxIter = Rx.AsyncIterator - typealias Tx = Rx.Continuation - - class State { - var registrations: [Id: Tx] = [:] - } - - let subscriptionId: Id - let rx: Rx - var rx_iter: RxIter - var state: State - - init(_ subscriptionId: Id, _ state: State) { - self.subscriptionId = subscriptionId - var tx: Tx! - rx = Rx (bufferingPolicy: Tx.BufferingPolicy.bufferingOldest(1), { tx = $0 }) - self.rx_iter = rx.makeAsyncIterator() - - self.state = state - - state.registrations[subscriptionId] = tx - } - - public func next() async -> ()? { - return await rx_iter.next() - } - - deinit { - // TODO: We should have a `close() async` function where we unsubscripbe - // from the notifications. - state.registrations.removeValue(forKey: subscriptionId) - } -} - diff --git a/bindings/swift/OuisyncLib/Sources/Session.swift b/bindings/swift/OuisyncLib/Sources/Session.swift new file mode 100644 index 000000000..43502dbf6 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Session.swift @@ -0,0 +1,12 @@ +import Foundation + +extension Session { + public static func create(configPath: String) async throws -> Session { + let client = try await Client.connect(configPath: configPath) + return Session(client) + } + + public func close() async { + await client.close() + } +} diff --git a/bindings/swift/OuisyncLib/Sources/StateMonitor.swift b/bindings/swift/OuisyncLib/Sources/StateMonitor.swift new file mode 100644 index 000000000..a13b8431e --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/StateMonitor.swift @@ -0,0 +1,52 @@ +import Foundation +import MessagePack + +public struct MonitorId: Hashable { + public let name: String + public let disambiguator: Int64 + + public static func expectUnique(_ name: String) -> MonitorId { + MonitorId(name: name, disambiguator: 0) + } + + internal func encodeToMsgPack() -> MessagePackValue { + .string("\(name):\(disambiguator)") + } + + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> MonitorId? { + guard case .string(let s) = v else { return nil } + guard let colon = s.lastIndex(of: ":") else { return nil } + let namePart = String(s[.., [MonitorId...]] + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> StateMonitorNode? { + guard case .array(let arr) = v, arr.count == 2 else { return nil } + guard case .map(let valuesMap) = arr[0] else { return nil } + var values: [String: String] = [:] + for (k, v) in valuesMap { + guard case .string(let ks) = k, case .string(let vs) = v else { return nil } + values[ks] = vs + } + guard case .array(let childrenArr) = arr[1] else { return nil } + let children = childrenArr.compactMap { MonitorId.decodeFromMsgPack($0) } + guard children.count == childrenArr.count else { return nil } + return StateMonitorNode(values: values, children: children) + } + + // Encode as array [map, [MonitorId...]] + internal func encodeToMsgPack() -> MessagePackValue { + let valuesMap = MessagePackValue.map( + Dictionary(uniqueKeysWithValues: values.map { (.string($0.key), MessagePackValue.string($0.value)) }) + ) + let childrenArr = MessagePackValue.array(children.map { $0.encodeToMsgPack() }) + return .array([valuesMap, childrenArr]) + } +} diff --git a/bindings/swift/OuisyncLib/Sources/generated/Api.swift b/bindings/swift/OuisyncLib/Sources/generated/Api.swift new file mode 100644 index 000000000..8c8d9f08d --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/generated/Api.swift @@ -0,0 +1,3266 @@ +// Auto-generated by ouisync-bindgen. Do not edit. + +import Foundation +import MessagePack + +/// Symmetric encryption/decryption secret key. +/// +/// Note: this implementation tries to prevent certain types of attacks by making sure the +/// underlying sensitive key material is always stored at most in one place. This is achieved by +/// putting it on the heap which means it is not moved when the key itself is moved which could +/// otherwise leave a copy of the data in memory. Additionally, the data is behind a `Arc` which +/// means the key can be cheaply cloned without actually cloning the data. Finally, the data is +/// scrambled (overwritten with zeros) when the key is dropped to make sure it does not stay in +/// the memory past its lifetime. +public struct SecretKey { + public let value: Data + + public var debugDescription: String { "\(type(of: self))(******)" } + + internal func encodeToMsgPack() -> MessagePackValue { .binary(value) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .binary(let bytes_decoded) = v else { return nil } + let decoded = Data(bytes_decoded) + return Self(value: decoded) + } +} + +/// A simple wrapper over String to avoid certain kinds of attack. For more elaboration please see +/// the documentation for the SecretKey structure. +public struct Password { + public let value: String + + public var debugDescription: String { "\(type(of: self))(******)" } + + internal func encodeToMsgPack() -> MessagePackValue { .string(value) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let decoded = { if case .string(let s) = v { return s }; return nil }() else { return nil } + return Self(value: decoded) + } +} + +public struct PasswordSalt { + public let value: Data + + internal func encodeToMsgPack() -> MessagePackValue { .binary(value) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .binary(let bytes_decoded) = v else { return nil } + let decoded = Data(bytes_decoded) + return Self(value: decoded) + } +} + +/// Strongly typed storage size. +public struct StorageSize { + public let bytes: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.uint(UInt64(bytes))]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 1 else { return nil } + guard let bytes = _msgpackUInt(arr[0]) else { return nil } + return Self(bytes: bytes) + } +} + +/// Access mode of a repository. +public enum AccessMode: UInt8 { + /// Repository is neither readable not writtable (but can still be synced). + case blind = 0 + /// Repository is readable but not writtable. + case read = 1 + /// Repository is both readable and writable. + case write = 2 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(rawValue)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let n = _msgpackUInt(v), let r = UInt8(exactly: n) else { return nil } + return Self(rawValue: r) + } +} + +/// Type of secret to unlock a repository. +public enum LocalSecret { + /// Password provided by the user + case password(_ value: Password) + /// Secret key generated by secure means (e.g., crypto-secure RNG, KDF, ...) + case secretKey(_ value: SecretKey) + + internal func encodeToMsgPack() -> MessagePackValue { + switch self { + case .password(let value): return .map([.string("Password"): value.encodeToMsgPack()]) + case .secretKey(let value): return .map([.string("SecretKey"): value.encodeToMsgPack()]) + } + } + + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + switch v { + case .map(let m) where m.count == 1: + guard let entry = m.first, case .string(let name) = entry.key else { return nil } + switch name { + case "Password": + guard let decoded = Password.decodeFromMsgPack(entry.value) else { return nil } + return .password(decoded) + case "SecretKey": + guard let decoded = SecretKey.decodeFromMsgPack(entry.value) else { return nil } + return .secretKey(decoded) + default: return nil + } + default: return nil + } + } +} + +/// Used to set or change the read or write local secret of a repository. +public enum SetLocalSecret { + /// Password provided by the user + case password(_ value: Password) + /// Use to directly (without doing password hashing) set the `SecretKey` and `PasswordSalt` for + /// read or write access. + case keyAndSalt(key: SecretKey, salt: PasswordSalt) + + internal func encodeToMsgPack() -> MessagePackValue { + switch self { + case .password(let value): return .map([.string("Password"): value.encodeToMsgPack()]) + case .keyAndSalt(let key, let salt): return .map([.string("KeyAndSalt"): .array([key.encodeToMsgPack(), salt.encodeToMsgPack()])]) + } + } + + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + switch v { + case .map(let m) where m.count == 1: + guard let entry = m.first, case .string(let name) = entry.key else { return nil } + switch name { + case "Password": + guard let decoded = Password.decodeFromMsgPack(entry.value) else { return nil } + return .password(decoded) + case "KeyAndSalt": + guard case .array(let arr) = entry.value, arr.count == 2 else { return nil } + guard let key = SecretKey.decodeFromMsgPack(arr[0]) else { return nil } + guard let salt = PasswordSalt.decodeFromMsgPack(arr[1]) else { return nil } + return .keyAndSalt(key: key, salt: salt) + default: return nil + } + default: return nil + } + } +} + +/// Token to share a repository. It can be encoded as a URL-formatted string and transmitted to +/// other replicas. +public struct ShareToken { + public let value: String + + internal func encodeToMsgPack() -> MessagePackValue { .string(value) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let decoded = { if case .string(let s) = v { return s }; return nil }() else { return nil } + return Self(value: decoded) + } +} + +/// How to change access to a repository. +public enum AccessChange { + /// Enable read or write access, optionally with local secret + case enable(_ value: SetLocalSecret?) + /// Disable access + case disable + + internal func encodeToMsgPack() -> MessagePackValue { + switch self { + case .enable(let value): return .map([.string("Enable"): { if let x = value { return x.encodeToMsgPack() } else { return .nil } }()]) + case .disable: return .string("Disable") + } + } + + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + switch v { + case .string(let name): + switch name { + case "Disable": return .disable + default: return nil + } + case .map(let m) where m.count == 1: + guard let entry = m.first, case .string(let name) = entry.key else { return nil } + switch name { + case "Enable": + let decoded: SetLocalSecret? + if case .nil = entry.value { + decoded = nil + } else { + guard let tmp_decoded = SetLocalSecret.decodeFromMsgPack(entry.value) else { return nil } + decoded = tmp_decoded + } + return .enable(decoded) + default: return nil + } + default: return nil + } + } +} + +/// Type of filesystem entry. +public enum EntryType: UInt8 { + case file = 1 + case directory = 2 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(rawValue)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let n = _msgpackUInt(v), let r = UInt8(exactly: n) else { return nil } + return Self(rawValue: r) + } +} + +/// Network notification event. +public enum NetworkEvent: UInt8 { + /// A peer has appeared with higher protocol version than us. Probably means we are using + /// outdated library. This event can be used to notify the user that they should update the app. + case protocolVersionMismatch = 0 + /// The set of known peers has changed (e.g., a new peer has been discovered) + case peerSetChange = 1 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(rawValue)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let n = _msgpackUInt(v), let r = UInt8(exactly: n) else { return nil } + return Self(rawValue: r) + } +} + +/// Information about a peer. +public struct PeerInfo { + public let addr: String + public let source: PeerSource + public let state: PeerState + public let stats: Stats + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.string(addr), source.encodeToMsgPack(), state.encodeToMsgPack(), stats.encodeToMsgPack()]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 4 else { return nil } + guard let addr = { if case .string(let s) = arr[0] { return s }; return nil }() else { return nil } + guard let source = PeerSource.decodeFromMsgPack(arr[1]) else { return nil } + guard let state = PeerState.decodeFromMsgPack(arr[2]) else { return nil } + guard let stats = Stats.decodeFromMsgPack(arr[3]) else { return nil } + return Self(addr: addr, source: source, state: state, stats: stats) + } +} + +/// How was the peer discovered. +public enum PeerSource: UInt8 { + /// Explicitly added by the user. + case userProvided = 0 + /// Peer connected to us. + case listener = 1 + /// Discovered on the Local Discovery. + case localDiscovery = 2 + /// Discovered on the DHT. + case dht = 3 + /// Discovered on the Peer Exchange. + case peerExchange = 4 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(rawValue)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let n = _msgpackUInt(v), let r = UInt8(exactly: n) else { return nil } + return Self(rawValue: r) + } +} + +public enum PeerState { + case known + case connecting + case handshaking + case active(id: PublicRuntimeId, since: Date) + + internal func encodeToMsgPack() -> MessagePackValue { + switch self { + case .known: return .string("Known") + case .connecting: return .string("Connecting") + case .handshaking: return .string("Handshaking") + case .active(let id, let since): return .map([.string("Active"): .array([id.encodeToMsgPack(), .int(Int64(since.timeIntervalSince1970 * 1000))])]) + } + } + + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + switch v { + case .string(let name): + switch name { + case "Known": return .known + case "Connecting": return .connecting + case "Handshaking": return .handshaking + default: return nil + } + case .map(let m) where m.count == 1: + guard let entry = m.first, case .string(let name) = entry.key else { return nil } + switch name { + case "Active": + guard case .array(let arr) = entry.value, arr.count == 2 else { return nil } + guard let id = PublicRuntimeId.decodeFromMsgPack(arr[0]) else { return nil } + guard let since = (_msgpackInt(arr[1]).map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) }) else { return nil } + return .active(id: id, since: since) + default: return nil + } + default: return nil + } + } +} + +public struct PublicRuntimeId { + public let value: Data + + internal func encodeToMsgPack() -> MessagePackValue { .binary(value) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .binary(let bytes_decoded) = v else { return nil } + let decoded = Data(bytes_decoded) + return Self(value: decoded) + } +} + +/// Network traffic statistics. +public struct Stats { + public let bytesTx: UInt64 + public let bytesRx: UInt64 + public let throughputTx: UInt64 + public let throughputRx: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.uint(UInt64(bytesTx)), .uint(UInt64(bytesRx)), .uint(UInt64(throughputTx)), .uint(UInt64(throughputRx))]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 4 else { return nil } + guard let bytesTx = _msgpackUInt(arr[0]) else { return nil } + guard let bytesRx = _msgpackUInt(arr[1]) else { return nil } + guard let throughputTx = _msgpackUInt(arr[2]) else { return nil } + guard let throughputRx = _msgpackUInt(arr[3]) else { return nil } + return Self(bytesTx: bytesTx, bytesRx: bytesRx, throughputTx: throughputTx, throughputRx: throughputRx) + } +} + +/// Progress of a task. +public struct Progress { + public let value: UInt64 + public let total: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.uint(UInt64(value)), .uint(UInt64(total))]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 2 else { return nil } + guard let value = _msgpackUInt(arr[0]) else { return nil } + guard let total = _msgpackUInt(arr[1]) else { return nil } + return Self(value: value, total: total) + } +} + +/// Identified of a network stream topic. +/// +/// When two connected peers open a stream with the same topic id, they can communicate over it with +/// each other. +public struct TopicId { + public let value: Data + + internal func encodeToMsgPack() -> MessagePackValue { .binary(value) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .binary(let bytes_decoded) = v else { return nil } + let decoded = Data(bytes_decoded) + return Self(value: decoded) + } +} + +public enum NatBehavior: UInt8 { + case endpointIndependent = 0 + case addressDependent = 1 + case addressAndPortDependent = 2 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(rawValue)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let n = _msgpackUInt(v), let r = UInt8(exactly: n) else { return nil } + return Self(rawValue: r) + } +} + +public enum ErrorCode: UInt16 { + /// No error + case ok = 0 + /// Insuficient permission to perform the intended operation + case permissionDenied = 1 + /// Invalid input parameter + case invalidInput = 2 + /// Invalid data (e.g., malformed incoming message, config file, etc...) + case invalidData = 3 + /// Entry already exists + case alreadyExists = 4 + /// Entry not found + case notFound = 5 + /// Multiple matching entries found + case ambiguous = 6 + /// The indended operation is not supported + case unsupported = 8 + /// The operation was interrupted + case interrupted = 9 + /// Failed to establish connection to the server + case connectionRefused = 1025 + /// Connection aborted by the server + case connectionAborted = 1026 + /// Failed to send or receive message + case transportError = 1027 + /// Listener failed to bind to the specified address + case listenerBindError = 1028 + /// Listener failed to accept client connection + case listenerAcceptError = 1029 + /// Operation on the internal repository store failed + case storeError = 2049 + /// Entry was expected to not be a directory but it is + case isDirectory = 2050 + /// Entry was expected to be a directory but it isn't + case notDirectory = 2051 + /// Directory was expected to be empty but it isn't + case directoryNotEmpty = 2052 + /// File or directory is busy + case resourceBusy = 2053 + /// Failed to initialize runtime + case runtimeInitializeError = 4097 + /// Failed to read from or write into the config file + case configError = 4099 + /// TLS certificated not found + case tlsCertificatesNotFound = 4100 + /// TLS certificates failed to load + case tlsCertificatesInvalid = 4101 + /// TLS keys not found + case tlsKeysNotFound = 4102 + /// Failed to create TLS config + case tlsConfigError = 4103 + /// Failed to install virtual filesystem driver + case vfsDriverInstallError = 4104 + /// Unspecified virtual filesystem error + case vfsOtherError = 4105 + /// Another instance of the service is already running + case serviceAlreadyRunning = 4106 + /// Store directory is not specified + case storeDirUnspecified = 4107 + /// Mount directory is not specified + case mountDirUnspecified = 4108 + /// Unspecified error + case other = 65535 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(rawValue)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let n = _msgpackUInt(v), let r = UInt16(exactly: n) else { return nil } + return Self(rawValue: r) + } +} + +public class OuisyncError: Error { + public let code: ErrorCode + public let message: String? + public let sources: [String] + + public init(_ code: ErrorCode, _ message: String? = nil, sources: [String] = []) { + self.code = code + self.message = message + self.sources = sources + } + + /// Insuficient permission to perform the intended operation + public class PermissionDenied: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.permissionDenied, message, sources: sources) + } + } + + /// Invalid input parameter + public class InvalidInput: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.invalidInput, message, sources: sources) + } + } + + /// Invalid data (e.g., malformed incoming message, config file, etc...) + public class InvalidData: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.invalidData, message, sources: sources) + } + } + + /// Entry already exists + public class AlreadyExists: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.alreadyExists, message, sources: sources) + } + } + + /// Entry not found + public class NotFound: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.notFound, message, sources: sources) + } + } + + /// Multiple matching entries found + public class Ambiguous: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.ambiguous, message, sources: sources) + } + } + + /// The indended operation is not supported + public class Unsupported: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.unsupported, message, sources: sources) + } + } + + /// The operation was interrupted + public class Interrupted: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.interrupted, message, sources: sources) + } + } + + /// Failed to establish connection to the server + public class ConnectionRefused: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.connectionRefused, message, sources: sources) + } + } + + /// Connection aborted by the server + public class ConnectionAborted: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.connectionAborted, message, sources: sources) + } + } + + /// Failed to send or receive message + public class TransportError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.transportError, message, sources: sources) + } + } + + /// Listener failed to bind to the specified address + public class ListenerBindError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.listenerBindError, message, sources: sources) + } + } + + /// Listener failed to accept client connection + public class ListenerAcceptError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.listenerAcceptError, message, sources: sources) + } + } + + /// Operation on the internal repository store failed + public class StoreError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.storeError, message, sources: sources) + } + } + + /// Entry was expected to not be a directory but it is + public class IsDirectory: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.isDirectory, message, sources: sources) + } + } + + /// Entry was expected to be a directory but it isn't + public class NotDirectory: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.notDirectory, message, sources: sources) + } + } + + /// Directory was expected to be empty but it isn't + public class DirectoryNotEmpty: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.directoryNotEmpty, message, sources: sources) + } + } + + /// File or directory is busy + public class ResourceBusy: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.resourceBusy, message, sources: sources) + } + } + + /// Failed to initialize runtime + public class RuntimeInitializeError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.runtimeInitializeError, message, sources: sources) + } + } + + /// Failed to read from or write into the config file + public class ConfigError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.configError, message, sources: sources) + } + } + + /// TLS certificated not found + public class TlsCertificatesNotFound: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.tlsCertificatesNotFound, message, sources: sources) + } + } + + /// TLS certificates failed to load + public class TlsCertificatesInvalid: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.tlsCertificatesInvalid, message, sources: sources) + } + } + + /// TLS keys not found + public class TlsKeysNotFound: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.tlsKeysNotFound, message, sources: sources) + } + } + + /// Failed to create TLS config + public class TlsConfigError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.tlsConfigError, message, sources: sources) + } + } + + /// Failed to install virtual filesystem driver + public class VfsDriverInstallError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.vfsDriverInstallError, message, sources: sources) + } + } + + /// Unspecified virtual filesystem error + public class VfsOtherError: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.vfsOtherError, message, sources: sources) + } + } + + /// Another instance of the service is already running + public class ServiceAlreadyRunning: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.serviceAlreadyRunning, message, sources: sources) + } + } + + /// Store directory is not specified + public class StoreDirUnspecified: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.storeDirUnspecified, message, sources: sources) + } + } + + /// Mount directory is not specified + public class MountDirUnspecified: OuisyncError { + public init(_ message: String? = nil, sources: [String] = []) { + super.init(.mountDirUnspecified, message, sources: sources) + } + } + + public static func dispatch(_ code: ErrorCode, _ message: String? = nil, sources: [String] = []) -> OuisyncError { + switch code { + case .permissionDenied: return PermissionDenied(message, sources: sources) + case .invalidInput: return InvalidInput(message, sources: sources) + case .invalidData: return InvalidData(message, sources: sources) + case .alreadyExists: return AlreadyExists(message, sources: sources) + case .notFound: return NotFound(message, sources: sources) + case .ambiguous: return Ambiguous(message, sources: sources) + case .unsupported: return Unsupported(message, sources: sources) + case .interrupted: return Interrupted(message, sources: sources) + case .connectionRefused: return ConnectionRefused(message, sources: sources) + case .connectionAborted: return ConnectionAborted(message, sources: sources) + case .transportError: return TransportError(message, sources: sources) + case .listenerBindError: return ListenerBindError(message, sources: sources) + case .listenerAcceptError: return ListenerAcceptError(message, sources: sources) + case .storeError: return StoreError(message, sources: sources) + case .isDirectory: return IsDirectory(message, sources: sources) + case .notDirectory: return NotDirectory(message, sources: sources) + case .directoryNotEmpty: return DirectoryNotEmpty(message, sources: sources) + case .resourceBusy: return ResourceBusy(message, sources: sources) + case .runtimeInitializeError: return RuntimeInitializeError(message, sources: sources) + case .configError: return ConfigError(message, sources: sources) + case .tlsCertificatesNotFound: return TlsCertificatesNotFound(message, sources: sources) + case .tlsCertificatesInvalid: return TlsCertificatesInvalid(message, sources: sources) + case .tlsKeysNotFound: return TlsKeysNotFound(message, sources: sources) + case .tlsConfigError: return TlsConfigError(message, sources: sources) + case .vfsDriverInstallError: return VfsDriverInstallError(message, sources: sources) + case .vfsOtherError: return VfsOtherError(message, sources: sources) + case .serviceAlreadyRunning: return ServiceAlreadyRunning(message, sources: sources) + case .storeDirUnspecified: return StoreDirUnspecified(message, sources: sources) + case .mountDirUnspecified: return MountDirUnspecified(message, sources: sources) + default: return OuisyncError(code, message, sources: sources) + } + } + + public var description: String { + let all = ([message] + sources.map { Optional($0) }).compactMap { $0 } + return "\(type(of: self)): \(all.joined(separator: " → "))" + } +} + +public enum LogLevel: UInt8 { + case error = 1 + case warn = 2 + case info = 3 + case debug = 4 + case trace = 5 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(rawValue)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let n = _msgpackUInt(v), let r = UInt8(exactly: n) else { return nil } + return Self(rawValue: r) + } +} + +public struct MessageId { + public let value: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(value)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let decoded = _msgpackUInt(v) else { return nil } + return Self(value: decoded) + } +} + +/// Edit of a single metadata entry. +public struct MetadataEdit { + public let key: String + public let oldValue: String? + public let newValue: String? + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.string(key), { if let x = oldValue { return .string(x) } else { return .nil } }(), { if let x = newValue { return .string(x) } else { return .nil } }()]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 3 else { return nil } + guard let key = { if case .string(let s) = arr[0] { return s }; return nil }() else { return nil } + let oldValue: String? + if case .nil = arr[1] { + oldValue = nil + } else { + guard let tmp_oldValue = { if case .string(let s) = arr[1] { return s }; return nil }() else { return nil } + oldValue = tmp_oldValue + } + let newValue: String? + if case .nil = arr[2] { + newValue = nil + } else { + guard let tmp_newValue = { if case .string(let s) = arr[2] { return s }; return nil }() else { return nil } + newValue = tmp_newValue + } + return Self(key: key, oldValue: oldValue, newValue: newValue) + } +} + +/// Default network parameters +public struct NetworkDefaults { + public let bind: [String] + public let portForwardingEnabled: Bool + public let localDiscoveryEnabled: Bool + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.array(bind.map { .string($0) }), .bool(portForwardingEnabled), .bool(localDiscoveryEnabled)]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 3 else { return nil } + guard case .array(let arr_bind) = arr[0] else { return nil } + var bind: [String] = [] + for elem_bind in arr_bind { + guard let x_bind = { if case .string(let s) = elem_bind { return s }; return nil }() else { return nil } + bind.append(x_bind) + } + guard let portForwardingEnabled = { if case .bool(let b) = arr[1] { return b }; return nil }() else { return nil } + guard let localDiscoveryEnabled = { if case .bool(let b) = arr[2] { return b }; return nil }() else { return nil } + return Self(bind: bind, portForwardingEnabled: portForwardingEnabled, localDiscoveryEnabled: localDiscoveryEnabled) + } +} + +public struct DirectoryEntry { + public let name: String + public let entryType: EntryType + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.string(name), entryType.encodeToMsgPack()]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 2 else { return nil } + guard let name = { if case .string(let s) = arr[0] { return s }; return nil }() else { return nil } + guard let entryType = EntryType.decodeFromMsgPack(arr[1]) else { return nil } + return Self(name: name, entryType: entryType) + } +} + +public struct QuotaInfo { + public let quota: StorageSize? + public let size: StorageSize + + internal func encodeToMsgPack() -> MessagePackValue { + .array([{ if let x = quota { return x.encodeToMsgPack() } else { return .nil } }(), size.encodeToMsgPack()]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 2 else { return nil } + let quota: StorageSize? + if case .nil = arr[0] { + quota = nil + } else { + guard let tmp_quota = StorageSize.decodeFromMsgPack(arr[0]) else { return nil } + quota = tmp_quota + } + guard let size = StorageSize.decodeFromMsgPack(arr[1]) else { return nil } + return Self(quota: quota, size: size) + } +} + +public struct Datagram { + public let data: Data + public let addr: String + + internal func encodeToMsgPack() -> MessagePackValue { + .array([.binary(data), .string(addr)]) + } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard case .array(let arr) = v, arr.count == 2 else { return nil } + guard case .binary(let bytes_data) = arr[0] else { return nil } + let data = Data(bytes_data) + guard let addr = { if case .string(let s) = arr[1] { return s }; return nil }() else { return nil } + return Self(data: data, addr: addr) + } +} + +public struct OuisyncFileHandle { + public let value: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(value)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let decoded = _msgpackUInt(v) else { return nil } + return Self(value: decoded) + } +} + +public struct RepositoryHandle { + public let value: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(value)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let decoded = _msgpackUInt(v) else { return nil } + return Self(value: decoded) + } +} + +public struct NetworkSocketHandle { + public let value: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(value)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let decoded = _msgpackUInt(v) else { return nil } + return Self(value: decoded) + } +} + +public struct NetworkStreamHandle { + public let value: UInt64 + + internal func encodeToMsgPack() -> MessagePackValue { .uint(UInt64(value)) } + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { + guard let decoded = _msgpackUInt(v) else { return nil } + return Self(value: decoded) + } +} + +internal enum Request { + case cancel(_ id: MessageId) + case fileClose(_ file: OuisyncFileHandle) + case fileFlush(_ file: OuisyncFileHandle) + case fileGetLength(_ file: OuisyncFileHandle) + case fileGetProgress(_ file: OuisyncFileHandle) + case fileRead(_ file: OuisyncFileHandle, _ offset: UInt64, _ size: UInt64) + case fileTruncate(_ file: OuisyncFileHandle, _ len: UInt64) + case fileWrite(_ file: OuisyncFileHandle, _ offset: UInt64, _ data: Data) + case networkSocketClose(_ socket: NetworkSocketHandle) + case networkSocketRecvFrom(_ socket: NetworkSocketHandle, _ len: UInt64) + case networkSocketSendTo(_ socket: NetworkSocketHandle, _ data: Data, _ addr: String) + case networkStreamClose(_ stream: NetworkStreamHandle) + case networkStreamReadExact(_ stream: NetworkStreamHandle, _ len: UInt64) + case networkStreamWriteAll(_ stream: NetworkStreamHandle, _ buf: Data) + case repositoryClose(_ repo: RepositoryHandle) + case repositoryCreateDirectory(_ repo: RepositoryHandle, _ path: String) + case repositoryCreateFile(_ repo: RepositoryHandle, _ path: String) + case repositoryCreateMirror(_ repo: RepositoryHandle, _ host: String) + case repositoryDelete(_ repo: RepositoryHandle) + case repositoryDeleteMirror(_ repo: RepositoryHandle, _ host: String) + case repositoryExport(_ repo: RepositoryHandle, _ outputPath: String) + case repositoryFileExists(_ repo: RepositoryHandle, _ path: String) + case repositoryGetAccessMode(_ repo: RepositoryHandle) + case repositoryGetBlockExpiration(_ repo: RepositoryHandle) + case repositoryGetCredentials(_ repo: RepositoryHandle) + case repositoryGetEntryType(_ repo: RepositoryHandle, _ path: String) + case repositoryGetExpiration(_ repo: RepositoryHandle) + case repositoryGetInfoHash(_ repo: RepositoryHandle) + case repositoryGetMetadata(_ repo: RepositoryHandle, _ key: String) + case repositoryGetMountPoint(_ repo: RepositoryHandle) + case repositoryGetPath(_ repo: RepositoryHandle) + case repositoryGetQuota(_ repo: RepositoryHandle) + case repositoryGetShortName(_ repo: RepositoryHandle) + case repositoryGetStats(_ repo: RepositoryHandle) + case repositoryGetSyncProgress(_ repo: RepositoryHandle) + case repositoryIsDhtEnabled(_ repo: RepositoryHandle) + case repositoryIsPexEnabled(_ repo: RepositoryHandle) + case repositoryIsSyncEnabled(_ repo: RepositoryHandle) + case repositoryMirrorExists(_ repo: RepositoryHandle, _ host: String) + case repositoryMount(_ repo: RepositoryHandle) + case repositoryMove(_ repo: RepositoryHandle, _ dst: String) + case repositoryMoveEntry(_ repo: RepositoryHandle, _ src: String, _ dst: String) + case repositoryOpenFile(_ repo: RepositoryHandle, _ path: String) + case repositoryReadDirectory(_ repo: RepositoryHandle, _ path: String) + case repositoryRemoveDirectory(_ repo: RepositoryHandle, _ path: String, _ recursive: Bool) + case repositoryRemoveFile(_ repo: RepositoryHandle, _ path: String) + case repositoryResetAccess(_ repo: RepositoryHandle, _ token: ShareToken) + case repositorySetAccess(_ repo: RepositoryHandle, _ read: AccessChange?, _ write: AccessChange?) + case repositorySetAccessMode(_ repo: RepositoryHandle, _ accessMode: AccessMode, _ localSecret: LocalSecret?) + case repositorySetBlockExpiration(_ repo: RepositoryHandle, _ value: TimeInterval?) + case repositorySetCredentials(_ repo: RepositoryHandle, _ credentials: Data) + case repositorySetDhtEnabled(_ repo: RepositoryHandle, _ enabled: Bool) + case repositorySetExpiration(_ repo: RepositoryHandle, _ value: TimeInterval?) + case repositorySetMetadata(_ repo: RepositoryHandle, _ edits: [MetadataEdit]) + case repositorySetPexEnabled(_ repo: RepositoryHandle, _ enabled: Bool) + case repositorySetQuota(_ repo: RepositoryHandle, _ value: StorageSize?) + case repositorySetSyncEnabled(_ repo: RepositoryHandle, _ enabled: Bool) + case repositoryShare(_ repo: RepositoryHandle, _ accessMode: AccessMode, _ localSecret: LocalSecret?) + case repositorySubscribe(_ repo: RepositoryHandle) + case repositoryUnmount(_ repo: RepositoryHandle) + case sessionAddUserProvidedPeers(_ addrs: [String]) + case sessionBindMetrics(_ addr: String?) + case sessionBindNetwork(_ addrs: [String]) + case sessionBindRemoteControl(_ addr: String?) + case sessionCopy(_ srcRepo: String?, _ srcPath: String, _ dstRepo: String?, _ dstPath: String) + case sessionCreateRepository(_ path: String, _ readSecret: SetLocalSecret?, _ writeSecret: SetLocalSecret?, _ token: ShareToken?, _ syncEnabled: Bool, _ dhtEnabled: Bool, _ pexEnabled: Bool) + case sessionDeleteRepositoryByName(_ name: String) + case sessionDeriveSecretKey(_ password: Password, _ salt: PasswordSalt) + case sessionDhtLookup(_ infoHash: String, _ announce: Bool) + case sessionFindRepository(_ name: String) + case sessionGeneratePasswordSalt + case sessionGenerateSecretKey + case sessionGetCurrentProtocolVersion + case sessionGetDefaultBlockExpiration + case sessionGetDefaultQuota + case sessionGetDefaultRepositoryExpiration + case sessionGetDhtRouters + case sessionGetExternalAddrV4 + case sessionGetExternalAddrV6 + case sessionGetHighestSeenProtocolVersion + case sessionGetLocalListenerAddrs + case sessionGetMetricsListenerAddr + case sessionGetMountRoot + case sessionGetNatBehavior + case sessionGetNetworkStats + case sessionGetPeers + case sessionGetRemoteControlListenerAddr + case sessionGetRemoteListenerAddrs(_ host: String) + case sessionGetRuntimeId + case sessionGetShareTokenAccessMode(_ token: ShareToken) + case sessionGetShareTokenInfoHash(_ token: ShareToken) + case sessionGetShareTokenSuggestedName(_ token: ShareToken) + case sessionGetStateMonitor(_ path: [MonitorId]) + case sessionGetStoreDirs + case sessionGetUserProvidedPeers + case sessionInitNetwork(_ defaults: NetworkDefaults) + case sessionInsertStoreDirs(_ paths: [String]) + case sessionIsLocalDhtEnabled + case sessionIsLocalDiscoveryEnabled + case sessionIsPexRecvEnabled + case sessionIsPexSendEnabled + case sessionIsPortForwardingEnabled + case sessionListRepositories + case sessionMirrorExists(_ token: ShareToken, _ host: String) + case sessionOpenNetworkSocketV4 + case sessionOpenNetworkSocketV6 + case sessionOpenNetworkStream(_ addr: String, _ topicId: TopicId) + case sessionOpenRepository(_ path: String, _ localSecret: LocalSecret?) + case sessionPinDht + case sessionRemoveStoreDirs(_ paths: [String]) + case sessionRemoveUserProvidedPeers(_ addrs: [String]) + case sessionSetDefaultBlockExpiration(_ value: TimeInterval?) + case sessionSetDefaultQuota(_ value: StorageSize?) + case sessionSetDefaultRepositoryExpiration(_ value: TimeInterval?) + case sessionSetDhtRouters(_ routers: [String]) + case sessionSetLocalDhtEnabled(_ enabled: Bool) + case sessionSetLocalDiscoveryEnabled(_ enabled: Bool) + case sessionSetMountRoot(_ path: String?) + case sessionSetPexRecvEnabled(_ enabled: Bool) + case sessionSetPexSendEnabled(_ enabled: Bool) + case sessionSetPortForwardingEnabled(_ enabled: Bool) + case sessionSetStoreDirs(_ paths: [String]) + case sessionSubscribeToNetwork + case sessionSubscribeToStateMonitor(_ path: [MonitorId]) + case sessionUnpinDht + case sessionValidateShareToken(_ token: String) + + internal func encodeToMsgPack() -> MessagePackValue { + switch self { + case .cancel(let id): return .map([.string("Cancel"): .array([id.encodeToMsgPack()])]) + case .fileClose(let file): return .map([.string("FileClose"): .array([file.encodeToMsgPack()])]) + case .fileFlush(let file): return .map([.string("FileFlush"): .array([file.encodeToMsgPack()])]) + case .fileGetLength(let file): return .map([.string("FileGetLength"): .array([file.encodeToMsgPack()])]) + case .fileGetProgress(let file): return .map([.string("FileGetProgress"): .array([file.encodeToMsgPack()])]) + case .fileRead(let file, let offset, let size): return .map([.string("FileRead"): .array([file.encodeToMsgPack(), .uint(UInt64(offset)), .uint(UInt64(size))])]) + case .fileTruncate(let file, let len): return .map([.string("FileTruncate"): .array([file.encodeToMsgPack(), .uint(UInt64(len))])]) + case .fileWrite(let file, let offset, let data): return .map([.string("FileWrite"): .array([file.encodeToMsgPack(), .uint(UInt64(offset)), .binary(data)])]) + case .networkSocketClose(let socket): return .map([.string("NetworkSocketClose"): .array([socket.encodeToMsgPack()])]) + case .networkSocketRecvFrom(let socket, let len): return .map([.string("NetworkSocketRecvFrom"): .array([socket.encodeToMsgPack(), .uint(UInt64(len))])]) + case .networkSocketSendTo(let socket, let data, let addr): return .map([.string("NetworkSocketSendTo"): .array([socket.encodeToMsgPack(), .binary(data), .string(addr)])]) + case .networkStreamClose(let stream): return .map([.string("NetworkStreamClose"): .array([stream.encodeToMsgPack()])]) + case .networkStreamReadExact(let stream, let len): return .map([.string("NetworkStreamReadExact"): .array([stream.encodeToMsgPack(), .uint(UInt64(len))])]) + case .networkStreamWriteAll(let stream, let buf): return .map([.string("NetworkStreamWriteAll"): .array([stream.encodeToMsgPack(), .binary(buf)])]) + case .repositoryClose(let repo): return .map([.string("RepositoryClose"): .array([repo.encodeToMsgPack()])]) + case .repositoryCreateDirectory(let repo, let path): return .map([.string("RepositoryCreateDirectory"): .array([repo.encodeToMsgPack(), .string(path)])]) + case .repositoryCreateFile(let repo, let path): return .map([.string("RepositoryCreateFile"): .array([repo.encodeToMsgPack(), .string(path)])]) + case .repositoryCreateMirror(let repo, let host): return .map([.string("RepositoryCreateMirror"): .array([repo.encodeToMsgPack(), .string(host)])]) + case .repositoryDelete(let repo): return .map([.string("RepositoryDelete"): .array([repo.encodeToMsgPack()])]) + case .repositoryDeleteMirror(let repo, let host): return .map([.string("RepositoryDeleteMirror"): .array([repo.encodeToMsgPack(), .string(host)])]) + case .repositoryExport(let repo, let outputPath): return .map([.string("RepositoryExport"): .array([repo.encodeToMsgPack(), .string(outputPath)])]) + case .repositoryFileExists(let repo, let path): return .map([.string("RepositoryFileExists"): .array([repo.encodeToMsgPack(), .string(path)])]) + case .repositoryGetAccessMode(let repo): return .map([.string("RepositoryGetAccessMode"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetBlockExpiration(let repo): return .map([.string("RepositoryGetBlockExpiration"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetCredentials(let repo): return .map([.string("RepositoryGetCredentials"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetEntryType(let repo, let path): return .map([.string("RepositoryGetEntryType"): .array([repo.encodeToMsgPack(), .string(path)])]) + case .repositoryGetExpiration(let repo): return .map([.string("RepositoryGetExpiration"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetInfoHash(let repo): return .map([.string("RepositoryGetInfoHash"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetMetadata(let repo, let key): return .map([.string("RepositoryGetMetadata"): .array([repo.encodeToMsgPack(), .string(key)])]) + case .repositoryGetMountPoint(let repo): return .map([.string("RepositoryGetMountPoint"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetPath(let repo): return .map([.string("RepositoryGetPath"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetQuota(let repo): return .map([.string("RepositoryGetQuota"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetShortName(let repo): return .map([.string("RepositoryGetShortName"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetStats(let repo): return .map([.string("RepositoryGetStats"): .array([repo.encodeToMsgPack()])]) + case .repositoryGetSyncProgress(let repo): return .map([.string("RepositoryGetSyncProgress"): .array([repo.encodeToMsgPack()])]) + case .repositoryIsDhtEnabled(let repo): return .map([.string("RepositoryIsDhtEnabled"): .array([repo.encodeToMsgPack()])]) + case .repositoryIsPexEnabled(let repo): return .map([.string("RepositoryIsPexEnabled"): .array([repo.encodeToMsgPack()])]) + case .repositoryIsSyncEnabled(let repo): return .map([.string("RepositoryIsSyncEnabled"): .array([repo.encodeToMsgPack()])]) + case .repositoryMirrorExists(let repo, let host): return .map([.string("RepositoryMirrorExists"): .array([repo.encodeToMsgPack(), .string(host)])]) + case .repositoryMount(let repo): return .map([.string("RepositoryMount"): .array([repo.encodeToMsgPack()])]) + case .repositoryMove(let repo, let dst): return .map([.string("RepositoryMove"): .array([repo.encodeToMsgPack(), .string(dst)])]) + case .repositoryMoveEntry(let repo, let src, let dst): return .map([.string("RepositoryMoveEntry"): .array([repo.encodeToMsgPack(), .string(src), .string(dst)])]) + case .repositoryOpenFile(let repo, let path): return .map([.string("RepositoryOpenFile"): .array([repo.encodeToMsgPack(), .string(path)])]) + case .repositoryReadDirectory(let repo, let path): return .map([.string("RepositoryReadDirectory"): .array([repo.encodeToMsgPack(), .string(path)])]) + case .repositoryRemoveDirectory(let repo, let path, let recursive): return .map([.string("RepositoryRemoveDirectory"): .array([repo.encodeToMsgPack(), .string(path), .bool(recursive)])]) + case .repositoryRemoveFile(let repo, let path): return .map([.string("RepositoryRemoveFile"): .array([repo.encodeToMsgPack(), .string(path)])]) + case .repositoryResetAccess(let repo, let token): return .map([.string("RepositoryResetAccess"): .array([repo.encodeToMsgPack(), token.encodeToMsgPack()])]) + case .repositorySetAccess(let repo, let read, let write): return .map([.string("RepositorySetAccess"): .array([repo.encodeToMsgPack(), { if let x = read { return x.encodeToMsgPack() } else { return .nil } }(), { if let x = write { return x.encodeToMsgPack() } else { return .nil } }()])]) + case .repositorySetAccessMode(let repo, let accessMode, let localSecret): return .map([.string("RepositorySetAccessMode"): .array([repo.encodeToMsgPack(), accessMode.encodeToMsgPack(), { if let x = localSecret { return x.encodeToMsgPack() } else { return .nil } }()])]) + case .repositorySetBlockExpiration(let repo, let value): return .map([.string("RepositorySetBlockExpiration"): .array([repo.encodeToMsgPack(), { if let x = value { return .int(Int64(x * 1000)) } else { return .nil } }()])]) + case .repositorySetCredentials(let repo, let credentials): return .map([.string("RepositorySetCredentials"): .array([repo.encodeToMsgPack(), .binary(credentials)])]) + case .repositorySetDhtEnabled(let repo, let enabled): return .map([.string("RepositorySetDhtEnabled"): .array([repo.encodeToMsgPack(), .bool(enabled)])]) + case .repositorySetExpiration(let repo, let value): return .map([.string("RepositorySetExpiration"): .array([repo.encodeToMsgPack(), { if let x = value { return .int(Int64(x * 1000)) } else { return .nil } }()])]) + case .repositorySetMetadata(let repo, let edits): return .map([.string("RepositorySetMetadata"): .array([repo.encodeToMsgPack(), .array(edits.map { $0.encodeToMsgPack() })])]) + case .repositorySetPexEnabled(let repo, let enabled): return .map([.string("RepositorySetPexEnabled"): .array([repo.encodeToMsgPack(), .bool(enabled)])]) + case .repositorySetQuota(let repo, let value): return .map([.string("RepositorySetQuota"): .array([repo.encodeToMsgPack(), { if let x = value { return x.encodeToMsgPack() } else { return .nil } }()])]) + case .repositorySetSyncEnabled(let repo, let enabled): return .map([.string("RepositorySetSyncEnabled"): .array([repo.encodeToMsgPack(), .bool(enabled)])]) + case .repositoryShare(let repo, let accessMode, let localSecret): return .map([.string("RepositoryShare"): .array([repo.encodeToMsgPack(), accessMode.encodeToMsgPack(), { if let x = localSecret { return x.encodeToMsgPack() } else { return .nil } }()])]) + case .repositorySubscribe(let repo): return .map([.string("RepositorySubscribe"): .array([repo.encodeToMsgPack()])]) + case .repositoryUnmount(let repo): return .map([.string("RepositoryUnmount"): .array([repo.encodeToMsgPack()])]) + case .sessionAddUserProvidedPeers(let addrs): return .map([.string("SessionAddUserProvidedPeers"): .array([.array(addrs.map { .string($0) })])]) + case .sessionBindMetrics(let addr): return .map([.string("SessionBindMetrics"): .array([{ if let x = addr { return .string(x) } else { return .nil } }()])]) + case .sessionBindNetwork(let addrs): return .map([.string("SessionBindNetwork"): .array([.array(addrs.map { .string($0) })])]) + case .sessionBindRemoteControl(let addr): return .map([.string("SessionBindRemoteControl"): .array([{ if let x = addr { return .string(x) } else { return .nil } }()])]) + case .sessionCopy(let srcRepo, let srcPath, let dstRepo, let dstPath): return .map([.string("SessionCopy"): .array([{ if let x = srcRepo { return .string(x) } else { return .nil } }(), .string(srcPath), { if let x = dstRepo { return .string(x) } else { return .nil } }(), .string(dstPath)])]) + case .sessionCreateRepository(let path, let readSecret, let writeSecret, let token, let syncEnabled, let dhtEnabled, let pexEnabled): return .map([.string("SessionCreateRepository"): .array([.string(path), { if let x = readSecret { return x.encodeToMsgPack() } else { return .nil } }(), { if let x = writeSecret { return x.encodeToMsgPack() } else { return .nil } }(), { if let x = token { return x.encodeToMsgPack() } else { return .nil } }(), .bool(syncEnabled), .bool(dhtEnabled), .bool(pexEnabled)])]) + case .sessionDeleteRepositoryByName(let name): return .map([.string("SessionDeleteRepositoryByName"): .array([.string(name)])]) + case .sessionDeriveSecretKey(let password, let salt): return .map([.string("SessionDeriveSecretKey"): .array([password.encodeToMsgPack(), salt.encodeToMsgPack()])]) + case .sessionDhtLookup(let infoHash, let announce): return .map([.string("SessionDhtLookup"): .array([.string(infoHash), .bool(announce)])]) + case .sessionFindRepository(let name): return .map([.string("SessionFindRepository"): .array([.string(name)])]) + case .sessionGeneratePasswordSalt: return .string("SessionGeneratePasswordSalt") + case .sessionGenerateSecretKey: return .string("SessionGenerateSecretKey") + case .sessionGetCurrentProtocolVersion: return .string("SessionGetCurrentProtocolVersion") + case .sessionGetDefaultBlockExpiration: return .string("SessionGetDefaultBlockExpiration") + case .sessionGetDefaultQuota: return .string("SessionGetDefaultQuota") + case .sessionGetDefaultRepositoryExpiration: return .string("SessionGetDefaultRepositoryExpiration") + case .sessionGetDhtRouters: return .string("SessionGetDhtRouters") + case .sessionGetExternalAddrV4: return .string("SessionGetExternalAddrV4") + case .sessionGetExternalAddrV6: return .string("SessionGetExternalAddrV6") + case .sessionGetHighestSeenProtocolVersion: return .string("SessionGetHighestSeenProtocolVersion") + case .sessionGetLocalListenerAddrs: return .string("SessionGetLocalListenerAddrs") + case .sessionGetMetricsListenerAddr: return .string("SessionGetMetricsListenerAddr") + case .sessionGetMountRoot: return .string("SessionGetMountRoot") + case .sessionGetNatBehavior: return .string("SessionGetNatBehavior") + case .sessionGetNetworkStats: return .string("SessionGetNetworkStats") + case .sessionGetPeers: return .string("SessionGetPeers") + case .sessionGetRemoteControlListenerAddr: return .string("SessionGetRemoteControlListenerAddr") + case .sessionGetRemoteListenerAddrs(let host): return .map([.string("SessionGetRemoteListenerAddrs"): .array([.string(host)])]) + case .sessionGetRuntimeId: return .string("SessionGetRuntimeId") + case .sessionGetShareTokenAccessMode(let token): return .map([.string("SessionGetShareTokenAccessMode"): .array([token.encodeToMsgPack()])]) + case .sessionGetShareTokenInfoHash(let token): return .map([.string("SessionGetShareTokenInfoHash"): .array([token.encodeToMsgPack()])]) + case .sessionGetShareTokenSuggestedName(let token): return .map([.string("SessionGetShareTokenSuggestedName"): .array([token.encodeToMsgPack()])]) + case .sessionGetStateMonitor(let path): return .map([.string("SessionGetStateMonitor"): .array([.array(path.map { $0.encodeToMsgPack() })])]) + case .sessionGetStoreDirs: return .string("SessionGetStoreDirs") + case .sessionGetUserProvidedPeers: return .string("SessionGetUserProvidedPeers") + case .sessionInitNetwork(let defaults): return .map([.string("SessionInitNetwork"): .array([defaults.encodeToMsgPack()])]) + case .sessionInsertStoreDirs(let paths): return .map([.string("SessionInsertStoreDirs"): .array([.array(paths.map { .string($0) })])]) + case .sessionIsLocalDhtEnabled: return .string("SessionIsLocalDhtEnabled") + case .sessionIsLocalDiscoveryEnabled: return .string("SessionIsLocalDiscoveryEnabled") + case .sessionIsPexRecvEnabled: return .string("SessionIsPexRecvEnabled") + case .sessionIsPexSendEnabled: return .string("SessionIsPexSendEnabled") + case .sessionIsPortForwardingEnabled: return .string("SessionIsPortForwardingEnabled") + case .sessionListRepositories: return .string("SessionListRepositories") + case .sessionMirrorExists(let token, let host): return .map([.string("SessionMirrorExists"): .array([token.encodeToMsgPack(), .string(host)])]) + case .sessionOpenNetworkSocketV4: return .string("SessionOpenNetworkSocketV4") + case .sessionOpenNetworkSocketV6: return .string("SessionOpenNetworkSocketV6") + case .sessionOpenNetworkStream(let addr, let topicId): return .map([.string("SessionOpenNetworkStream"): .array([.string(addr), topicId.encodeToMsgPack()])]) + case .sessionOpenRepository(let path, let localSecret): return .map([.string("SessionOpenRepository"): .array([.string(path), { if let x = localSecret { return x.encodeToMsgPack() } else { return .nil } }()])]) + case .sessionPinDht: return .string("SessionPinDht") + case .sessionRemoveStoreDirs(let paths): return .map([.string("SessionRemoveStoreDirs"): .array([.array(paths.map { .string($0) })])]) + case .sessionRemoveUserProvidedPeers(let addrs): return .map([.string("SessionRemoveUserProvidedPeers"): .array([.array(addrs.map { .string($0) })])]) + case .sessionSetDefaultBlockExpiration(let value): return .map([.string("SessionSetDefaultBlockExpiration"): .array([{ if let x = value { return .int(Int64(x * 1000)) } else { return .nil } }()])]) + case .sessionSetDefaultQuota(let value): return .map([.string("SessionSetDefaultQuota"): .array([{ if let x = value { return x.encodeToMsgPack() } else { return .nil } }()])]) + case .sessionSetDefaultRepositoryExpiration(let value): return .map([.string("SessionSetDefaultRepositoryExpiration"): .array([{ if let x = value { return .int(Int64(x * 1000)) } else { return .nil } }()])]) + case .sessionSetDhtRouters(let routers): return .map([.string("SessionSetDhtRouters"): .array([.array(routers.map { .string($0) })])]) + case .sessionSetLocalDhtEnabled(let enabled): return .map([.string("SessionSetLocalDhtEnabled"): .array([.bool(enabled)])]) + case .sessionSetLocalDiscoveryEnabled(let enabled): return .map([.string("SessionSetLocalDiscoveryEnabled"): .array([.bool(enabled)])]) + case .sessionSetMountRoot(let path): return .map([.string("SessionSetMountRoot"): .array([{ if let x = path { return .string(x) } else { return .nil } }()])]) + case .sessionSetPexRecvEnabled(let enabled): return .map([.string("SessionSetPexRecvEnabled"): .array([.bool(enabled)])]) + case .sessionSetPexSendEnabled(let enabled): return .map([.string("SessionSetPexSendEnabled"): .array([.bool(enabled)])]) + case .sessionSetPortForwardingEnabled(let enabled): return .map([.string("SessionSetPortForwardingEnabled"): .array([.bool(enabled)])]) + case .sessionSetStoreDirs(let paths): return .map([.string("SessionSetStoreDirs"): .array([.array(paths.map { .string($0) })])]) + case .sessionSubscribeToNetwork: return .string("SessionSubscribeToNetwork") + case .sessionSubscribeToStateMonitor(let path): return .map([.string("SessionSubscribeToStateMonitor"): .array([.array(path.map { $0.encodeToMsgPack() })])]) + case .sessionUnpinDht: return .string("SessionUnpinDht") + case .sessionValidateShareToken(let token): return .map([.string("SessionValidateShareToken"): .array([.string(token)])]) + } + } +} + +internal enum Response { + case accessMode(_ value: AccessMode) + case bool(_ value: Bool) + case bytes(_ value: Data) + case datagram(_ value: Datagram) + case directoryEntries(_ value: [DirectoryEntry]) + case duration(_ value: TimeInterval) + case entryType(_ value: EntryType) + case file(_ value: OuisyncFileHandle) + case natBehavior(_ value: NatBehavior) + case networkEvent(_ value: NetworkEvent) + case networkSocket(_ value: NetworkSocketHandle) + case networkStream(_ value: NetworkStreamHandle) + case none + case passwordSalt(_ value: PasswordSalt) + case path(_ value: String) + case paths(_ value: [String]) + case peerAddr(_ value: String) + case peerAddrs(_ value: [String]) + case peerInfos(_ value: [PeerInfo]) + case progress(_ value: Progress) + case publicRuntimeId(_ value: PublicRuntimeId) + case quotaInfo(_ value: QuotaInfo) + case repositories(_ value: [String: RepositoryHandle]) + case repository(_ value: RepositoryHandle) + case secretKey(_ value: SecretKey) + case shareToken(_ value: ShareToken) + case socketAddr(_ value: String) + case stateMonitor(_ value: StateMonitorNode) + case stats(_ value: Stats) + case storageSize(_ value: StorageSize) + case string(_ value: String) + case strings(_ value: [String]) + case u16(_ value: UInt16) + case u64(_ value: UInt64) + case unit + + internal func encodeToMsgPack() -> MessagePackValue { + switch self { + case .accessMode(let value): return .map([.string("AccessMode"): value.encodeToMsgPack()]) + case .bool(let value): return .map([.string("Bool"): .bool(value)]) + case .bytes(let value): return .map([.string("Bytes"): .binary(value)]) + case .datagram(let value): return .map([.string("Datagram"): value.encodeToMsgPack()]) + case .directoryEntries(let value): return .map([.string("DirectoryEntries"): .array(value.map { $0.encodeToMsgPack() })]) + case .duration(let value): return .map([.string("Duration"): .int(Int64(value * 1000))]) + case .entryType(let value): return .map([.string("EntryType"): value.encodeToMsgPack()]) + case .file(let value): return .map([.string("File"): value.encodeToMsgPack()]) + case .natBehavior(let value): return .map([.string("NatBehavior"): value.encodeToMsgPack()]) + case .networkEvent(let value): return .map([.string("NetworkEvent"): value.encodeToMsgPack()]) + case .networkSocket(let value): return .map([.string("NetworkSocket"): value.encodeToMsgPack()]) + case .networkStream(let value): return .map([.string("NetworkStream"): value.encodeToMsgPack()]) + case .none: return .string("None") + case .passwordSalt(let value): return .map([.string("PasswordSalt"): value.encodeToMsgPack()]) + case .path(let value): return .map([.string("Path"): .string(value)]) + case .paths(let value): return .map([.string("Paths"): .array(value.map { .string($0) })]) + case .peerAddr(let value): return .map([.string("PeerAddr"): .string(value)]) + case .peerAddrs(let value): return .map([.string("PeerAddrs"): .array(value.map { .string($0) })]) + case .peerInfos(let value): return .map([.string("PeerInfos"): .array(value.map { $0.encodeToMsgPack() })]) + case .progress(let value): return .map([.string("Progress"): value.encodeToMsgPack()]) + case .publicRuntimeId(let value): return .map([.string("PublicRuntimeId"): value.encodeToMsgPack()]) + case .quotaInfo(let value): return .map([.string("QuotaInfo"): value.encodeToMsgPack()]) + case .repositories(let value): return .map([.string("Repositories"): .map(Dictionary(uniqueKeysWithValues: value.map { (.string($0.key), $0.value.encodeToMsgPack()) }))]) + case .repository(let value): return .map([.string("Repository"): value.encodeToMsgPack()]) + case .secretKey(let value): return .map([.string("SecretKey"): value.encodeToMsgPack()]) + case .shareToken(let value): return .map([.string("ShareToken"): value.encodeToMsgPack()]) + case .socketAddr(let value): return .map([.string("SocketAddr"): .string(value)]) + case .stateMonitor(let value): return .map([.string("StateMonitor"): value.encodeToMsgPack()]) + case .stats(let value): return .map([.string("Stats"): value.encodeToMsgPack()]) + case .storageSize(let value): return .map([.string("StorageSize"): value.encodeToMsgPack()]) + case .string(let value): return .map([.string("String"): .string(value)]) + case .strings(let value): return .map([.string("Strings"): .array(value.map { .string($0) })]) + case .u16(let value): return .map([.string("U16"): .uint(UInt64(value))]) + case .u64(let value): return .map([.string("U64"): .uint(UInt64(value))]) + case .unit: return .string("Unit") + } + } + + internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Response? { + switch v { + case .string(let name): + switch name { + case "None": return .none + case "Unit": return .unit + default: return nil + } + case .map(let m) where m.count == 1: + guard let entry = m.first, case .string(let name) = entry.key else { return nil } + switch name { + case "AccessMode": + guard let decoded = AccessMode.decodeFromMsgPack(entry.value) else { return nil } + return .accessMode(decoded) + case "Bool": + guard let decoded = { if case .bool(let b) = entry.value { return b }; return nil }() else { return nil } + return .bool(decoded) + case "Bytes": + guard case .binary(let bytes_decoded) = entry.value else { return nil } + let decoded = Data(bytes_decoded) + return .bytes(decoded) + case "Datagram": + guard let decoded = Datagram.decodeFromMsgPack(entry.value) else { return nil } + return .datagram(decoded) + case "DirectoryEntries": + guard case .array(let arr_decoded) = entry.value else { return nil } + var decoded: [DirectoryEntry] = [] + for elem_decoded in arr_decoded { + guard let x_decoded = DirectoryEntry.decodeFromMsgPack(elem_decoded) else { return nil } + decoded.append(x_decoded) + } + return .directoryEntries(decoded) + case "Duration": + guard let decoded = (_msgpackInt(entry.value).map { TimeInterval($0) / 1000.0 }) else { return nil } + return .duration(decoded) + case "EntryType": + guard let decoded = EntryType.decodeFromMsgPack(entry.value) else { return nil } + return .entryType(decoded) + case "File": + guard let decoded = OuisyncFileHandle.decodeFromMsgPack(entry.value) else { return nil } + return .file(decoded) + case "NatBehavior": + guard let decoded = NatBehavior.decodeFromMsgPack(entry.value) else { return nil } + return .natBehavior(decoded) + case "NetworkEvent": + guard let decoded = NetworkEvent.decodeFromMsgPack(entry.value) else { return nil } + return .networkEvent(decoded) + case "NetworkSocket": + guard let decoded = NetworkSocketHandle.decodeFromMsgPack(entry.value) else { return nil } + return .networkSocket(decoded) + case "NetworkStream": + guard let decoded = NetworkStreamHandle.decodeFromMsgPack(entry.value) else { return nil } + return .networkStream(decoded) + case "PasswordSalt": + guard let decoded = PasswordSalt.decodeFromMsgPack(entry.value) else { return nil } + return .passwordSalt(decoded) + case "Path": + guard let decoded = { if case .string(let s) = entry.value { return s }; return nil }() else { return nil } + return .path(decoded) + case "Paths": + guard case .array(let arr_decoded) = entry.value else { return nil } + var decoded: [String] = [] + for elem_decoded in arr_decoded { + guard let x_decoded = { if case .string(let s) = elem_decoded { return s }; return nil }() else { return nil } + decoded.append(x_decoded) + } + return .paths(decoded) + case "PeerAddr": + guard let decoded = { if case .string(let s) = entry.value { return s }; return nil }() else { return nil } + return .peerAddr(decoded) + case "PeerAddrs": + guard case .array(let arr_decoded) = entry.value else { return nil } + var decoded: [String] = [] + for elem_decoded in arr_decoded { + guard let x_decoded = { if case .string(let s) = elem_decoded { return s }; return nil }() else { return nil } + decoded.append(x_decoded) + } + return .peerAddrs(decoded) + case "PeerInfos": + guard case .array(let arr_decoded) = entry.value else { return nil } + var decoded: [PeerInfo] = [] + for elem_decoded in arr_decoded { + guard let x_decoded = PeerInfo.decodeFromMsgPack(elem_decoded) else { return nil } + decoded.append(x_decoded) + } + return .peerInfos(decoded) + case "Progress": + guard let decoded = Progress.decodeFromMsgPack(entry.value) else { return nil } + return .progress(decoded) + case "PublicRuntimeId": + guard let decoded = PublicRuntimeId.decodeFromMsgPack(entry.value) else { return nil } + return .publicRuntimeId(decoded) + case "QuotaInfo": + guard let decoded = QuotaInfo.decodeFromMsgPack(entry.value) else { return nil } + return .quotaInfo(decoded) + case "Repositories": + guard case .map(let map_decoded) = entry.value else { return nil } + var decoded: [String: RepositoryHandle] = [:] + for (mk, mv) in map_decoded { + guard let dk = { if case .string(let s) = mk { return s }; return nil }() else { return nil } + guard let dv = RepositoryHandle.decodeFromMsgPack(mv) else { return nil } + decoded[dk] = dv + } + return .repositories(decoded) + case "Repository": + guard let decoded = RepositoryHandle.decodeFromMsgPack(entry.value) else { return nil } + return .repository(decoded) + case "SecretKey": + guard let decoded = SecretKey.decodeFromMsgPack(entry.value) else { return nil } + return .secretKey(decoded) + case "ShareToken": + guard let decoded = ShareToken.decodeFromMsgPack(entry.value) else { return nil } + return .shareToken(decoded) + case "SocketAddr": + guard let decoded = { if case .string(let s) = entry.value { return s }; return nil }() else { return nil } + return .socketAddr(decoded) + case "StateMonitor": + guard let decoded = StateMonitorNode.decodeFromMsgPack(entry.value) else { return nil } + return .stateMonitor(decoded) + case "Stats": + guard let decoded = Stats.decodeFromMsgPack(entry.value) else { return nil } + return .stats(decoded) + case "StorageSize": + guard let decoded = StorageSize.decodeFromMsgPack(entry.value) else { return nil } + return .storageSize(decoded) + case "String": + guard let decoded = { if case .string(let s) = entry.value { return s }; return nil }() else { return nil } + return .string(decoded) + case "Strings": + guard case .array(let arr_decoded) = entry.value else { return nil } + var decoded: [String] = [] + for elem_decoded in arr_decoded { + guard let x_decoded = { if case .string(let s) = elem_decoded { return s }; return nil }() else { return nil } + decoded.append(x_decoded) + } + return .strings(decoded) + case "U16": + guard let decoded = (_msgpackUInt(entry.value).flatMap { UInt16(exactly: $0) }) else { return nil } + return .u16(decoded) + case "U64": + guard let decoded = _msgpackUInt(entry.value) else { return nil } + return .u64(decoded) + default: return nil + } + default: return nil + } + } +} + +public class Session { + internal let client: Client + + internal init(_ client: Client) { + self.client = client + } + + /// Adds peers to connect to. + /// + /// Normally peers are discovered automatically (using Bittorrent DHT, Peer exchange or Local + /// discovery) but this function is useful in case when the discovery is not available for any + /// reason (e.g. in an isolated network). + /// + /// Note that peers added with this function are remembered across restarts. To forget peers, + /// use [Self::session_remove_user_provided_peers]. + public func addUserProvidedPeers(_ addrs: [String]) async throws { + let request = Request.sessionAddUserProvidedPeers( + addrs, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func bindMetrics(_ addr: String?) async throws { + let request = Request.sessionBindMetrics( + addr, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Binds the network listeners to the specified interfaces. + /// + /// Up to four listeners can be bound, one for each combination of protocol (TCP or QUIC) and IP + /// family (IPv4 or IPv6). The format of the interfaces is "PROTO/IP:PORT" where PROTO is "tcp" + /// or "quic". If IP is IPv6, it needs to be enclosed in square brackets. + /// + /// If port is `0`, binds to a random port initially but on subsequent starts tries to use the + /// same port (unless it's already taken). This can be useful to configuring port forwarding. + public func bindNetwork(_ addrs: [String]) async throws { + let request = Request.sessionBindNetwork( + addrs, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func bindRemoteControl(_ addr: String?) async throws -> UInt16 { + let request = Request.sessionBindRemoteControl( + addr, + ) + let response = try await client.invoke(request) + switch response { + case .u16(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Copy file or directory into, from or between repositories + /// + /// - `src_repo`: Name of the repository from which file will be copied. + /// - `src_path`: Path of to the entry to be copied. If `src_repo` is set, the `src_path` is + /// relative to the corresponding repository root. If `src_repo` is null, `src_path` is + /// interpreted as path on the local file system. + /// - `dst_repo`: Name of the repository into which the entry will be copied. + /// - `dst_path`: Destination entry + public func copy(_ srcRepo: String? = nil, _ srcPath: String, _ dstRepo: String? = nil, _ dstPath: String) async throws { + let request = Request.sessionCopy( + srcRepo, + srcPath, + dstRepo, + dstPath, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Creates a new repository. + /// + /// - `path`: path to the repository file or name of the repository. + /// - `read_secret`: local secret for reading the repository on this device only. Do not share + /// with peers!. If null, the repo won't be protected and anyone with physical access to the + /// device will be able to read it. + /// - `write_secret`: local secret for writing to the repository on this device only. Do not + /// share with peers! Can be the same as `read_secret` if one wants to use only one secret + /// for both reading and writing. Separate secrets are useful for plausible deniability. If + /// both `read_secret` and `write_secret` are `None`, the repo won't be protected and anyone + /// with physical access to the device will be able to read and write to it. If `read_secret` + /// is not `None` but `write_secret` is `None`, the repo won't be writable from this device. + /// - `token`: used to share repositories between devices. If not `None`, this repo will be + /// linked with the repos with the same token on other devices. See also + /// [Self::repository_share]. This also determines the maximal access mode the repo can be + /// opened in. If `None`, it's *write* mode. + public func createRepository(_ path: String, _ readSecret: SetLocalSecret? = nil, _ writeSecret: SetLocalSecret? = nil, _ token: ShareToken? = nil, _ syncEnabled: Bool = false, _ dhtEnabled: Bool = false, _ pexEnabled: Bool = false) async throws -> Repository { + let request = Request.sessionCreateRepository( + path, + readSecret, + writeSecret, + token, + syncEnabled, + dhtEnabled, + pexEnabled, + ) + let response = try await client.invoke(request) + switch response { + case .repository(let value): return Repository(client, value) + default: + throw UnexpectedResponse() + } + } + + /// Delete a repository with the given name. + public func deleteRepositoryByName(_ name: String) async throws { + let request = Request.sessionDeleteRepositoryByName( + name, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func deriveSecretKey(_ password: Password, _ salt: PasswordSalt) async throws -> SecretKey { + let request = Request.sessionDeriveSecretKey( + password, + salt, + ) + let response = try await client.invoke(request) + switch response { + case .secretKey(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Starts a DHT lookup for the given info-hash (formated as hex string). Returns a stream of + /// discovered peer addresses. If `announce` is true, also announces us as having the content + /// corresponding to the info-hash. + /// + /// Note: Currently this doesn't automatically connnect to the discovered peers but this might + /// change in the future. + public func dhtLookup(_ infoHash: String, _ announce: Bool) async throws -> AsyncStream { + let request = Request.sessionDhtLookup( + infoHash, + announce, + ) + let stream = try await client.subscribe(request) + return AsyncStream { continuation in + let task = Task { + for await response in stream { + switch response { + case .peerAddr(let value): continuation.yield(value) + default: break + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + public func findRepository(_ name: String) async throws -> Repository { + let request = Request.sessionFindRepository( + name, + ) + let response = try await client.invoke(request) + switch response { + case .repository(let value): return Repository(client, value) + default: + throw UnexpectedResponse() + } + } + + public func generatePasswordSalt() async throws -> PasswordSalt { + let request = Request.sessionGeneratePasswordSalt + let response = try await client.invoke(request) + switch response { + case .passwordSalt(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func generateSecretKey() async throws -> SecretKey { + let request = Request.sessionGenerateSecretKey + let response = try await client.invoke(request) + switch response { + case .secretKey(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns our Ouisync protocol version. + /// + /// In order to establish connections with peers, they must use the same protocol version as + /// us. + /// + /// See also [Self::session_get_highest_seen_protocol_version] + public func getCurrentProtocolVersion() async throws -> UInt64 { + let request = Request.sessionGetCurrentProtocolVersion + let response = try await client.invoke(request) + switch response { + case .u64(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getDefaultBlockExpiration() async throws -> TimeInterval? { + let request = Request.sessionGetDefaultBlockExpiration + let response = try await client.invoke(request) + switch response { + case .duration(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getDefaultQuota() async throws -> StorageSize? { + let request = Request.sessionGetDefaultQuota + let response = try await client.invoke(request) + switch response { + case .storageSize(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getDefaultRepositoryExpiration() async throws -> TimeInterval? { + let request = Request.sessionGetDefaultRepositoryExpiration + let response = try await client.invoke(request) + switch response { + case .duration(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + /// Returns the current DHT routers (bootstrap nodes). If the routers haven't been changed by + /// the user yet, returns the default routers. + public func getDhtRouters() async throws -> [String] { + let request = Request.sessionGetDhtRouters + let response = try await client.invoke(request) + switch response { + case .strings(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getExternalAddrV4() async throws -> String? { + let request = Request.sessionGetExternalAddrV4 + let response = try await client.invoke(request) + switch response { + case .socketAddr(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getExternalAddrV6() async throws -> String? { + let request = Request.sessionGetExternalAddrV6 + let response = try await client.invoke(request) + switch response { + case .socketAddr(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + /// Returns the highest protocol version of all known peers. + /// + /// If this is higher than [our version](Self::session_get_current_protocol_version) it likely + /// means we are using an outdated version of Ouisync. When a peer with higher protocol version + /// is found, a [NetworkEvent::ProtocolVersionMismatch] is emitted. + public func getHighestSeenProtocolVersion() async throws -> UInt64 { + let request = Request.sessionGetHighestSeenProtocolVersion + let response = try await client.invoke(request) + switch response { + case .u64(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the listener addresses of this Ouisync instance. + public func getLocalListenerAddrs() async throws -> [String] { + let request = Request.sessionGetLocalListenerAddrs + let response = try await client.invoke(request) + switch response { + case .peerAddrs(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getMetricsListenerAddr() async throws -> String? { + let request = Request.sessionGetMetricsListenerAddr + let response = try await client.invoke(request) + switch response { + case .socketAddr(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getMountRoot() async throws -> String? { + let request = Request.sessionGetMountRoot + let response = try await client.invoke(request) + switch response { + case .path(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getNatBehavior() async throws -> NatBehavior? { + let request = Request.sessionGetNatBehavior + let response = try await client.invoke(request) + switch response { + case .natBehavior(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getNetworkStats() async throws -> Stats { + let request = Request.sessionGetNetworkStats + let response = try await client.invoke(request) + switch response { + case .stats(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns info about all known peers (both discovered and explicitly added). + /// + /// When the set of known peers changes, a [NetworkEvent::PeerSetChange] is emitted. Calling + /// this function afterwards returns the new peer info. + public func getPeers() async throws -> [PeerInfo] { + let request = Request.sessionGetPeers + let response = try await client.invoke(request) + switch response { + case .peerInfos(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getRemoteControlListenerAddr() async throws -> String? { + let request = Request.sessionGetRemoteControlListenerAddr + let response = try await client.invoke(request) + switch response { + case .socketAddr(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + /// Returns the listener addresses of the specified remote Ouisync instance. Works only if the + /// remote control API is enabled on the remote instance. Typically used with cache servers. + public func getRemoteListenerAddrs(_ host: String) async throws -> [String] { + let request = Request.sessionGetRemoteListenerAddrs( + host, + ) + let response = try await client.invoke(request) + switch response { + case .peerAddrs(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the runtime id of this Ouisync instance. + /// + /// The runtime id is a unique identifier of this instance which is randomly generated every + /// time Ouisync starts. + public func getRuntimeId() async throws -> PublicRuntimeId { + let request = Request.sessionGetRuntimeId + let response = try await client.invoke(request) + switch response { + case .publicRuntimeId(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the access mode that the given token grants. + public func getShareTokenAccessMode(_ token: ShareToken) async throws -> AccessMode { + let request = Request.sessionGetShareTokenAccessMode( + token, + ) + let response = try await client.invoke(request) + switch response { + case .accessMode(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Return the info-hash of the repository corresponding to the given token, formatted as hex + /// string. + /// + /// See also: [repository_get_info_hash] + public func getShareTokenInfoHash(_ token: ShareToken) async throws -> String { + let request = Request.sessionGetShareTokenInfoHash( + token, + ) + let response = try await client.invoke(request) + switch response { + case .string(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the suggested name for the repository corresponding to the given token. + public func getShareTokenSuggestedName(_ token: ShareToken) async throws -> String { + let request = Request.sessionGetShareTokenSuggestedName( + token, + ) + let response = try await client.invoke(request) + switch response { + case .string(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getStateMonitor(_ path: [MonitorId]) async throws -> StateMonitorNode? { + let request = Request.sessionGetStateMonitor( + path, + ) + let response = try await client.invoke(request) + switch response { + case .stateMonitor(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getStoreDirs() async throws -> [String] { + let request = Request.sessionGetStoreDirs + let response = try await client.invoke(request) + switch response { + case .paths(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getUserProvidedPeers() async throws -> [String] { + let request = Request.sessionGetUserProvidedPeers + let response = try await client.invoke(request) + switch response { + case .peerAddrs(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Initializes the network according to the stored configuration. If a particular network + /// parameter is not yet configured, falls back to the given defaults. + public func initNetwork(_ defaults: NetworkDefaults) async throws { + let request = Request.sessionInitNetwork( + defaults, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func insertStoreDirs(_ paths: [String]) async throws { + let request = Request.sessionInsertStoreDirs( + paths, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Checks whether local DHT is enabled. + public func isLocalDhtEnabled() async throws -> Bool { + let request = Request.sessionIsLocalDhtEnabled + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Is local discovery enabled? + public func isLocalDiscoveryEnabled() async throws -> Bool { + let request = Request.sessionIsLocalDiscoveryEnabled + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Checks whether accepting peers discovered on the peer exchange is enabled. + public func isPexRecvEnabled() async throws -> Bool { + let request = Request.sessionIsPexRecvEnabled + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func isPexSendEnabled() async throws -> Bool { + let request = Request.sessionIsPexSendEnabled + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Is port forwarding (UPnP) enabled? + public func isPortForwardingEnabled() async throws -> Bool { + let request = Request.sessionIsPortForwardingEnabled + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func listRepositories() async throws -> [String: Repository] { + let request = Request.sessionListRepositories + let response = try await client.invoke(request) + switch response { + case .repositories(let value): return value.mapValues { Repository(client, $0) } + default: + throw UnexpectedResponse() + } + } + + public func mirrorExists(_ token: ShareToken, _ host: String) async throws -> Bool { + let request = Request.sessionMirrorExists( + token, + host, + ) + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Opens a side channel to the underlying IPv4 UDP socket. The side channel is used to + /// send/receive raw UDP datagrams on the same socket that the sync protocol uses. This is + /// useful to share the socket between different protocols for hole punching. + /// + /// Returns `None` if QUIC IPv4 endpoint isn't bound (see [Self::session_bind_network]). + public func openNetworkSocketV4() async throws -> NetworkSocket? { + let request = Request.sessionOpenNetworkSocketV4 + let response = try await client.invoke(request) + switch response { + case .networkSocket(let value): return NetworkSocket(client, value) + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + /// Opens a side channel to the underlying IPv6 UDP socket. The side channel is used to + /// send/receive raw UDP datagrams on the same socket that the sync protocol uses. This is + /// useful to share the socket between different protocols for hole punching. + /// + /// Returns `None` if QUIC IPv6 endpoint isn't bound (see [Self::session_bind_network]). + public func openNetworkSocketV6() async throws -> NetworkSocket? { + let request = Request.sessionOpenNetworkSocketV6 + let response = try await client.invoke(request) + switch response { + case .networkSocket(let value): return NetworkSocket(client, value) + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + /// Opens a raw byte streams to the given peer, bound to the given topic. + public func openNetworkStream(_ addr: String, _ topicId: TopicId) async throws -> NetworkStream { + let request = Request.sessionOpenNetworkStream( + addr, + topicId, + ) + let response = try await client.invoke(request) + switch response { + case .networkStream(let value): return NetworkStream(client, value) + default: + throw UnexpectedResponse() + } + } + + /// Opens an existing repository. + /// + /// - `path`: path to the local file the repo is stored in. + /// - `local_secret`: a local secret. See the `read_secret` and `write_secret` params in + /// [Self::session_create_repository] for more details. If this repo uses local secret + /// (s), this determines the access mode the repo is opened in: `read_secret` opens it + /// in *read* mode, `write_secret` opens it in *write* mode and no secret or wrong secret + /// opens it in *blind* mode. If this repo doesn't use local secret(s), the repo is opened in + /// the maximal mode specified when the repo was created. + public func openRepository(_ path: String, _ localSecret: LocalSecret? = nil) async throws -> Repository { + let request = Request.sessionOpenRepository( + path, + localSecret, + ) + let response = try await client.invoke(request) + switch response { + case .repository(let value): return Repository(client, value) + default: + throw UnexpectedResponse() + } + } + + /// Pin the DHT to ensure it starts and remains running even when there are no active DHT + /// lookups and no DHT-enabled repositories. This is useful to prevent the DHT restarting + /// between the lookups (which could be slow). + public func pinDht() async throws { + let request = Request.sessionPinDht + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func removeStoreDirs(_ paths: [String]) async throws { + let request = Request.sessionRemoveStoreDirs( + paths, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Removes peers previously added with [Self::session_add_user_provided_peers]. + public func removeUserProvidedPeers(_ addrs: [String]) async throws { + let request = Request.sessionRemoveUserProvidedPeers( + addrs, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setDefaultBlockExpiration(_ value: TimeInterval?) async throws { + let request = Request.sessionSetDefaultBlockExpiration( + value, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setDefaultQuota(_ value: StorageSize?) async throws { + let request = Request.sessionSetDefaultQuota( + value, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setDefaultRepositoryExpiration(_ value: TimeInterval?) async throws { + let request = Request.sessionSetDefaultRepositoryExpiration( + value, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Changes the DHT routers (bootstrap nodes), rebootstraps the DHTs and restart any ongoing + /// lookups. If this is not called, a default set of routers is used. Each router is specified + /// as hostname + port or ip address + port. + public func setDhtRouters(_ routers: [String]) async throws { + let request = Request.sessionSetDhtRouters( + routers, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Set whether DHT on the local network or localhost is allowed. By default this is `false` + /// because DHT is a global discovery mechanism and finding a local peer on it is unexpected + /// (and could indicate malice). However, is some situations it's still useful to enable it + /// (typically for testing). + /// + /// Note: this option is currently experimental and unstable (semver extempt). It's possible it + /// will be removed in the future. + public func setLocalDhtEnabled(_ enabled: Bool) async throws { + let request = Request.sessionSetLocalDhtEnabled( + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Enables/disables local discovery. + public func setLocalDiscoveryEnabled(_ enabled: Bool) async throws { + let request = Request.sessionSetLocalDiscoveryEnabled( + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setMountRoot(_ path: String?) async throws { + let request = Request.sessionSetMountRoot( + path, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setPexRecvEnabled(_ enabled: Bool) async throws { + let request = Request.sessionSetPexRecvEnabled( + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setPexSendEnabled(_ enabled: Bool) async throws { + let request = Request.sessionSetPexSendEnabled( + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Enables/disables port forwarding (UPnP). + public func setPortForwardingEnabled(_ enabled: Bool) async throws { + let request = Request.sessionSetPortForwardingEnabled( + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setStoreDirs(_ paths: [String]) async throws { + let request = Request.sessionSetStoreDirs( + paths, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func subscribeToNetwork() async throws -> AsyncStream { + let request = Request.sessionSubscribeToNetwork + let stream = try await client.subscribe(request) + return AsyncStream { continuation in + let task = Task { + for await response in stream { + switch response { + case .networkEvent(let value): continuation.yield(value) + default: break + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + public func subscribeToStateMonitor(_ path: [MonitorId]) async throws -> AsyncStream { + let request = Request.sessionSubscribeToStateMonitor( + path, + ) + let stream = try await client.subscribe(request) + return AsyncStream { continuation in + let task = Task { + for await response in stream { + switch response { + case .unit: continuation.yield(()) + default: break + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Unpin the DHT. If the DHT is not pinned and there are no more active DHT lookups and no + /// DHT-enabled repositories, the DHT shuts down. + public func unpinDht() async throws { + let request = Request.sessionUnpinDht + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Checks whether the given string is a valid share token. + public func validateShareToken(_ token: String) async throws -> ShareToken { + let request = Request.sessionValidateShareToken( + token, + ) + let response = try await client.invoke(request) + switch response { + case .shareToken(let value): return value + default: + throw UnexpectedResponse() + } + } +} + +public class Repository { + internal let client: Client + public let handle: RepositoryHandle + + internal init(_ client: Client, _ handle: RepositoryHandle) { + self.client = client + self.handle = handle + } + + /// Closes the repository. + public func close() async throws { + let request = Request.repositoryClose( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Creates a new directory at the given path in the repository. + public func createDirectory(_ path: String) async throws { + let request = Request.repositoryCreateDirectory( + handle, + path, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Creates a new file at the given path in the repository. + public func createFile(_ path: String) async throws -> File { + let request = Request.repositoryCreateFile( + handle, + path, + ) + let response = try await client.invoke(request) + switch response { + case .file(let value): return File(client, value) + default: + throw UnexpectedResponse() + } + } + + /// Creates mirror of this repository on the given cache server host. + /// + /// Cache servers relay traffic between Ouisync peers and also temporarily store data. They are + /// useful when direct P2P connection fails (e.g. due to restrictive NAT) and also to allow + /// syncing when the peers are not online at the same time (they still need to be online within + /// ~24 hours of each other). + /// + /// Requires the repository to be opened in write mode. + public func createMirror(_ host: String) async throws { + let request = Request.repositoryCreateMirror( + handle, + host, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Delete the repository + public func delete() async throws { + let request = Request.repositoryDelete( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Deletes mirror of this repository from the given cache server host. + /// + /// Requires the repository to be opened in write mode. + public func deleteMirror(_ host: String) async throws { + let request = Request.repositoryDeleteMirror( + handle, + host, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Export repository to file + public func export(_ outputPath: String) async throws -> String { + let request = Request.repositoryExport( + handle, + outputPath, + ) + let response = try await client.invoke(request) + switch response { + case .path(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func fileExists(_ path: String) async throws -> Bool { + let request = Request.repositoryFileExists( + handle, + path, + ) + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the access mode (*blind*, *read* or *write*) the repository is currently opened in. + public func getAccessMode() async throws -> AccessMode { + let request = Request.repositoryGetAccessMode( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .accessMode(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getBlockExpiration() async throws -> TimeInterval? { + let request = Request.repositoryGetBlockExpiration( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .duration(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + /// Gets the current credentials of this repository. Can be used to restore access after closing + /// and reopening the repository. + public func getCredentials() async throws -> Data { + let request = Request.repositoryGetCredentials( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .bytes(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the type of repository entry (file, directory, ...) or `None` if the entry doesn't + /// exist. + public func getEntryType(_ path: String) async throws -> EntryType? { + let request = Request.repositoryGetEntryType( + handle, + path, + ) + let response = try await client.invoke(request) + switch response { + case .entryType(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getExpiration() async throws -> TimeInterval? { + let request = Request.repositoryGetExpiration( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .duration(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + /// Return the info-hash of the repository formatted as hex string. This can be used as a + /// globally unique, non-secret identifier of the repository. + public func getInfoHash() async throws -> String { + let request = Request.repositoryGetInfoHash( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .string(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getMetadata(_ key: String) async throws -> String? { + let request = Request.repositoryGetMetadata( + handle, + key, + ) + let response = try await client.invoke(request) + switch response { + case .string(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getMountPoint() async throws -> String? { + let request = Request.repositoryGetMountPoint( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .path(let value): return value + case .none: + return nil + default: + throw UnexpectedResponse() + } + } + + public func getPath() async throws -> String { + let request = Request.repositoryGetPath( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .path(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getQuota() async throws -> QuotaInfo { + let request = Request.repositoryGetQuota( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .quotaInfo(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getShortName() async throws -> String { + let request = Request.repositoryGetShortName( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .string(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func getStats() async throws -> Stats { + let request = Request.repositoryGetStats( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .stats(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the synchronization progress of this repository as the number of bytes already + /// synced ([Progress.value]) vs. the total size of the repository in bytes ([Progress.total]). + public func getSyncProgress() async throws -> Progress { + let request = Request.repositoryGetSyncProgress( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .progress(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Is Bittorrent DHT enabled? + public func isDhtEnabled() async throws -> Bool { + let request = Request.repositoryIsDhtEnabled( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Is Peer Exchange enabled? + public func isPexEnabled() async throws -> Bool { + let request = Request.repositoryIsPexEnabled( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns whether syncing with other replicas is enabled for this repository. + public func isSyncEnabled() async throws -> Bool { + let request = Request.repositoryIsSyncEnabled( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Checks if this repository is mirrored on the given cache server host. + public func mirrorExists(_ host: String) async throws -> Bool { + let request = Request.repositoryMirrorExists( + handle, + host, + ) + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func mount() async throws -> String { + let request = Request.repositoryMount( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .path(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func move(_ dst: String) async throws { + let request = Request.repositoryMove( + handle, + dst, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Moves an entry (file or directory) from `src` to `dst`. + public func moveEntry(_ src: String, _ dst: String) async throws { + let request = Request.repositoryMoveEntry( + handle, + src, + dst, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Opens an existing file at the given path in the repository. + public func openFile(_ path: String) async throws -> File { + let request = Request.repositoryOpenFile( + handle, + path, + ) + let response = try await client.invoke(request) + switch response { + case .file(let value): return File(client, value) + default: + throw UnexpectedResponse() + } + } + + /// Returns the entries of the directory at the given path in the repository. + public func readDirectory(_ path: String) async throws -> [DirectoryEntry] { + let request = Request.repositoryReadDirectory( + handle, + path, + ) + let response = try await client.invoke(request) + switch response { + case .directoryEntries(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Removes the directory at the given path from the repository. If `recursive` is true it removes + /// also the contents, otherwise the directory must be empty. + public func removeDirectory(_ path: String, _ recursive: Bool = false) async throws { + let request = Request.repositoryRemoveDirectory( + handle, + path, + recursive, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Removes (deletes) the file at the given path from the repository. + public func removeFile(_ path: String) async throws { + let request = Request.repositoryRemoveFile( + handle, + path, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func resetAccess(_ token: ShareToken) async throws { + let request = Request.repositoryResetAccess( + handle, + token, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Sets, unsets or changes local secrets for accessing the repository or disables the given + /// access mode. + /// + /// ## Examples + /// + /// To protect both read and write access with the same password: + /// + /// ```kotlin + /// val password = Password("supersecret") + /// repo.setAccess(read: AccessChange.Enable(password), write: AccessChange.Enable(password)) + /// ``` + /// + /// To require password only for writing: + /// + /// ```kotlin + /// repo.setAccess(read: AccessChange.Enable(null), write: AccessChange.Enable(password)) + /// ``` + /// + /// To competelly disable write access but leave read access as it was. Warning: this operation + /// is currently irreversibe. + /// + /// ```kotlin + /// repo.setAccess(read: null, write: AccessChange.Disable) + /// ``` + public func setAccess(_ read: AccessChange?, _ write: AccessChange?) async throws { + let request = Request.repositorySetAccess( + handle, + read, + write, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Switches the repository to the given access mode. + /// + /// - `access_mode` is the desired access mode to switch to. + /// - `local_secret` is the local secret protecting the desired access mode. Can be `None` if no + /// local secret is used. + public func setAccessMode(_ accessMode: AccessMode, _ localSecret: LocalSecret? = nil) async throws { + let request = Request.repositorySetAccessMode( + handle, + accessMode, + localSecret, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setBlockExpiration(_ value: TimeInterval?) async throws { + let request = Request.repositorySetBlockExpiration( + handle, + value, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Sets the current credentials of the repository. + public func setCredentials(_ credentials: Data) async throws { + let request = Request.repositorySetCredentials( + handle, + credentials, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Enables/disabled Bittorrent DHT (for peer discovery). + public func setDhtEnabled(_ enabled: Bool) async throws { + let request = Request.repositorySetDhtEnabled( + handle, + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setExpiration(_ value: TimeInterval?) async throws { + let request = Request.repositorySetExpiration( + handle, + value, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setMetadata(_ edits: [MetadataEdit]) async throws -> Bool { + let request = Request.repositorySetMetadata( + handle, + edits, + ) + let response = try await client.invoke(request) + switch response { + case .bool(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Enables/disables Peer Exchange (for peer discovery). + public func setPexEnabled(_ enabled: Bool) async throws { + let request = Request.repositorySetPexEnabled( + handle, + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func setQuota(_ value: StorageSize?) async throws { + let request = Request.repositorySetQuota( + handle, + value, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Enabled or disables syncing with other replicas. + /// + /// Note syncing is initially disabled. + public func setSyncEnabled(_ enabled: Bool) async throws { + let request = Request.repositorySetSyncEnabled( + handle, + enabled, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Creates a *share token* to share this repository with other devices. + /// + /// By default the access mode of the token will be the same as the mode the repo is currently + /// opened in but it can be escalated with the `local_secret` param or de-escalated with the + /// `access_mode` param. + /// + /// - `access_mode`: access mode of the token. Useful to de-escalate the access mode to below of + /// what the repo is opened in. + /// - `local_secret`: the local repo secret. If not `None`, the share token's access mode will + /// be the same as what the secret provides. Useful to escalate the access mode to above of + /// what the repo is opened in. + public func share(_ accessMode: AccessMode, _ localSecret: LocalSecret? = nil) async throws -> ShareToken { + let request = Request.repositoryShare( + handle, + accessMode, + localSecret, + ) + let response = try await client.invoke(request) + switch response { + case .shareToken(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func subscribe() async throws -> AsyncStream { + let request = Request.repositorySubscribe( + handle, + ) + let stream = try await client.subscribe(request) + return AsyncStream { continuation in + let task = Task { + for await response in stream { + switch response { + case .unit: continuation.yield(()) + default: break + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + public func unmount() async throws { + let request = Request.repositoryUnmount( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public static func == (lhs: Repository, rhs: Repository) -> Bool { + return lhs.handle.value == rhs.handle.value + } + + public var description: String { "\(type(of: self))(\(handle))" } +} + +public class File { + internal let client: Client + public let handle: OuisyncFileHandle + + internal init(_ client: Client, _ handle: OuisyncFileHandle) { + self.client = client + self.handle = handle + } + + /// Closes the file. + public func close() async throws { + let request = Request.fileClose( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Flushes any pending writes to the file. + public func flush() async throws { + let request = Request.fileFlush( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Returns the length of the file in bytes + public func getLength() async throws -> UInt64 { + let request = Request.fileGetLength( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .u64(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Returns the sync progress of this file, that is, the total byte size of all the blocks of + /// this file that's already been downloaded. + /// + /// Note that Ouisync downloads the blocks in random order, so until the file's been completely + /// downloaded, the already downloaded blocks are not guaranteed to continuous (there might be + /// gaps). + public func getProgress() async throws -> UInt64 { + let request = Request.fileGetProgress( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .u64(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Reads `size` bytes from the file starting at `offset` bytes from the beginning of the file. + public func read(_ offset: UInt64, _ size: UInt64) async throws -> Data { + let request = Request.fileRead( + handle, + offset, + size, + ) + let response = try await client.invoke(request) + switch response { + case .bytes(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Truncates the file to the given length. + public func truncate(_ len: UInt64) async throws { + let request = Request.fileTruncate( + handle, + len, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Writes the data to the file at the given offset. + public func write(_ offset: UInt64, _ data: Data) async throws { + let request = Request.fileWrite( + handle, + offset, + data, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public static func == (lhs: File, rhs: File) -> Bool { + return lhs.handle.value == rhs.handle.value + } + + public var description: String { "\(type(of: self))(\(handle))" } +} + +public class NetworkSocket { + internal let client: Client + public let handle: NetworkSocketHandle + + internal init(_ client: Client, _ handle: NetworkSocketHandle) { + self.client = client + self.handle = handle + } + + public func close() async throws { + let request = Request.networkSocketClose( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public func recvFrom(_ len: UInt64) async throws -> Datagram { + let request = Request.networkSocketRecvFrom( + handle, + len, + ) + let response = try await client.invoke(request) + switch response { + case .datagram(let value): return value + default: + throw UnexpectedResponse() + } + } + + public func sendTo(_ data: Data, _ addr: String) async throws -> UInt64 { + let request = Request.networkSocketSendTo( + handle, + data, + addr, + ) + let response = try await client.invoke(request) + switch response { + case .u64(let value): return value + default: + throw UnexpectedResponse() + } + } + + public static func == (lhs: NetworkSocket, rhs: NetworkSocket) -> Bool { + return lhs.handle.value == rhs.handle.value + } + + public var description: String { "\(type(of: self))(\(handle))" } +} + +public class NetworkStream { + internal let client: Client + public let handle: NetworkStreamHandle + + internal init(_ client: Client, _ handle: NetworkStreamHandle) { + self.client = client + self.handle = handle + } + + /// Gracefully closes the given raw byte stream. + public func close() async throws { + let request = Request.networkStreamClose( + handle, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + /// Reads exactly the given number of bytes from the given raw byte stream. + public func readExact(_ len: UInt64) async throws -> Data { + let request = Request.networkStreamReadExact( + handle, + len, + ) + let response = try await client.invoke(request) + switch response { + case .bytes(let value): return value + default: + throw UnexpectedResponse() + } + } + + /// Writes the whole buffer to the given raw byte stream. + public func writeAll(_ buf: Data) async throws { + let request = Request.networkStreamWriteAll( + handle, + buf, + ) + let response = try await client.invoke(request) + switch response { + case .unit: + return + default: + throw UnexpectedResponse() + } + } + + public static func == (lhs: NetworkStream, rhs: NetworkStream) -> Bool { + return lhs.handle.value == rhs.handle.value + } + + public var description: String { "\(type(of: self))(\(handle))" } +} + +public class UnexpectedResponse: Error {} + +private func _msgpackInt(_ v: MessagePackValue) -> Int64? { + switch v { case .int(let n): return n; case .uint(let n): return Int64(bitPattern: n); default: return nil } +} +private func _msgpackUInt(_ v: MessagePackValue) -> UInt64? { + switch v { case .uint(let n): return n; case .int(let n): return UInt64(bitPattern: n); default: return nil } +} + diff --git a/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift new file mode 100644 index 000000000..e83399357 --- /dev/null +++ b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift @@ -0,0 +1,99 @@ +import Foundation +@_exported import OuisyncLibCore +import OuisyncLibFFI + +// ErrorCode from cbindgen is typedef uint16_t; Swift imports it as UInt16. +// The callback pointer type used by both start_service and stop_service. +private typealias ServiceCallback = @convention(c) (UnsafeRawPointer?, UInt16) -> Void + +// MARK: - OuisyncService + +public class OuisyncService { + let handle: UnsafeMutableRawPointer + + private init(_ handle: UnsafeMutableRawPointer) { + self.handle = handle + } + + /// Initialize logging. Call before start(). + public static func initLog() { + init_log() + } + + /// Start the ouisync service and wait until it is ready to accept connections. + /// Returns a service handle; call stop() when done. + public static func start(configDir: String, debugLabel: String? = nil) async throws -> OuisyncService { + var rawHandle: UnsafeMutableRawPointer? + + do { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let ctx = Unmanaged.passRetained(StartBox(cont)).toOpaque() + rawHandle = configDir.withCString { cdir in + if let label = debugLabel { + return label.withCString { clab in + start_service(cdir, clab, serviceStarted, ctx) + } + } else { + return start_service(cdir, nil, serviceStarted, ctx) + } + } + } + } catch { + // start_service always returns a handle (the oneshot::Sender box). + // Free it even when initialization fails; the callback won't fire. + if let h = rawHandle { + stop_service(h, serviceNoop, nil) + } + throw error + } + + guard let h = rawHandle else { + throw OuisyncLibCore.OuisyncError(.other, "start_service returned null") + } + return OuisyncService(h) + } + + /// Stop the service and wait for shutdown to complete. + public func stop() async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let ctx = Unmanaged.passRetained(StopBox(cont)).toOpaque() + stop_service(handle, serviceStopped, ctx) + } + } +} + +// MARK: - Continuation boxes + +private class StartBox { + let continuation: CheckedContinuation + init(_ c: CheckedContinuation) { continuation = c } +} + +private class StopBox { + let continuation: CheckedContinuation + init(_ c: CheckedContinuation) { continuation = c } +} + +// MARK: - C callbacks (no captures — compatible with @convention(c)) + +private func serviceStarted(_ ctx: UnsafeRawPointer?, _ code: UInt16) { + guard let ctx else { return } + Unmanaged.fromOpaque(ctx).takeRetainedValue() + .continuation.resume(with: serviceResult(code)) +} + +private func serviceStopped(_ ctx: UnsafeRawPointer?, _ code: UInt16) { + guard let ctx else { return } + Unmanaged.fromOpaque(ctx).takeRetainedValue() + .continuation.resume(with: serviceResult(code)) +} + +private func serviceNoop(_: UnsafeRawPointer?, _: UInt16) {} + +private func serviceResult(_ code: UInt16) -> Result { + if code == 0 { + return .success(()) + } + let errorCode = OuisyncLibCore.ErrorCode(rawValue: code) ?? .other + return .failure(OuisyncLibCore.OuisyncError(errorCode, "service error (code \(code))")) +} diff --git a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift index c6b9d946a..f6e65c90e 100644 --- a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift +++ b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift @@ -1,12 +1,143 @@ -import XCTest -@testable import OuisyncLib +import Foundation +import Testing +import OuisyncLib -final class OuisyncLibTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest +// Each @Test calls withFixture, which handles service + session lifecycle. +// This avoids relying on async deinit (not supported in Swift Testing). +// configDir is passed to the body so tests can open additional sessions. +private func withFixture( + _ body: (_ session: Session, _ configDir: String) async throws -> Void +) async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("ouisync-\(UUID().uuidString)") + let configDir = tempDir.appendingPathComponent("config").path + let storeDir = tempDir.appendingPathComponent("store").path + try FileManager.default.createDirectory(atPath: storeDir, withIntermediateDirectories: true) - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + OuisyncService.initLog() + let service = try await OuisyncService.start(configDir: configDir) + let session = try await Session.create(configPath: configDir) + try await session.setStoreDirs([storeDir]) + + var bodyError: Error? + do { + try await body(session, configDir) + } catch { + bodyError = error + } + + await session.close() + try? await service.stop() + try? FileManager.default.removeItem(at: tempDir) + + if let error = bodyError { throw error } +} + +@Suite struct OuisyncLibTests { + + // MARK: - Repository + + @Test func listRepositoriesEmpty() async throws { + try await withFixture { session, _ in + let repos = try await session.listRepositories() + #expect(repos.isEmpty) + } + } + + @Test func createRepository() async throws { + try await withFixture { session, _ in + let repo = try await session.createRepository("repo") + defer { Task { try? await repo.close() } } + + let repos = try await session.listRepositories() + #expect(repos.count == 1) + } + } + + @Test func deleteRepository() async throws { + try await withFixture { session, _ in + let repo = try await session.createRepository("repo") + try await repo.delete() + + let repos = try await session.listRepositories() + #expect(repos.isEmpty) + } + } + + @Test func multipleRepositories() async throws { + try await withFixture { session, _ in + let a = try await session.createRepository("a") + defer { Task { try? await a.close() } } + let b = try await session.createRepository("b") + defer { Task { try? await b.close() } } + + let repos = try await session.listRepositories() + #expect(repos.count == 2) + } + } + + // MARK: - File + + @Test func fileWriteAndRead() async throws { + try await withFixture { session, _ in + let content = Data("hello ouisync".utf8) + let repo = try await session.createRepository("repo") + defer { Task { try? await repo.close() } } + + let fileW = try await repo.createFile("test.txt") + try await fileW.write(0, content) + try await fileW.flush() + try await fileW.close() + + let fileR = try await repo.openFile("test.txt") + defer { Task { try? await fileR.close() } } + let length = try await fileR.getLength() + let data = try await fileR.read(0, length) + #expect(data == content) + } + } + + @Test func fileTruncate() async throws { + try await withFixture { session, _ in + let repo = try await session.createRepository("repo") + defer { Task { try? await repo.close() } } + + let file = try await repo.createFile("test.txt") + defer { Task { try? await file.close() } } + + try await file.write(0, Data("hello world".utf8)) + try await file.flush() + #expect(try await file.getLength() == 11) + + try await file.truncate(5) + #expect(try await file.getLength() == 5) + + let slice = try await file.read(0, 5) + #expect(String(data: slice, encoding: .utf8) == "hello") + } + } + + @Test func openMissingFileThrows() async throws { + try await withFixture { session, _ in + let repo = try await session.createRepository("repo") + defer { Task { try? await repo.close() } } + + await #expect(throws: OuisyncError.NotFound.self) { + _ = try await repo.openFile("missing.txt") + } + } + } + + // MARK: - Session + + @Test func multipleSessionsSameRuntimeId() async throws { + try await withFixture { session, configDir in + let other = try await Session.create(configPath: configDir) + defer { Task { await other.close() } } + + let id1 = try await session.getRuntimeId() + let id2 = try await other.getRuntimeId() + #expect(id1.value == id2.value) + } } } diff --git a/bindings/swift/OuisyncLib/build-xcframework-macos.sh b/bindings/swift/OuisyncLib/build-xcframework-macos.sh new file mode 100755 index 000000000..fe8e66a8f --- /dev/null +++ b/bindings/swift/OuisyncLib/build-xcframework-macos.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Build OuisyncLibFFI.xcframework for the native macOS host. +# Run this once before `swift test` or opening the package in Xcode. +set -euo pipefail + +CARGO_BIN="${CARGO_HOME:-$HOME/.cargo}/bin" +export PATH="$CARGO_BIN:$PATH" +CARGO="$CARGO_BIN/cargo" +CBINDGEN="$CARGO_BIN/cbindgen" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)" +PACKAGE_DIR="$SCRIPT_DIR" + +ARCH="$(uname -m)" +case "$ARCH" in + arm64) TARGET="aarch64-apple-darwin" ;; + x86_64) TARGET="x86_64-apple-darwin" ;; + *) echo "Unsupported arch: $ARCH"; exit 1 ;; +esac + +BUILD_DIR="$PROJECT_ROOT/target" +LIB="$BUILD_DIR/$TARGET/release/libouisync_service.a" +INCLUDE="$BUILD_DIR/swift-include" +XCF="$PACKAGE_DIR/output/OuisyncLibFFI.xcframework" + +echo "==> Building ouisync-service for $TARGET..." +cd "$PROJECT_ROOT" +"$CARGO" build --package ouisync-service --release --target "$TARGET" + +echo "==> Generating bindings header..." +mkdir -p "$INCLUDE" +"$CBINDGEN" --lang C \ + --crate ouisync-service \ + --config service/cbindgen.toml \ + > "$INCLUDE/bindings.h" + +cat > "$INCLUDE/module.modulemap" <<'EOF' +module OuisyncLibFFI { + header "bindings.h" + export * +} +EOF + +echo "==> Creating xcframework..." +rm -rf "$XCF" +xcodebuild -create-xcframework \ + -library "$LIB" \ + -headers "$INCLUDE" \ + -output "$XCF" + +echo "==> Done: $XCF" diff --git a/bindings/swift/OuisyncLib/build-xcframework.sh b/bindings/swift/OuisyncLib/build-xcframework.sh new file mode 100755 index 000000000..30dc0b732 --- /dev/null +++ b/bindings/swift/OuisyncLib/build-xcframework.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Build OuisyncLibFFI.xcframework for all platforms listed in config.sh. +# Run this once before `swift build`, `swift test`, or opening the package in Xcode. +# Re-run any time the Rust service API changes. +# +# Targets are read from config.sh (TARGETS array). SKIP is intentionally ignored — +# this script always builds. Set DEBUG=1 in config.sh or in the environment for a +# debug build (much faster, but not suitable for production). +# +# Prerequisites: +# cargo, cbindgen, rustup +# Rust targets: rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios +# Xcode (not just CLT): xcode-select -p must print …/Xcode.app/Contents/Developer +set -euo pipefail + +CARGO_BIN="${CARGO_HOME:-$HOME/.cargo}/bin" +export PATH="$CARGO_BIN:$PATH" +CARGO="$CARGO_BIN/cargo" +CBINDGEN="$CARGO_BIN/cbindgen" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)" +PACKAGE_DIR="$SCRIPT_DIR" + +# Read TARGETS (and optional DEBUG) from config.sh; SKIP is deliberately not used here. +source "$SCRIPT_DIR/config.sh" + +if [[ "${DEBUG:-}" == "1" ]]; then + CONFIGURATION="debug" + RELEASE_FLAG="" +else + CONFIGURATION="release" + RELEASE_FLAG="--release" +fi + +BUILD_DIR="$PROJECT_ROOT/target" +INCLUDE="$BUILD_DIR/swift-include" +XCF="$PACKAGE_DIR/output/OuisyncLibFFI.xcframework" + +# ── 1. Build each enabled target ────────────────────────────────────────────── +cd "$PROJECT_ROOT" +for TARGET in "${TARGETS[@]}"; do + echo "==> Building ouisync-service for $TARGET..." + "$CARGO" build --package ouisync-service $RELEASE_FLAG --target "$TARGET" +done + +# ── 2. Generate C header and module map ─────────────────────────────────────── +echo "==> Generating bindings header..." +mkdir -p "$INCLUDE" +"$CBINDGEN" --lang C \ + --crate ouisync-service \ + --config service/cbindgen.toml \ + > "$INCLUDE/bindings.h" +cat > "$INCLUDE/module.modulemap" <<'EOF' +module OuisyncLibFFI { + header "bindings.h" + export * +} +EOF + +# ── 3. Assemble xcframework ──────────────────────────────────────────────────── +echo "==> Creating xcframework..." +rm -rf "$XCF" +XCF_PARAMS=() + +# Returns 0 if $1 is listed in the TARGETS array, 1 otherwise. +target_enabled() { + local needle="$1" + for t in "${TARGETS[@]}"; do + [[ "$t" == "$needle" ]] && return 0 + done + return 1 +} + +# Adds a -library/-headers pair to XCF_PARAMS for the given list of .a files. +# Runs lipo when more than one library is supplied. +add_slice() { + local name="$1"; shift # logical name used as lipo output dir + local libs=("$@") + [[ ${#libs[@]} -eq 0 ]] && return 0 + + local library + if [[ ${#libs[@]} -eq 1 ]]; then + library="${libs[0]}" + else + library="$BUILD_DIR/lipo-$name/libouisync_service.a" + mkdir -p "$(dirname "$library")" + echo "==> lipo $name: ${libs[*]}" + lipo -create "${libs[@]}" -output "$library" + fi + XCF_PARAMS+=("-library" "$library" "-headers" "$INCLUDE") +} + +# macOS: universal binary from arm64 + x86_64 +macos_libs=() +for TARGET in aarch64-apple-darwin x86_64-apple-darwin; do + if target_enabled "$TARGET"; then + macos_libs+=("$BUILD_DIR/$TARGET/$CONFIGURATION/libouisync_service.a") + fi +done +add_slice macos "${macos_libs[@]}" + +# iOS device: arm64 only (no lipo needed) +ios_libs=() +if target_enabled aarch64-apple-ios; then + ios_libs+=("$BUILD_DIR/aarch64-apple-ios/$CONFIGURATION/libouisync_service.a") +fi +add_slice ios "${ios_libs[@]}" + +# iOS simulator: universal binary from arm64-sim + x86_64 +sim_libs=() +for TARGET in aarch64-apple-ios-sim x86_64-apple-ios; do + if target_enabled "$TARGET"; then + sim_libs+=("$BUILD_DIR/$TARGET/$CONFIGURATION/libouisync_service.a") + fi +done +add_slice ios-simulator "${sim_libs[@]}" + +xcodebuild -create-xcframework "${XCF_PARAMS[@]}" -output "$XCF" +echo "==> Done: $XCF" diff --git a/bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample b/bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample deleted file mode 100644 index 670c55dbb..000000000 --- a/bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample +++ /dev/null @@ -1,13 +0,0 @@ - - - - - AvailableLibraries - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - diff --git a/bindings/swift/example/.gitignore b/bindings/swift/example/.gitignore new file mode 100644 index 000000000..6258da182 --- /dev/null +++ b/bindings/swift/example/.gitignore @@ -0,0 +1,13 @@ +# Build output +.build/ +DerivedData/ +build/ + +# Xcode user-specific files +xcuserdata/ +*.xcuserstate +*.xcscmblueprint +*.xccheckout + +# Xcode auto-generated workspace data (schemes, package pins, etc.) +xcshareddata/ diff --git a/bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj b/bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj new file mode 100644 index 000000000..f0b510204 --- /dev/null +++ b/bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj @@ -0,0 +1,344 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + C0000000000000000000B001 /* OuisyncExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0000000000000000000A001 /* OuisyncExampleApp.swift */; }; + C0000000000000000000B002 /* ExampleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0000000000000000000A002 /* ExampleViewModel.swift */; }; + C0000000000000000000B003 /* RepositoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0000000000000000000A003 /* RepositoryListView.swift */; }; + C0000000000000000000B004 /* FolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0000000000000000000A004 /* FolderView.swift */; }; + C0000000000000000000B005 /* FileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0000000000000000000A005 /* FileView.swift */; }; + C0000000000000000000B006 /* OuisyncLib in Frameworks */ = {isa = PBXBuildFile; productRef = C0000000000000000001D002 /* OuisyncLib */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + C0000000000000000000A001 /* OuisyncExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OuisyncExampleApp.swift; sourceTree = ""; }; + C0000000000000000000A002 /* ExampleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleViewModel.swift; sourceTree = ""; }; + C0000000000000000000A003 /* RepositoryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListView.swift; sourceTree = ""; }; + C0000000000000000000A004 /* FolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderView.swift; sourceTree = ""; }; + C0000000000000000000A005 /* FileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileView.swift; sourceTree = ""; }; + C0000000000000000000A009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C0000000000000000000A00E /* OuisyncExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OuisyncExample.entitlements; sourceTree = ""; }; + C0000000000000000000A007 /* OuisyncExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OuisyncExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C0000000000000000000C002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C0000000000000000000B006 /* OuisyncLib in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C0000000000000000000D001 /* Products */ = { + isa = PBXGroup; + children = ( + C0000000000000000000A007 /* OuisyncExample.app */, + ); + name = Products; + sourceTree = ""; + }; + C0000000000000000000D002 /* Sources */ = { + isa = PBXGroup; + children = ( + C0000000000000000000A001 /* OuisyncExampleApp.swift */, + C0000000000000000000A002 /* ExampleViewModel.swift */, + C0000000000000000000A003 /* RepositoryListView.swift */, + C0000000000000000000A004 /* FolderView.swift */, + C0000000000000000000A005 /* FileView.swift */, + C0000000000000000000A009 /* Info.plist */, + C0000000000000000000A00E /* OuisyncExample.entitlements */, + ); + path = Sources; + sourceTree = ""; + }; + C0000000000000000000D003 = { + isa = PBXGroup; + children = ( + C0000000000000000000D002 /* Sources */, + C0000000000000000000D001 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C0000000000000000000E001 /* OuisyncExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = C0000000000000000001C002 /* Build configuration list for PBXNativeTarget "OuisyncExample" */; + buildPhases = ( + C0000000000000000000C002 /* Frameworks */, + C0000000000000000000C001 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OuisyncExample; + packageProductDependencies = ( + C0000000000000000001D002 /* OuisyncLib */, + ); + productName = OuisyncExample; + productReference = C0000000000000000000A007 /* OuisyncExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C0000000000000000000F001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + C0000000000000000000E001 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = C0000000000000000001C001 /* Build configuration list for PBXProject "OuisyncExample" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C0000000000000000000D003; + packageReferences = ( + C0000000000000000001D001 /* XCLocalSwiftPackageReference "../OuisyncLib" */, + ); + productRefGroup = C0000000000000000000D001 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C0000000000000000000E001 /* OuisyncExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + C0000000000000000000C001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C0000000000000000000B001 /* OuisyncExampleApp.swift in Sources */, + C0000000000000000000B002 /* ExampleViewModel.swift in Sources */, + C0000000000000000000B003 /* RepositoryListView.swift in Sources */, + C0000000000000000000B004 /* FolderView.swift in Sources */, + C0000000000000000000B005 /* FileView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + C0000000000000000001A001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + C0000000000000000001A002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C0000000000000000001B001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Sources/OuisyncExample.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5SR9R72Z83; + INFOPLIST_FILE = Sources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ie.equalit.ouisync.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C0000000000000000001B002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Sources/OuisyncExample.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5SR9R72Z83; + INFOPLIST_FILE = Sources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ie.equalit.ouisync.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C0000000000000000001C001 /* Build configuration list for PBXProject "OuisyncExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0000000000000000001A001 /* Debug */, + C0000000000000000001A002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C0000000000000000001C002 /* Build configuration list for PBXNativeTarget "OuisyncExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0000000000000000001B001 /* Debug */, + C0000000000000000001B002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + C0000000000000000001D001 /* XCLocalSwiftPackageReference "../OuisyncLib" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../OuisyncLib; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C0000000000000000001D002 /* OuisyncLib */ = { + isa = XCSwiftPackageProductDependency; + package = C0000000000000000001D001 /* XCLocalSwiftPackageReference "../OuisyncLib" */; + productName = OuisyncLib; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = C0000000000000000000F001 /* Project object */; +} diff --git a/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/bindings/swift/example/README.md b/bindings/swift/example/README.md new file mode 100644 index 000000000..fad7f72c9 --- /dev/null +++ b/bindings/swift/example/README.md @@ -0,0 +1,64 @@ +# OuisyncExample + +A minimal SwiftUI example app for the OuisyncLib Swift bindings. The single +Xcode project targets both **macOS** (14+) and **iOS** (17+) from one shared +codebase. + +## Structure + +`ExampleViewModel` manages the service and session lifecycle and exposes +repository state to the UI via `@Published` properties. The UI is split across +three views: + +| File | Screen | Demonstrates | +|---|---|---| +| `RepositoryListView.swift` | Repository list | Creating, deleting, and sharing repositories | +| `FolderView.swift` | Folder contents | Browsing directories recursively | +| `FileView.swift` | File detail | Opening a file, tracking sync progress, reading content | + +Platform differences (clipboard API, window sizing) are isolated to a handful +of `#if os(macOS)` guards in those files. + +## Prerequisites + +Build the xcframework before opening the project (from the repo root): + +```sh +bash bindings/swift/OuisyncLib/build-xcframework.sh +``` + +This compiles the Rust service for all targets in `config.sh` and produces +`OuisyncLib/output/OuisyncLibFFI.xcframework`. + +## Running + +Open the project in Xcode: + +```sh +open bindings/swift/example/OuisyncExample.xcodeproj +``` + +Select a destination (Mac, iPhone simulator, iPad simulator) from the scheme +picker and press **Run** (⌘R). + +The app stores its data under `~/Library/Application Support/OuisyncExample/`. + +## Usage + +The app starts the Ouisync service automatically on launch. + +**Repository list** — lists all repositories. Use the **+** button to create a +new one (optionally pasting a share token to import someone else's repository). +The **share** icon copies a write-access share token to the clipboard; the +**trash** icon deletes the repository. + +**Folder view** — tap a repository to browse its root directory. Subdirectories +and files are shown; tap to navigate deeper. + +**File view** — shows file size, sync status, and SHA-256 hash. Use the pencil +icon to write text content and the refresh button to re-read after the file +syncs further. + +> **Note:** The Swift bindings do not yet expose a `subscribe()` method on +> `Repository`, so the file view polls for sync progress (once per second) +> rather than reacting to live notifications. diff --git a/bindings/swift/example/Sources/ExampleViewModel.swift b/bindings/swift/example/Sources/ExampleViewModel.swift new file mode 100644 index 000000000..aed977db5 --- /dev/null +++ b/bindings/swift/example/Sources/ExampleViewModel.swift @@ -0,0 +1,159 @@ +import Foundation +import OuisyncLib + +struct PendingShare: Equatable { + let token: String + let suggestedName: String + + // Receives an ouisync:// URL, converts it back to https://ouisync.net/... + // for validateShareToken, and extracts the suggested name from the fragment. + init?(url: URL) { + guard url.scheme == "ouisync", + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + components.scheme = "https" + guard let httpsURL = components.url else { return nil } + token = httpsURL.absoluteString + + // Fragment may be "" or "?name=" + let fragment = url.fragment ?? "" + if let queryStart = fragment.firstIndex(of: "?") { + let query = String(fragment[fragment.index(after: queryStart)...]) + let params = query.split(separator: "&").reduce(into: [String: String]()) { dict, pair in + let kv = pair.split(separator: "=", maxSplits: 1) + if kv.count == 2 { + dict[String(kv[0])] = String(kv[1]).removingPercentEncoding ?? String(kv[1]) + } + } + suggestedName = params["name"] ?? "" + } else { + suggestedName = "" + } + } +} + +private let configDir: String = { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appendingPathComponent("OuisyncExample/config").path +}() + +private let storeDir: String = { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appendingPathComponent("OuisyncExample/store").path +}() + +@MainActor +class ExampleViewModel: ObservableObject { + private var service: OuisyncService? + private var session: Session? + + @Published var sessionError: String? + @Published var repositories: [String: Repository] = [:] + @Published var repositoryInfoHashes: [String: String] = [:] + @Published var pendingShare: PendingShare? + + + init() { + Task { await start() } + } + + // MARK: - Lifecycle + + private func start() async { + try? FileManager.default.createDirectory(atPath: storeDir, withIntermediateDirectories: true) + + OuisyncService.initLog() + + do { + service = try await OuisyncService.start(configDir: configDir) + } catch let error as OuisyncError where error.code == .serviceAlreadyRunning { + // another instance started the service, connect anyway + } catch { + sessionError = "Service failed to start: \(error)" + return + } + + do { + session = try await Session.create(configPath: configDir) + try await session?.setStoreDirs([storeDir]) + } catch { + sessionError = "Session failed to connect: \(error)" + return + } + + // Bind to all interfaces on random ports, QUIC only, IPv4 + IPv6. + try? await session?.bindNetwork(["quic/0.0.0.0:0", "quic/[::]:0"]) + // UPnP improves reachability behind NAT. + try? await session?.setPortForwardingEnabled(true) + // Automatically discover peers on the LAN. + try? await session?.setLocalDiscoveryEnabled(true) + + await loadRepositories() + } + + func shutdown() async { + let repos = repositories.values + repositories = [:] + for repo in repos { + try? await repo.close() + } + await session?.close() + session = nil + try? await service?.stop() + service = nil + } + + // MARK: - Repository management + + func loadRepositories() async { + guard let session else { return } + do { + repositories = try await session.listRepositories() + for (_, repo) in repositories { + try await repo.setSyncEnabled(true) + } + await loadInfoHashes() + } catch { + sessionError = "Failed to list repositories: \(error)" + } + } + + private func loadInfoHashes() async { + var hashes: [String: String] = [:] + for (name, repo) in repositories { + if let hash = try? await repo.getInfoHash() { + hashes[name] = hash + print("[ouisync] repository \"\(name)\" info_hash=\(hash)") + } + } + repositoryInfoHashes = hashes + } + + func createRepository(name: String, token: String) async throws { + guard let session else { return } + + var shareToken: ShareToken? = nil + if !token.isEmpty { + shareToken = try await session.validateShareToken(token) + } + + // syncEnabled / dhtEnabled / pexEnabled all true so the new repo starts syncing immediately. + let repo = try await session.createRepository(name, nil, nil, shareToken, true, true, true) + repositories[name] = repo + if let hash = try? await repo.getInfoHash() { + repositoryInfoHashes[name] = hash + print("[ouisync] repository \"\(name)\" info_hash=\(hash)") + } + } + + func deleteRepository(name: String) async throws { + guard let repo = repositories[name] else { return } + repositories.removeValue(forKey: name) + repositoryInfoHashes.removeValue(forKey: name) + try await repo.delete() + } + + func shareRepository(name: String) async -> String? { + guard let repo = repositories[name] else { return nil } + return try? await repo.share(.write).value + } +} diff --git a/bindings/swift/example/Sources/FileView.swift b/bindings/swift/example/Sources/FileView.swift new file mode 100644 index 000000000..d9cdd9226 --- /dev/null +++ b/bindings/swift/example/Sources/FileView.swift @@ -0,0 +1,230 @@ +import SwiftUI +import CryptoKit +import OuisyncLib + +struct FileView: View { + @EnvironmentObject private var viewModel: ExampleViewModel + let repositoryName: String + let path: String + + @State private var state: FileState = .loading + @State private var isWriting = false + @State private var errorMessage: String? + + private var repo: Repository? { viewModel.repositories[repositoryName] } + + var body: some View { + Group { + switch state { + case .loading: + ProgressView("Loading…") + case .syncing(let progress): + VStack(spacing: 12) { + ProgressView("Syncing…", value: progress) + Text("\(Int(progress * 100))%").foregroundStyle(.secondary) + } + .padding() + case .reading(let progress): + VStack(spacing: 12) { + ProgressView("Reading…", value: progress) + Text("\(Int(progress * 100))%").foregroundStyle(.secondary) + } + .padding() + case .done(let info): + fileInfoTable(info) + case .error(let message): + ContentUnavailableView( + "Error", + systemImage: "exclamationmark.triangle", + description: Text(message) + ) + } + } + .navigationTitle((path as NSString).lastPathComponent) + .task { await loadFile() } + .toolbar { + ToolbarItem { + Button { isWriting = true } label: { Image(systemName: "pencil") } + .help("Write text content") + } + ToolbarItem { + Button { Task { await loadFile() } } label: { Image(systemName: "arrow.clockwise") } + .help("Refresh") + } + } + .sheet(isPresented: $isWriting) { + WriteContentSheet { text in + isWriting = false + Task { await writeContent(text) } + } onCancel: { + isWriting = false + } + .padding() + } + .alert("Error", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + + // MARK: - Info table + + private func fileInfoTable(_ info: FileInfo) -> some View { + Form { + LabeledContent("Size", value: formatSize(info.length)) + if !info.fullySync { + LabeledContent("Synced", value: "\(info.syncedBytes) / \(info.length) bytes") + } + if !info.sha256.isEmpty { + LabeledContent("SHA-256", value: info.sha256) + } + if let text = info.text { + Section("Content") { + Text(text) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + } + .formStyle(.grouped) + } + + // MARK: - Load + + private func loadFile() async { + guard let repo else { + state = .error("Repository '\(repositoryName)' not found") + return + } + + state = .loading + + do { + let file = try await repo.openFile(path) + defer { Task { try? await file.close() } } + + let length = try await file.getLength() + + // Poll for sync progress until the file is fully downloaded. + // (subscribe() is not yet wired in the Swift bindings.) + while true { + let synced = try await file.getProgress() + let progress = length > 0 ? Double(synced) / Double(length) : 1.0 + state = .syncing(progress) + if synced >= length { break } + try await Task.sleep(for: .seconds(1)) + } + + if length == 0 { + state = .done(FileInfo(length: 0, syncedBytes: 0, fullySync: true, sha256: "", text: nil)) + return + } + + // Read the full file in chunks, computing SHA-256 along the way. + let chunkSize: UInt64 = 65536 + var offset: UInt64 = 0 + var hasher = SHA256() + var allData = Data(capacity: Int(min(length, 1024 * 1024))) + + while offset < length { + let chunk = try await file.read(offset, min(chunkSize, length - offset)) + hasher.update(data: chunk) + allData.append(chunk) + offset += UInt64(chunk.count) + state = .reading(Double(offset) / Double(length)) + } + + let digest = hasher.finalize() + let hex = digest.map { String(format: "%02x", $0) }.joined() + let text = allData.count <= 4096 ? String(data: allData, encoding: .utf8) : nil + + state = .done(FileInfo(length: length, syncedBytes: length, fullySync: true, sha256: hex, text: text)) + } catch { + state = .error(error.localizedDescription) + } + } + + // MARK: - Write + + private func writeContent(_ text: String) async { + guard let repo else { return } + do { + let data = Data(text.utf8) + let file = try await repo.createFile(path) + defer { Task { try? await file.close() } } + try await file.write(0, data) + try await file.flush() + await loadFile() + } catch { + errorMessage = error.localizedDescription + } + } +} + +// MARK: - Write-content sheet + +private struct WriteContentSheet: View { + let onSubmit: (String) -> Void + let onCancel: () -> Void + + @State private var text = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Write content").font(.headline) + TextEditor(text: $text) + .font(.system(.body, design: .monospaced)) +#if os(macOS) + .frame(width: 380, height: 160) +#else + .frame(minHeight: 160) +#endif + .border(Color.secondary.opacity(0.3)) + HStack { + Spacer() + Button("Cancel") { onCancel() } + Button("Write") { onSubmit(text) } + .buttonStyle(.borderedProminent) + .disabled(text.isEmpty) + } + } +#if os(macOS) + .frame(width: 400) +#endif + } +} + +// MARK: - Supporting types + +private enum FileState { + case loading + case syncing(Double) + case reading(Double) + case done(FileInfo) + case error(String) +} + +private struct FileInfo { + let length: UInt64 + let syncedBytes: UInt64 + let fullySync: Bool + let sha256: String + let text: String? // non-nil when file is small and valid UTF-8 +} + +// MARK: - Helpers + +private func formatSize(_ bytes: UInt64) -> String { + let kilo: UInt64 = 1024 + let mega: UInt64 = 1024 * 1024 + let giga: UInt64 = 1024 * 1024 * 1024 + + if bytes >= giga { return String(format: "%.1f GiB", Double(bytes) / Double(giga)) } + if bytes >= mega { return String(format: "%.1f MiB", Double(bytes) / Double(mega)) } + if bytes >= kilo { return String(format: "%.1f KiB", Double(bytes) / Double(kilo)) } + return "\(bytes) B" +} diff --git a/bindings/swift/example/Sources/FolderView.swift b/bindings/swift/example/Sources/FolderView.swift new file mode 100644 index 000000000..aa898d5d0 --- /dev/null +++ b/bindings/swift/example/Sources/FolderView.swift @@ -0,0 +1,180 @@ +import SwiftUI +import OuisyncLib + +struct FolderView: View { + @EnvironmentObject private var viewModel: ExampleViewModel + let repositoryName: String + let path: String + + @State private var entries: [DirectoryEntry] = [] + @State private var error: String? + @State private var isLoading = true + @State private var isCreating = false + @State private var errorMessage: String? + + private var repo: Repository? { viewModel.repositories[repositoryName] } + + var body: some View { + Group { + if isLoading { + ProgressView() + } else if let error { + ContentUnavailableView( + "Error", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else if entries.isEmpty { + ContentUnavailableView( + "Empty Folder", + systemImage: "folder", + description: Text("Tap + to create a file or directory.") + ) + } else { + entryList + } + } + .navigationTitle(path.isEmpty ? repositoryName : "\(repositoryName)\(path)") + .task { await loadEntries() } + .task(id: repositoryName) { await watchRepository() } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { isCreating = true } label: { Image(systemName: "plus") } + } + ToolbarItem { + Button { Task { await loadEntries() } } label: { Image(systemName: "arrow.clockwise") } + .help("Refresh") + } + } + .sheet(isPresented: $isCreating) { + CreateEntrySheet { name, kind in + isCreating = false + Task { await createEntry(name: name, kind: kind) } + } onCancel: { + isCreating = false + } + .padding() + } + .alert("Error", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + + // MARK: - Entry list + + private var entryList: some View { + List(entries, id: \.name) { entry in + NavigationLink(value: destinationRoute(for: entry)) { + Label(entry.name, systemImage: entry.entryType == .directory ? "folder" : "doc") + } + } + } + + private func destinationRoute(for entry: DirectoryEntry) -> Route { + let entryPath = "\(path)/\(entry.name)" + switch entry.entryType { + case .directory: return .folder(repositoryName: repositoryName, path: entryPath) + case .file: return .file(repositoryName: repositoryName, path: entryPath) + } + } + + // MARK: - Actions + + private func watchRepository() async { + guard let repo else { return } + guard let stream = try? await repo.subscribe() else { return } + for await _ in stream { + await loadEntries() + } + } + + private func loadEntries() async { + guard let repo else { + error = "Repository '\(repositoryName)' not found" + isLoading = false + return + } + isLoading = true + error = nil + do { + entries = try await repo.readDirectory(path) + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + private func createEntry(name: String, kind: EntryKind) async { + guard let repo else { return } + let entryPath = path.isEmpty ? "/\(name)" : "\(path)/\(name)" + do { + switch kind { + case .file: + let file = try await repo.createFile(entryPath) + try await file.close() + case .directory: + try await repo.createDirectory(entryPath) + } + await loadEntries() + } catch { + errorMessage = error.localizedDescription + } + } +} + +// MARK: - Create entry sheet + +enum EntryKind { case file, directory } + +private struct CreateEntrySheet: View { + let onSubmit: (String, EntryKind) -> Void + let onCancel: () -> Void + + @State private var name = "" + @State private var kind: EntryKind = .file + @State private var nameError = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New entry").font(.headline) + + Picker("Kind", selection: $kind) { + Text("File").tag(EntryKind.file) + Text("Directory").tag(EntryKind.directory) + } + .pickerStyle(.segmented) + + VStack(alignment: .leading, spacing: 4) { + TextField("Name", text: $name) + if !nameError.isEmpty { + Text(nameError).font(.caption).foregroundStyle(.red) + } + } + + HStack { + Spacer() + Button("Cancel") { onCancel() } + Button("Create") { + guard validate() else { return } + onSubmit(name, kind) + } + .buttonStyle(.borderedProminent) + } + } +#if os(macOS) + .frame(width: 280) +#endif + } + + private func validate() -> Bool { + if name.isEmpty { nameError = "Name is required"; return false } + if name.contains("/") { nameError = "Name must not contain '/'"; return false } + nameError = "" + return true + } +} diff --git a/bindings/swift/example/Sources/Info.plist b/bindings/swift/example/Sources/Info.plist new file mode 100644 index 000000000..c06cbb471 --- /dev/null +++ b/bindings/swift/example/Sources/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleURLTypes + + + CFBundleURLName + ie.equalit.ouisync + CFBundleURLSchemes + + ouisync + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/bindings/swift/example/Sources/OuisyncExample.entitlements b/bindings/swift/example/Sources/OuisyncExample.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/bindings/swift/example/Sources/OuisyncExample.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/bindings/swift/example/Sources/OuisyncExampleApp.swift b/bindings/swift/example/Sources/OuisyncExampleApp.swift new file mode 100644 index 000000000..5a72429b4 --- /dev/null +++ b/bindings/swift/example/Sources/OuisyncExampleApp.swift @@ -0,0 +1,24 @@ +import SwiftUI + +@main +struct OuisyncExampleApp: App { + @StateObject private var viewModel = ExampleViewModel() + + var body: some Scene { + WindowGroup { + RepositoryListView() + .environmentObject(viewModel) + .onOpenURL { url in + viewModel.pendingShare = PendingShare(url: url) + } +#if os(macOS) + .frame(minWidth: 400, minHeight: 300) +#endif + } +#if os(macOS) + .commands { + CommandGroup(replacing: .newItem) {} + } +#endif + } +} diff --git a/bindings/swift/example/Sources/RepositoryListView.swift b/bindings/swift/example/Sources/RepositoryListView.swift new file mode 100644 index 000000000..ea51047ae --- /dev/null +++ b/bindings/swift/example/Sources/RepositoryListView.swift @@ -0,0 +1,220 @@ +import SwiftUI +import OuisyncLib + +struct RepositoryListView: View { + @EnvironmentObject private var viewModel: ExampleViewModel + @State private var navigationPath: [Route] = [] + @State private var isCreating = false + @State private var initialName = "" + @State private var initialToken = "" + @State private var errorMessage: String? + + var body: some View { + NavigationStack(path: $navigationPath) { + content + .navigationTitle("Repositories") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { isCreating = true } label: { Image(systemName: "plus") } + } + } + .navigationDestination(for: Route.self) { route in + switch route { + case .folder(let repoName, let path): + FolderView(repositoryName: repoName, path: path) + .environmentObject(viewModel) + case .file(let repoName, let path): + FileView(repositoryName: repoName, path: path) + .environmentObject(viewModel) + } + } + } + .sheet(isPresented: $isCreating) { + CreateRepositorySheet(initialName: initialName, initialToken: initialToken) { name, token in + isCreating = false + Task { + do { + try await viewModel.createRepository(name: name, token: token) + } catch { + errorMessage = error.localizedDescription + } + } + } onCancel: { + isCreating = false + } + .padding() + } + .onChange(of: viewModel.pendingShare) { share in + guard let share else { return } + initialName = share.suggestedName + initialToken = share.token + isCreating = true + viewModel.pendingShare = nil + } + .alert("Error", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + } + + @ViewBuilder + private var content: some View { + if let error = viewModel.sessionError { + ContentUnavailableView("Session Error", systemImage: "exclamationmark.triangle", description: Text(error)) + } else if viewModel.repositories.isEmpty { + ContentUnavailableView("No Repositories", systemImage: "folder", description: Text("Tap + to create a new repository.")) + } else { + List(Array(viewModel.repositories.keys.sorted()), id: \.self) { name in + RepositoryRow( + name: URL(fileURLWithPath: name).deletingPathExtension().lastPathComponent, + infoHash: viewModel.repositoryInfoHashes[name], + onNavigate: { + navigationPath.append(.folder(repositoryName: name, path: "")) + }, + onShare: { + await viewModel.shareRepository(name: name) + }, + onDelete: { + Task { + do { + try await viewModel.deleteRepository(name: name) + } catch { + errorMessage = error.localizedDescription + } + } + } + ) + } + } + } +} + +private struct RepositoryRow: View { + let name: String + let infoHash: String? + let onNavigate: () -> Void + let onShare: () async -> String? + let onDelete: () -> Void + + @State private var shareToken: String? + @State private var confirmDelete = false + + var body: some View { + HStack { + Image(systemName: "folder") + VStack(alignment: .leading, spacing: 2) { + Text(name) + if let hash = infoHash { + Text("id: \(hash.prefix(16))…") + .font(.caption2) + .foregroundStyle(.secondary) + .monospaced() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { onNavigate() } + + Group { + if let shareURL = shareToken.flatMap(ouisyncURL(from:)) { + ShareLink(item: shareURL) { + Image(systemName: "square.and.arrow.up") + } + } else { + Image(systemName: "square.and.arrow.up") + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.borderless) + .help("Share repository") + + Button { confirmDelete = true } label: { Image(systemName: "trash") } + .buttonStyle(.borderless) + .foregroundStyle(.red) + } + .task { + shareToken = await onShare() + } + .confirmationDialog("Delete \"\(name)\"?", isPresented: $confirmDelete) { + Button("Delete", role: .destructive) { onDelete() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete the repository and all its contents.") + } + } +} + +private struct CreateRepositorySheet: View { + var initialName: String = "" + var initialToken: String = "" + let onSubmit: (String, String) -> Void + let onCancel: () -> Void + + @State private var name: String + @State private var token: String + @State private var nameError = "" + + init(initialName: String = "", initialToken: String = "", onSubmit: @escaping (String, String) -> Void, onCancel: @escaping () -> Void) { + self.initialName = initialName + self.initialToken = initialToken + self.onSubmit = onSubmit + self.onCancel = onCancel + _name = State(initialValue: initialName) + _token = State(initialValue: initialToken) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Create Repository").font(.headline) + + VStack(alignment: .leading, spacing: 4) { + TextField("Name *", text: $name) + if !nameError.isEmpty { + Text(nameError).font(.caption).foregroundStyle(.red) + } + } + + TextField("Share token (optional)", text: $token) + + HStack { + Spacer() + Button("Cancel") { onCancel() } + Button("Create") { + guard validate() else { return } + onSubmit(name, token) + } + .buttonStyle(.borderedProminent) + } + } +#if os(macOS) + .frame(width: 320) +#endif + } + + private func validate() -> Bool { + if name.isEmpty { + nameError = "Name is required" + return false + } + nameError = "" + return true + } +} + +enum Route: Hashable { + case folder(repositoryName: String, path: String) + case file(repositoryName: String, path: String) +} + +// Converts https://ouisync.net/... → ouisync://ouisync.net/... for sharing. +// AirDrop and other share targets receive the ouisync:// URL; the app converts +// it back to https:// when validating the token on the receiving side. +private func ouisyncURL(from token: String) -> URL? { + guard var components = URLComponents(string: token) else { return nil } + components.scheme = "ouisync" + return components.url +} diff --git a/fs_util/Cargo.toml b/fs_util/Cargo.toml index dfbe489c9..d9067875b 100644 --- a/fs_util/Cargo.toml +++ b/fs_util/Cargo.toml @@ -13,7 +13,7 @@ tokio = { workspace = true, features = ["fs", "io-util", "rt", "sync"] } tokio-stream = { workspace = true } walkdir = "2.5.0" -[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))'.dependencies] +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "ios"))'.dependencies] libc = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/fs_util/src/safe_move.rs b/fs_util/src/safe_move.rs index 687e7394c..55484ad75 100644 --- a/fs_util/src/safe_move.rs +++ b/fs_util/src/safe_move.rs @@ -143,7 +143,7 @@ fn blocking_rename_no_replace_atomic(src: &Path, dst: &Path) -> io::Result<()> { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "ios"))] fn blocking_rename_no_replace_atomic(src: &Path, dst: &Path) -> io::Result<()> { use std::ffi::CString; diff --git a/service/cbindgen.toml b/service/cbindgen.toml new file mode 100644 index 000000000..20a805c47 --- /dev/null +++ b/service/cbindgen.toml @@ -0,0 +1,5 @@ +language = "C" +include_guard = "OUISYNC_SERVICE_H" + +[parse] +parse_deps = false diff --git a/service/src/logger/mod.rs b/service/src/logger/mod.rs index 59540b991..1a0d09dbd 100644 --- a/service/src/logger/mod.rs +++ b/service/src/logger/mod.rs @@ -5,12 +5,10 @@ mod format; #[path = "android.rs"] mod output; -#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_os = "ios"))] #[path = "stdout.rs"] mod output; -// TODO: ios - pub use color::{LogColor, ParseLogColorError}; pub use format::{LogFormat, ParseLogFormatError}; diff --git a/utils/bindgen/src/main.rs b/utils/bindgen/src/main.rs index f30b64733..64fa245c9 100644 --- a/utils/bindgen/src/main.rs +++ b/utils/bindgen/src/main.rs @@ -1,6 +1,7 @@ mod cpp; mod dart; mod kotlin; +mod swift; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -16,6 +17,7 @@ fn main() -> Result<()> { match options.language { Language::Dart => dart::generate(&context, &mut io::stdout())?, Language::Kotlin => kotlin::generate(&context, &mut io::stdout())?, + Language::Swift => swift::generate(&context, &mut io::stdout())?, Language::Cpp { out_dir } => cpp::generate(&context, &out_dir)?, } @@ -34,6 +36,7 @@ struct Options { enum Language { Dart, Kotlin, + Swift, Cpp { /// Output directory for generated *.{hpp,cpp} files out_dir: PathBuf, diff --git a/utils/bindgen/src/swift.rs b/utils/bindgen/src/swift.rs new file mode 100644 index 000000000..6437b3e6e --- /dev/null +++ b/utils/bindgen/src/swift.rs @@ -0,0 +1,1076 @@ +use anyhow::Result; +use heck::{AsLowerCamelCase, AsSnakeCase, AsUpperCamelCase}; +use ouisync_api_parser::{ + ComplexEnum, Context, Docs, Fields, Item, RequestVariant, SimpleEnum, Struct, + ToResponseVariantName, Type, Visibility, +}; +use std::{ + fmt, + io::{self, Write}, +}; + +pub(crate) fn generate(ctx: &Context, out: &mut dyn Write) -> Result<()> { + writeln!(out, "// Auto-generated by ouisync-bindgen. Do not edit.")?; + writeln!(out)?; + writeln!(out, "import Foundation")?; + writeln!(out, "import MessagePack")?; + writeln!(out)?; + + for (name, item) in &ctx.items { + match item { + Item::SimpleEnum(item) => { + write_simple_enum(out, name, item)?; + if name == "ErrorCode" { + write_error(out, item)?; + } + } + Item::ComplexEnum(item) => write_complex_enum(out, name, item)?, + Item::Struct(item) => write_struct(out, name, item)?, + } + } + + write_request_enum(out, &ctx.request.to_enum(), &ctx.request.variants)?; + write_response_enum(out, &ctx.response.to_enum())?; + + write_api_class(out, "Session", false, &ctx.request.variants)?; + write_api_class(out, "Repository", true, &ctx.request.variants)?; + write_api_class(out, "File", true, &ctx.request.variants)?; + write_api_class(out, "NetworkSocket", true, &ctx.request.variants)?; + write_api_class(out, "NetworkStream", true, &ctx.request.variants)?; + + writeln!(out, "public class UnexpectedResponse: Error {{}}")?; + writeln!(out)?; + + // Private msgpack helpers + writeln!(out, "private func _msgpackInt(_ v: MessagePackValue) -> Int64? {{")?; + writeln!(out, "{I}switch v {{ case .int(let n): return n; case .uint(let n): return Int64(bitPattern: n); default: return nil }}")?; + writeln!(out, "}}")?; + writeln!(out, "private func _msgpackUInt(_ v: MessagePackValue) -> UInt64? {{")?; + writeln!(out, "{I}switch v {{ case .uint(let n): return n; case .int(let n): return UInt64(bitPattern: n); default: return nil }}")?; + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +fn write_simple_enum(out: &mut dyn Write, name: &str, item: &SimpleEnum) -> Result<()> { + let repr = enum_repr_str(&item.repr); + + write_docs(out, "", &item.docs)?; + writeln!(out, "public enum {name}: {repr} {{")?; + + for (variant_name, variant) in &item.variants { + write_docs(out, I, &variant.docs)?; + writeln!( + out, + "{I}case {} = {}", + AsLowerCamelCase(variant_name), + variant.value + )?; + } + + // Codec + writeln!(out)?; + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{ .uint(UInt64(rawValue)) }}")?; + writeln!(out, "{I}internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? {{")?; + writeln!(out, "{I}{I}guard let n = _msgpackUInt(v), let r = {repr}(exactly: n) else {{ return nil }}")?; + writeln!(out, "{I}{I}return Self(rawValue: r)")?; + writeln!(out, "{I}}}")?; + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +fn enum_repr_str(repr: &ouisync_api_parser::EnumRepr) -> &'static str { + match repr { + ouisync_api_parser::EnumRepr::U8 => "UInt8", + ouisync_api_parser::EnumRepr::U16 => "UInt16", + ouisync_api_parser::EnumRepr::U32 => "UInt32", + ouisync_api_parser::EnumRepr::U64 => "UInt64", + } +} + +fn write_complex_enum(out: &mut dyn Write, name: &str, item: &ComplexEnum) -> Result<()> { + write_docs(out, "", &item.docs)?; + + match item.visibility { + Visibility::Public => write!(out, "public ")?, + Visibility::Private => write!(out, "internal ")?, + } + + writeln!(out, "enum {name} {{")?; + + for (variant_name, variant) in &item.variants { + write_docs(out, I, &variant.docs)?; + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + + match &variant.fields { + Fields::Named(fields) => { + write!(out, "{I}case {case_name}(")?; + let mut first = true; + for (field_name, field) in fields { + if !first { + write!(out, ", ")?; + } + write!( + out, + "{}: {}", + AsLowerCamelCase(field_name), + SwiftType(&field.ty) + )?; + first = false; + } + writeln!(out, ")")?; + } + Fields::Unnamed(field) => { + writeln!( + out, + "{I}case {case_name}(_ value: {})", + SwiftType(&field.ty) + )?; + } + Fields::Unit => { + writeln!(out, "{I}case {case_name}")?; + } + } + } + + writeln!(out)?; + // encode + write_complex_enum_encode(out, item)?; + writeln!(out)?; + // decode + write_complex_enum_decode(out, item)?; + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +fn write_complex_enum_encode(out: &mut dyn Write, item: &ComplexEnum) -> Result<()> { + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{")?; + writeln!(out, "{I}{I}switch self {{")?; + for (variant_name, variant) in &item.variants { + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + let msgpack_name = format!("{}", AsUpperCamelCase(variant_name)); + match &variant.fields { + Fields::Unit => { + writeln!(out, "{I}{I}case .{case_name}: return .string(\"{msgpack_name}\")")?; + } + Fields::Unnamed(field) => { + let enc = encode_expr(&field.ty, "value"); + writeln!(out, "{I}{I}case .{case_name}(let value): return .map([.string(\"{msgpack_name}\"): {enc}])")?; + } + Fields::Named(fields) => { + // Build pattern + let pattern: Vec = fields + .iter() + .map(|(n, _)| format!("let {}", AsLowerCamelCase(n))) + .collect(); + let pattern_str = pattern.join(", "); + let enc_exprs: Vec = fields + .iter() + .map(|(n, f)| encode_expr(&f.ty, &format!("{}", AsLowerCamelCase(n)))) + .collect(); + let enc_arr = enc_exprs.join(", "); + writeln!(out, "{I}{I}case .{case_name}({pattern_str}): return .map([.string(\"{msgpack_name}\"): .array([{enc_arr}])])")?; + } + } + } + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + Ok(()) +} + +fn write_complex_enum_decode(out: &mut dyn Write, item: &ComplexEnum) -> Result<()> { + writeln!(out, "{I}internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? {{")?; + writeln!(out, "{I}{I}switch v {{")?; + + // Unit variants via .string + let has_unit = item.variants.iter().any(|(_, v)| matches!(v.fields, Fields::Unit)); + if has_unit { + writeln!(out, "{I}{I}case .string(let name):")?; + writeln!(out, "{I}{I}{I}switch name {{")?; + for (variant_name, variant) in &item.variants { + if matches!(variant.fields, Fields::Unit) { + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + let msgpack_name = format!("{}", AsUpperCamelCase(variant_name)); + writeln!(out, "{I}{I}{I}case \"{msgpack_name}\": return .{case_name}")?; + } + } + writeln!(out, "{I}{I}{I}default: return nil")?; + writeln!(out, "{I}{I}{I}}}")?; + } + + // Non-unit variants via .map + let has_non_unit = item.variants.iter().any(|(_, v)| !matches!(v.fields, Fields::Unit)); + if has_non_unit { + writeln!(out, "{I}{I}case .map(let m) where m.count == 1:")?; + writeln!(out, "{I}{I}{I}guard let entry = m.first, case .string(let name) = entry.key else {{ return nil }}")?; + writeln!(out, "{I}{I}{I}switch name {{")?; + for (variant_name, variant) in &item.variants { + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + let msgpack_name = format!("{}", AsUpperCamelCase(variant_name)); + match &variant.fields { + Fields::Unit => {} // handled above + Fields::Unnamed(field) => { + writeln!(out, "{I}{I}{I}case \"{msgpack_name}\":")?; + write_decode_stmts(out, &field.ty, "decoded", "entry.value", 4)?; + writeln!(out, "{I}{I}{I}{I}return .{case_name}(decoded)")?; + } + Fields::Named(fields) => { + writeln!(out, "{I}{I}{I}case \"{msgpack_name}\":")?; + writeln!(out, "{I}{I}{I}{I}guard case .array(let arr) = entry.value, arr.count == {} else {{ return nil }}", fields.len())?; + let mut args = Vec::new(); + for (i, (field_name, field)) in fields.iter().enumerate() { + let fn_lcc = format!("{}", AsLowerCamelCase(field_name)); + write_decode_stmts(out, &field.ty, &fn_lcc, &format!("arr[{i}]"), 4)?; + args.push(format!("{fn_lcc}: {fn_lcc}")); + } + writeln!(out, "{I}{I}{I}{I}return .{case_name}({})", args.join(", "))?; + } + } + } + writeln!(out, "{I}{I}{I}default: return nil")?; + writeln!(out, "{I}{I}{I}}}")?; + } + + writeln!(out, "{I}{I}default: return nil")?; + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + Ok(()) +} + +/// Writes decode guard statements for a given type. Produces: +/// guard let {varname} = ... else { return nil } +/// The `src` is the MessagePackValue expression. +fn write_decode_stmts( + out: &mut dyn Write, + ty: &Type, + varname: &str, + src: &str, + indent: usize, +) -> Result<()> { + let prefix = I.repeat(indent); + match ty { + Type::Unit => { + writeln!(out, "{prefix}let {varname}: Void = ()")?; + } + Type::Scalar(s) => { + if let Some(swift_decode) = scalar_decode_expr(s, src) { + writeln!(out, "{prefix}guard let {varname} = {swift_decode} else {{ return nil }}")?; + } else { + // Custom type + let t = SwiftScalar(s).to_string(); + writeln!(out, "{prefix}guard let {varname} = {t}.decodeFromMsgPack({src}) else {{ return nil }}")?; + } + } + Type::Option(s) => { + let t = SwiftScalar(s).to_string(); + writeln!(out, "{prefix}let {varname}: {t}?")?; + writeln!(out, "{prefix}if case .nil = {src} {{")?; + writeln!(out, "{prefix}{I}{varname} = nil")?; + writeln!(out, "{prefix}}} else {{")?; + // decode inner + if let Some(inner_dec) = scalar_decode_expr(s, src) { + writeln!(out, "{prefix}{I}guard let tmp_{varname} = {inner_dec} else {{ return nil }}")?; + writeln!(out, "{prefix}{I}{varname} = tmp_{varname}")?; + } else { + writeln!(out, "{prefix}{I}guard let tmp_{varname} = {t}.decodeFromMsgPack({src}) else {{ return nil }}")?; + writeln!(out, "{prefix}{I}{varname} = tmp_{varname}")?; + } + writeln!(out, "{prefix}}}")?; + } + Type::Vec(s) => { + let elem_type = SwiftScalar(s).to_string(); + writeln!(out, "{prefix}guard case .array(let arr_{varname}) = {src} else {{ return nil }}")?; + writeln!(out, "{prefix}var {varname}: [{elem_type}] = []")?; + writeln!(out, "{prefix}for elem_{varname} in arr_{varname} {{")?; + if let Some(inner_dec) = scalar_decode_expr(s, &format!("elem_{varname}")) { + writeln!(out, "{prefix}{I}guard let x_{varname} = {inner_dec} else {{ return nil }}")?; + } else { + writeln!(out, "{prefix}{I}guard let x_{varname} = {elem_type}.decodeFromMsgPack(elem_{varname}) else {{ return nil }}")?; + } + writeln!(out, "{prefix}{I}{varname}.append(x_{varname})")?; + writeln!(out, "{prefix}}}")?; + } + Type::Map(k, v) => { + let k_swift = SwiftScalar(k).to_string(); + let v_swift = SwiftScalar(v).to_string(); + writeln!(out, "{prefix}guard case .map(let map_{varname}) = {src} else {{ return nil }}")?; + writeln!(out, "{prefix}var {varname}: [{k_swift}: {v_swift}] = [:]")?; + writeln!(out, "{prefix}for (mk, mv) in map_{varname} {{")?; + // key decode + if let Some(kd) = scalar_decode_expr(k, "mk") { + writeln!(out, "{prefix}{I}guard let dk = {kd} else {{ return nil }}")?; + } else { + writeln!(out, "{prefix}{I}guard let dk = {k_swift}.decodeFromMsgPack(mk) else {{ return nil }}")?; + } + if let Some(vd) = scalar_decode_expr(v, "mv") { + writeln!(out, "{prefix}{I}guard let dv = {vd} else {{ return nil }}")?; + } else { + writeln!(out, "{prefix}{I}guard let dv = {v_swift}.decodeFromMsgPack(mv) else {{ return nil }}")?; + } + writeln!(out, "{prefix}{I}{varname}[dk] = dv")?; + writeln!(out, "{prefix}}}")?; + } + Type::Bytes => { + writeln!(out, "{prefix}guard case .binary(let bytes_{varname}) = {src} else {{ return nil }}")?; + writeln!(out, "{prefix}let {varname} = Data(bytes_{varname})")?; + } + Type::Result(..) => { + writeln!(out, "{prefix}// Result not decoded inline")?; + } + } + Ok(()) +} + +// Returns a Swift expression that decodes a scalar from a MessagePackValue, +// or None if this is a custom type that needs `.decodeFromMsgPack`. +fn scalar_decode_expr(s: &str, src: &str) -> Option { + match s { + "u8" => Some(format!("(_msgpackUInt({src}).flatMap {{ UInt8(exactly: $0) }})")), + "u16" => Some(format!("(_msgpackUInt({src}).flatMap {{ UInt16(exactly: $0) }})")), + "u32" => Some(format!("(_msgpackUInt({src}).flatMap {{ UInt32(exactly: $0) }})")), + "u64" | "usize" => Some(format!("_msgpackUInt({src})")), + "i8" => Some(format!("(_msgpackInt({src}).flatMap {{ Int8(exactly: $0) }})")), + "i16" => Some(format!("(_msgpackInt({src}).flatMap {{ Int16(exactly: $0) }})")), + "i32" => Some(format!("(_msgpackInt({src}).flatMap {{ Int32(exactly: $0) }})")), + "i64" | "isize" => Some(format!("_msgpackInt({src})")), + "bool" => Some(format!("{{ if case .bool(let b) = {src} {{ return b }}; return nil }}()")), + "String" | "PathBuf" | "PeerAddr" | "SocketAddr" => { + Some(format!("{{ if case .string(let s) = {src} {{ return s }}; return nil }}()")) + } + "Duration" => Some(format!("(_msgpackInt({src}).map {{ TimeInterval($0) / 1000.0 }})")), + "SystemTime" => Some(format!("(_msgpackInt({src}).map {{ Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) }})")), + _ => None, // custom type + } +} + +fn encode_expr(ty: &Type, expr: &str) -> String { + match ty { + Type::Unit => ".nil".to_string(), + Type::Scalar(s) => encode_scalar_expr(s, expr), + Type::Option(s) => { + let inner = encode_scalar_expr(s, "x"); + format!("{{ if let x = {expr} {{ return {inner} }} else {{ return .nil }} }}()") + } + Type::Vec(s) => { + let inner = encode_scalar_expr(s, "$0"); + format!(".array({expr}.map {{ {inner} }})") + } + Type::Map(k, v) => { + let k_enc = encode_scalar_expr(k, "$0.key"); + let v_enc = encode_scalar_expr(v, "$0.value"); + format!(".map(Dictionary(uniqueKeysWithValues: {expr}.map {{ ({k_enc}, {v_enc}) }}))") + } + Type::Bytes => format!(".binary({expr})"), + Type::Result(..) => ".nil".to_string(), + } +} + +fn encode_scalar_expr(s: &str, expr: &str) -> String { + match s { + "u8" | "u16" | "u32" | "u64" | "usize" => format!(".uint(UInt64({expr}))"), + "i8" | "i16" | "i32" | "i64" | "isize" => format!(".int(Int64({expr}))"), + "bool" => format!(".bool({expr})"), + "String" | "PathBuf" | "PeerAddr" | "SocketAddr" => format!(".string({expr})"), + "Duration" => format!(".int(Int64({expr} * 1000))"), + "SystemTime" => format!(".int(Int64({expr}.timeIntervalSince1970 * 1000))"), + _ => format!("{expr}.encodeToMsgPack()"), + } +} + +fn write_struct(out: &mut dyn Write, name: &str, item: &Struct) -> Result<()> { + write_docs(out, "", &item.docs)?; + writeln!(out, "public struct {} {{", swift_type_name(name))?; + + match &item.fields { + Fields::Named(fields) => { + for (field_name, field) in fields { + writeln!( + out, + "{I}public let {}: {}", + AsLowerCamelCase(field_name), + SwiftType(&field.ty), + )?; + } + } + Fields::Unnamed(field) => { + writeln!(out, "{I}public let value: {}", SwiftType(&field.ty))?; + } + Fields::Unit => {} + } + + if item.secret { + writeln!(out)?; + writeln!( + out, + "{I}public var debugDescription: String {{ \"\\(type(of: self))(******)\" }}" + )?; + } + + writeln!(out)?; + + // Codec for struct + match &item.fields { + Fields::Unnamed(field) => { + let enc = encode_expr(&field.ty, "value"); + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{ {enc} }}")?; + writeln!(out, "{I}internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? {{")?; + write_decode_stmts(out, &field.ty, "decoded", "v", 2)?; + writeln!(out, "{I}{I}return Self(value: decoded)")?; + writeln!(out, "{I}}}")?; + } + Fields::Named(fields) => { + if fields.is_empty() { + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{ .array([]) }}")?; + writeln!(out, "{I}internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? {{")?; + writeln!(out, "{I}{I}return Self()")?; + writeln!(out, "{I}}}")?; + } else { + let enc_exprs: Vec = fields + .iter() + .map(|(n, f)| encode_expr(&f.ty, &format!("{}", AsLowerCamelCase(n)))) + .collect(); + let enc_arr = enc_exprs.join(", "); + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{")?; + writeln!(out, "{I}{I}.array([{enc_arr}])")?; + writeln!(out, "{I}}}")?; + writeln!(out, "{I}internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? {{")?; + writeln!(out, "{I}{I}guard case .array(let arr) = v, arr.count == {} else {{ return nil }}", fields.len())?; + let mut args = Vec::new(); + for (i, (field_name, field)) in fields.iter().enumerate() { + let fn_lcc = format!("{}", AsLowerCamelCase(field_name)); + write_decode_stmts(out, &field.ty, &fn_lcc, &format!("arr[{i}]"), 2)?; + args.push(format!("{fn_lcc}: {fn_lcc}")); + } + writeln!(out, "{I}{I}return Self({})", args.join(", "))?; + writeln!(out, "{I}}}")?; + } + } + Fields::Unit => { + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{ .array([]) }}")?; + writeln!(out, "{I}internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? {{")?; + writeln!(out, "{I}{I}return Self()")?; + writeln!(out, "{I}}}")?; + } + } + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +fn write_error(out: &mut dyn Write, item: &SimpleEnum) -> Result<()> { + writeln!(out, "public class OuisyncError: Error {{")?; + writeln!(out, "{I}public let code: ErrorCode")?; + writeln!(out, "{I}public let message: String?")?; + writeln!(out, "{I}public let sources: [String]")?; + writeln!(out)?; + writeln!( + out, + "{I}public init(_ code: ErrorCode, _ message: String? = nil, sources: [String] = []) {{" + )?; + writeln!(out, "{I}{I}self.code = code")?; + writeln!(out, "{I}{I}self.message = message")?; + writeln!(out, "{I}{I}self.sources = sources")?; + writeln!(out, "{I}}}")?; + + for (variant_name, variant) in &item.variants { + if variant_name == "Ok" || variant_name == "Other" { + continue; + } + + writeln!(out)?; + write_docs(out, I, &variant.docs)?; + writeln!(out, "{I}public class {variant_name}: OuisyncError {{")?; + writeln!( + out, + "{I}{I}public init(_ message: String? = nil, sources: [String] = []) {{" + )?; + writeln!( + out, + "{I}{I}{I}super.init(.{}, message, sources: sources)", + AsLowerCamelCase(variant_name) + )?; + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + } + + writeln!(out)?; + writeln!( + out, + "{I}public static func dispatch(_ code: ErrorCode, _ message: String? = nil, sources: [String] = []) -> OuisyncError {{" + )?; + writeln!(out, "{I}{I}switch code {{")?; + + for (variant_name, _) in &item.variants { + if variant_name == "Ok" || variant_name == "Other" { + continue; + } + writeln!( + out, + "{I}{I}case .{}: return {variant_name}(message, sources: sources)", + AsLowerCamelCase(variant_name) + )?; + } + + writeln!( + out, + "{I}{I}default: return OuisyncError(code, message, sources: sources)" + )?; + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + writeln!(out)?; + writeln!(out, "{I}public var description: String {{")?; + writeln!( + out, + "{I}{I}let all = ([message] + sources.map {{ Optional($0) }}).compactMap {{ $0 }}" + )?; + writeln!( + out, + "{I}{I}return \"\\(type(of: self)): \\(all.joined(separator: \" → \"))\"" + )?; + writeln!(out, "{I}}}")?; + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +/// Write the Request enum with encode but no decode. +fn write_request_enum( + out: &mut dyn Write, + item: &ComplexEnum, + _request_variants: &[(String, RequestVariant)], +) -> Result<()> { + write_docs(out, "", &item.docs)?; + + writeln!(out, "internal enum Request {{")?; + + for (variant_name, variant) in &item.variants { + write_docs(out, I, &variant.docs)?; + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + + match &variant.fields { + Fields::Named(fields) => { + write!(out, "{I}case {case_name}(")?; + let mut first = true; + for (field_name, field) in fields { + if !first { + write!(out, ", ")?; + } + write!( + out, + "_ {}: {}", + AsLowerCamelCase(field_name), + SwiftType(&field.ty) + )?; + first = false; + } + writeln!(out, ")")?; + } + Fields::Unnamed(field) => { + writeln!( + out, + "{I}case {case_name}(_ value: {})", + SwiftType(&field.ty) + )?; + } + Fields::Unit => { + writeln!(out, "{I}case {case_name}")?; + } + } + } + + writeln!(out)?; + // Encode only for Request (no decode needed) + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{")?; + writeln!(out, "{I}{I}switch self {{")?; + // The request_variants have snake_case names; item.variants use PascalCase + // We need to use the original snake_case names for AsUpperCamelCase + // item.variants variant_name is PascalCase (from to_enum() which calls to_pascal_case()) + // But we need original snake_case for msgpack key. + // Actually, AsUpperCamelCase on a PascalCase string gives the same PascalCase. + for (variant_name, variant) in &item.variants { + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + // variant_name is PascalCase from to_enum(); AsUpperCamelCase on PascalCase = PascalCase + let msgpack_name = variant_name.clone(); // already PascalCase + match &variant.fields { + Fields::Unit => { + writeln!(out, "{I}{I}case .{case_name}: return .string(\"{msgpack_name}\")")?; + } + Fields::Unnamed(field) => { + let enc = encode_expr(&field.ty, "value"); + writeln!(out, "{I}{I}case .{case_name}(let value): return .map([.string(\"{msgpack_name}\"): {enc}])")?; + } + Fields::Named(fields) => { + let pattern: Vec = fields + .iter() + .map(|(n, _)| format!("let {}", AsLowerCamelCase(n))) + .collect(); + let pattern_str = pattern.join(", "); + let enc_exprs: Vec = fields + .iter() + .map(|(n, f)| encode_expr(&f.ty, &format!("{}", AsLowerCamelCase(n)))) + .collect(); + let enc_arr = enc_exprs.join(", "); + writeln!(out, "{I}{I}case .{case_name}({pattern_str}): return .map([.string(\"{msgpack_name}\"): .array([{enc_arr}])])")?; + } + } + } + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +/// Write the Response enum with encode and decode. +fn write_response_enum(out: &mut dyn Write, item: &ComplexEnum) -> Result<()> { + writeln!(out, "internal enum Response {{")?; + + for (variant_name, variant) in &item.variants { + write_docs(out, I, &variant.docs)?; + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + + match &variant.fields { + Fields::Named(fields) => { + write!(out, "{I}case {case_name}(")?; + let mut first = true; + for (field_name, field) in fields { + if !first { + write!(out, ", ")?; + } + write!( + out, + "{}: {}", + AsLowerCamelCase(field_name), + SwiftType(&field.ty) + )?; + first = false; + } + writeln!(out, ")")?; + } + Fields::Unnamed(field) => { + writeln!( + out, + "{I}case {case_name}(_ value: {})", + SwiftType(&field.ty) + )?; + } + Fields::Unit => { + writeln!(out, "{I}case {case_name}")?; + } + } + } + + writeln!(out)?; + + // Encode + writeln!(out, "{I}internal func encodeToMsgPack() -> MessagePackValue {{")?; + writeln!(out, "{I}{I}switch self {{")?; + for (variant_name, variant) in &item.variants { + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + let msgpack_name = variant_name.clone(); // PascalCase + match &variant.fields { + Fields::Unit => { + writeln!(out, "{I}{I}case .{case_name}: return .string(\"{msgpack_name}\")")?; + } + Fields::Unnamed(field) => { + let enc = encode_expr(&field.ty, "value"); + writeln!(out, "{I}{I}case .{case_name}(let value): return .map([.string(\"{msgpack_name}\"): {enc}])")?; + } + Fields::Named(fields) => { + let pattern: Vec = fields + .iter() + .map(|(n, _)| format!("let {}", AsLowerCamelCase(n))) + .collect(); + let pattern_str = pattern.join(", "); + let enc_exprs: Vec = fields + .iter() + .map(|(n, f)| encode_expr(&f.ty, &format!("{}", AsLowerCamelCase(n)))) + .collect(); + let enc_arr = enc_exprs.join(", "); + writeln!(out, "{I}{I}case .{case_name}({pattern_str}): return .map([.string(\"{msgpack_name}\"): .array([{enc_arr}])])")?; + } + } + } + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + writeln!(out)?; + + // Decode + writeln!(out, "{I}internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Response? {{")?; + writeln!(out, "{I}{I}switch v {{")?; + + // Unit variants + let has_unit = item.variants.iter().any(|(_, v)| matches!(v.fields, Fields::Unit)); + if has_unit { + writeln!(out, "{I}{I}case .string(let name):")?; + writeln!(out, "{I}{I}{I}switch name {{")?; + for (variant_name, variant) in &item.variants { + if matches!(variant.fields, Fields::Unit) { + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + let msgpack_name = variant_name.clone(); + writeln!(out, "{I}{I}{I}case \"{msgpack_name}\": return .{case_name}")?; + } + } + writeln!(out, "{I}{I}{I}default: return nil")?; + writeln!(out, "{I}{I}{I}}}")?; + } + + // Non-unit variants + let has_non_unit = item.variants.iter().any(|(_, v)| !matches!(v.fields, Fields::Unit)); + if has_non_unit { + writeln!(out, "{I}{I}case .map(let m) where m.count == 1:")?; + writeln!(out, "{I}{I}{I}guard let entry = m.first, case .string(let name) = entry.key else {{ return nil }}")?; + writeln!(out, "{I}{I}{I}switch name {{")?; + for (variant_name, variant) in &item.variants { + let case_name = format!("{}", AsLowerCamelCase(variant_name)); + let msgpack_name = variant_name.clone(); + match &variant.fields { + Fields::Unit => {} + Fields::Unnamed(field) => { + writeln!(out, "{I}{I}{I}case \"{msgpack_name}\":")?; + write_decode_stmts(out, &field.ty, "decoded", "entry.value", 4)?; + writeln!(out, "{I}{I}{I}{I}return .{case_name}(decoded)")?; + } + Fields::Named(fields) => { + writeln!(out, "{I}{I}{I}case \"{msgpack_name}\":")?; + writeln!(out, "{I}{I}{I}{I}guard case .array(let arr) = entry.value, arr.count == {} else {{ return nil }}", fields.len())?; + let mut args = Vec::new(); + for (i, (field_name, field)) in fields.iter().enumerate() { + let fn_lcc = format!("{}", AsLowerCamelCase(field_name)); + write_decode_stmts(out, &field.ty, &fn_lcc, &format!("arr[{i}]"), 4)?; + args.push(format!("{fn_lcc}: {fn_lcc}")); + } + writeln!(out, "{I}{I}{I}{I}return .{case_name}({})", args.join(", "))?; + } + } + } + writeln!(out, "{I}{I}{I}default: return nil")?; + writeln!(out, "{I}{I}{I}}}")?; + } + + writeln!(out, "{I}{I}default: return nil")?; + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +fn write_api_class( + out: &mut dyn Write, + name: &str, + handle: bool, + request_variants: &[(String, RequestVariant)], +) -> Result<()> { + writeln!(out, "public class {name} {{")?; + writeln!(out, "{I}internal let client: Client")?; + + if handle { + let handle_type_owned = format!("{name}Handle"); + let handle_type = swift_type_name(&handle_type_owned); + writeln!(out, "{I}public let handle: {handle_type}")?; + writeln!(out)?; + writeln!( + out, + "{I}internal init(_ client: Client, _ handle: {handle_type}) {{" + )?; + writeln!(out, "{I}{I}self.client = client")?; + writeln!(out, "{I}{I}self.handle = handle")?; + writeln!(out, "{I}}}")?; + } else { + writeln!(out)?; + writeln!(out, "{I}internal init(_ client: Client) {{")?; + writeln!(out, "{I}{I}self.client = client")?; + writeln!(out, "{I}}}")?; + } + + let prefix = format!("{}_", AsSnakeCase(name)); + + for (variant_name, variant) in request_variants { + if variant.skip { + continue; + } + + let Some(op_name) = variant_name.strip_prefix(&prefix) else { + continue; + }; + + if let Some(stream_item) = &variant.ret_stream_item { + writeln!(out)?; + write_docs(out, I, &variant.docs)?; + write!(out, "{I}public func {}(", AsLowerCamelCase(op_name))?; + + let mut first = true; + for (index, (arg_name, field)) in variant.fields.iter().enumerate() { + if index == 0 && handle { + continue; + } + if !first { + write!(out, ", ")?; + } + let param_name = AsLowerCamelCase(arg_name.unwrap_or(DEFAULT_FIELD_NAME)); + write!(out, "_ {param_name}: {}", SwiftType(&field.ty))?; + first = false; + } + + writeln!(out, ") async throws -> AsyncStream<{}> {{", SwiftType(stream_item))?; + + let request_case = format!("{}", AsLowerCamelCase(variant_name)); + if variant.fields.is_empty() { + writeln!(out, "{I}{I}let request = Request.{request_case}")?; + } else { + writeln!(out, "{I}{I}let request = Request.{request_case}(")?; + for (index, (arg_name, _)) in variant.fields.iter().enumerate() { + let arg = AsLowerCamelCase(arg_name.unwrap_or(DEFAULT_FIELD_NAME)); + if index == 0 && handle { + writeln!(out, "{I}{I}{I}handle,")?; + } else { + writeln!(out, "{I}{I}{I}{arg},")?; + } + } + writeln!(out, "{I}{I})")?; + } + + writeln!(out, "{I}{I}let stream = try await client.subscribe(request)")?; + writeln!(out, "{I}{I}return AsyncStream {{ continuation in")?; + writeln!(out, "{I}{I}{I}let task = Task {{")?; + writeln!(out, "{I}{I}{I}{I}for await response in stream {{")?; + writeln!(out, "{I}{I}{I}{I}{I}switch response {{")?; + + match stream_item { + Type::Unit => { + writeln!(out, "{I}{I}{I}{I}{I}case .unit: continuation.yield(())")?; + } + _ => { + let rv = stream_item.to_response_variant_name(); + let rc = format!("{}", AsLowerCamelCase(&rv)); + writeln!( + out, + "{I}{I}{I}{I}{I}case .{rc}(let value): continuation.yield(value)" + )?; + } + } + + writeln!(out, "{I}{I}{I}{I}{I}default: break")?; + writeln!(out, "{I}{I}{I}{I}{I}}}")?; + writeln!(out, "{I}{I}{I}{I}}}")?; + writeln!(out, "{I}{I}{I}{I}continuation.finish()")?; + writeln!(out, "{I}{I}{I}}}")?; + writeln!(out, "{I}{I}{I}continuation.onTermination = {{ _ in task.cancel() }}")?; + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + continue; + } + + let ret = match &variant.ret { + Type::Result(ty, _) => ty.as_ref(), + ty => ty, + }; + let ret_stripped = ret.strip_suffix("Handle"); + let response_variant_name = ret.to_response_variant_name(); + let response_case = format!("{}", AsLowerCamelCase(&response_variant_name)); + + let use_default_args = variant + .fields + .iter() + .skip(if handle { 1 } else { 0 }) + .any(|(_, field)| SwiftType(&field.ty).default().is_none()); + + writeln!(out)?; + write_docs(out, I, &variant.docs)?; + write!(out, "{I}public func {}(", AsLowerCamelCase(op_name))?; + + let mut first = true; + for (index, (arg_name, field)) in variant.fields.iter().enumerate() { + if index == 0 && handle { + continue; + } + if !first { + write!(out, ", ")?; + } + let param_name = AsLowerCamelCase(arg_name.unwrap_or(DEFAULT_FIELD_NAME)); + let ty = SwiftType(&field.ty); + write!(out, "_ {param_name}: {ty}")?; + if use_default_args { + if let Some(default) = ty.default() { + write!(out, " = {default}")?; + } + } + first = false; + } + + write!(out, ") async throws")?; + + // Return type annotation + match ret { + Type::Unit => writeln!(out, " {{")?, + _ => { + // ret_stripped replaces Handle-suffixed types with their wrapper class. + // SwiftType already renders Option as "T?", so no extra "?" needed. + let display = ret_stripped.as_ref().unwrap_or(ret); + writeln!(out, " -> {} {{", SwiftType(display))?; + } + } + + // Build request + let request_case = format!("{}", AsLowerCamelCase(variant_name)); + + if variant.fields.is_empty() { + writeln!(out, "{I}{I}let request = Request.{request_case}")?; + } else { + writeln!(out, "{I}{I}let request = Request.{request_case}(")?; + for (index, (arg_name, _)) in variant.fields.iter().enumerate() { + let arg = AsLowerCamelCase(arg_name.unwrap_or(DEFAULT_FIELD_NAME)); + if index == 0 && handle { + writeln!(out, "{I}{I}{I}handle,")?; + } else { + writeln!(out, "{I}{I}{I}{arg},")?; + } + } + writeln!(out, "{I}{I})")?; + } + + writeln!(out, "{I}{I}let response = try await client.invoke(request)")?; + writeln!(out, "{I}{I}switch response {{")?; + + match ret { + Type::Unit => { + // void return: server sends Response.unit + writeln!(out, "{I}{I}case .unit:")?; + writeln!(out, "{I}{I}{I}return")?; + } + Type::Option(_) => { + // optional return: server sends .{responseCase}(value) or .none + write!(out, "{I}{I}case .{response_case}(let value):")?; + match &ret_stripped { + Some(Type::Option(w)) => writeln!(out, " return {w}(client, value)")?, + Some(_) => unreachable!(), + None => writeln!(out, " return value")?, + } + writeln!(out, "{I}{I}case .none:")?; + writeln!(out, "{I}{I}{I}return nil")?; + } + _ => { + write!(out, "{I}{I}case .{response_case}(let value):")?; + match &ret_stripped { + Some(Type::Scalar(w)) => writeln!(out, " return {w}(client, value)")?, + Some(Type::Vec(w)) => { + writeln!(out, " return value.map {{ {w}(client, $0) }}")? + } + Some(Type::Map(_, w)) => { + writeln!(out, " return value.mapValues {{ {w}(client, $0) }}")? + } + Some(_) => unreachable!(), + None => writeln!(out, " return value")?, + } + } + } + + writeln!(out, "{I}{I}default:")?; + writeln!(out, "{I}{I}{I}throw UnexpectedResponse()")?; + writeln!(out, "{I}{I}}}")?; + writeln!(out, "{I}}}")?; + } + + if handle { + writeln!(out)?; + writeln!( + out, + "{I}public static func == (lhs: {name}, rhs: {name}) -> Bool {{" + )?; + writeln!(out, "{I}{I}return lhs.handle.value == rhs.handle.value")?; + writeln!(out, "{I}}}")?; + writeln!(out)?; + writeln!( + out, + "{I}public var description: String {{ \"\\(type(of: self))(\\(handle))\" }}" + )?; + } + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +/// Renames Rust type names that conflict with Swift built-ins or Foundation types. +fn swift_type_name(name: &str) -> &str { + match name { + // Foundation already defines FileHandle for file descriptors. + "FileHandle" => "OuisyncFileHandle", + _ => name, + } +} + +fn write_docs(out: &mut dyn Write, prefix: &str, docs: &Docs) -> io::Result<()> { + for line in &docs.lines { + writeln!(out, "{prefix}///{line}")?; + } + Ok(()) +} + +struct SwiftType<'a>(&'a Type); + +impl SwiftType<'_> { + fn default(&self) -> Option<&str> { + match self.0 { + Type::Option(_) => Some("nil"), + Type::Scalar(s) if s == "bool" => Some("false"), + _ => None, + } + } +} + +impl fmt::Display for SwiftType<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Type::Unit => write!(f, "Void"), + Type::Scalar(s) => write!(f, "{}", SwiftScalar(s)), + Type::Option(s) => write!(f, "{}?", SwiftScalar(s)), + Type::Vec(s) => write!(f, "[{}]", SwiftScalar(s)), + Type::Map(k, v) => write!(f, "[{}: {}]", SwiftScalar(k), SwiftScalar(v)), + Type::Bytes => write!(f, "Data"), + Type::Result(..) => Err(fmt::Error), + } + } +} + +struct SwiftScalar<'a>(&'a str); + +impl fmt::Display for SwiftScalar<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + "u8" => write!(f, "UInt8"), + "u16" => write!(f, "UInt16"), + "u32" => write!(f, "UInt32"), + "u64" | "usize" => write!(f, "UInt64"), + "i8" => write!(f, "Int8"), + "i16" => write!(f, "Int16"), + "i32" => write!(f, "Int32"), + "i64" | "isize" => write!(f, "Int64"), + "bool" => write!(f, "Bool"), + "PathBuf" | "PeerAddr" | "SocketAddr" | "String" => write!(f, "String"), + "Duration" => write!(f, "TimeInterval"), + "SystemTime" => write!(f, "Date"), + "StateMonitor" => write!(f, "StateMonitorNode"), + _ => write!(f, "{}", swift_type_name(self.0)), + } + } +} + +const I: &str = " "; +const DEFAULT_FIELD_NAME: &str = "value"; diff --git a/utils/generate-swift-api.sh b/utils/generate-swift-api.sh new file mode 100755 index 000000000..848492d03 --- /dev/null +++ b/utils/generate-swift-api.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +cargo run --package ouisync-bindgen -- swift 2>/dev/null \ + > bindings/swift/OuisyncLib/Sources/generated/Api.swift