From ad7e730e0ff6cbabfd0d80003c41789dfe796b83 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 12:30:15 -0700 Subject: [PATCH 01/23] Add Swift bindings codegen - utils/bindgen: add swift.rs backend and Language::Swift subcommand - bindings/swift: commit generated Api.swift (2513 lines, 121 methods) - utils: add generate-swift-api.sh convenience script Run utils/generate-swift-api.sh to regenerate Api.swift after API changes. --- .../OuisyncLib/Sources/generated/Api.swift | 2513 +++++++++++++++++ utils/bindgen/src/main.rs | 3 + utils/bindgen/src/swift.rs | 461 +++ utils/generate-swift-api.sh | 5 + 4 files changed, 2982 insertions(+) create mode 100644 bindings/swift/OuisyncLib/Sources/generated/Api.swift create mode 100644 utils/bindgen/src/swift.rs create mode 100755 utils/generate-swift-api.sh diff --git a/bindings/swift/OuisyncLib/Sources/generated/Api.swift b/bindings/swift/OuisyncLib/Sources/generated/Api.swift new file mode 100644 index 000000000..e9a7bf44c --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/generated/Api.swift @@ -0,0 +1,2513 @@ +// Auto-generated by ouisync-bindgen. Do not edit. + +import Foundation + +/// 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))(******)" } +} + +/// 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))(******)" } +} + +public struct PasswordSalt { + public let value: Data +} + +/// Strongly typed storage size. +public struct StorageSize { + public let bytes: UInt64 +} + +/// 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 +} + +/// 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) +} + +/// 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) +} + +/// 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 +} + +/// 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 +} + +/// Type of filesystem entry. +public enum EntryType: UInt8 { + case file = 1 + case directory = 2 +} + +/// 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 +} + +/// Information about a peer. +public struct PeerInfo { + public let addr: String + public let source: PeerSource + public let state: PeerState + public let 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 +} + +public enum PeerState { + case known + case connecting + case handshaking + case active(id: PublicRuntimeId, since: Date) +} + +public struct PublicRuntimeId { + public let value: Data +} + +/// Network traffic statistics. +public struct Stats { + public let bytesTx: UInt64 + public let bytesRx: UInt64 + public let throughputTx: UInt64 + public let throughputRx: UInt64 +} + +/// Progress of a task. +public struct Progress { + public let value: UInt64 + public let total: UInt64 +} + +/// 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 +} + +public enum NatBehavior: UInt8 { + case endpointIndependent = 0 + case addressDependent = 1 + case addressAndPortDependent = 2 +} + +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 +} + +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 +} + +public struct MessageId { + public let value: UInt64 +} + +/// Edit of a single metadata entry. +public struct MetadataEdit { + public let key: String + public let oldValue: String? + public let newValue: String? +} + +/// Default network parameters +public struct NetworkDefaults { + public let bind: [String] + public let portForwardingEnabled: Bool + public let localDiscoveryEnabled: Bool +} + +public struct DirectoryEntry { + public let name: String + public let entryType: EntryType +} + +public struct QuotaInfo { + public let quota: StorageSize? + public let size: StorageSize +} + +public struct Datagram { + public let data: Data + public let addr: String +} + +public struct FileHandle { + public let value: UInt64 +} + +public struct RepositoryHandle { + public let value: UInt64 +} + +public struct NetworkSocketHandle { + public let value: UInt64 +} + +public struct NetworkStreamHandle { + public let value: UInt64 +} + +internal enum Request { + case cancel(id: MessageId) + case fileClose(file: FileHandle) + case fileFlush(file: FileHandle) + case fileGetLength(file: FileHandle) + case fileGetProgress(file: FileHandle) + case fileRead(file: FileHandle, offset: UInt64, size: UInt64) + case fileTruncate(file: FileHandle, len: UInt64) + case fileWrite(file: FileHandle, 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 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: FileHandle) + 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 +} + +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() + } + } + + 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() + } + } + + /// 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 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 == rhs.handle + } + + public var description: String { "\(type(of: self))(\(handle))" } +} + +public class File { + internal let client: Client + public let handle: FileHandle + + internal init(_ client: Client, _ handle: FileHandle) { + 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 == rhs.handle + } + + 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 == rhs.handle + } + + 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 == rhs.handle + } + + public var description: String { "\(type(of: self))(\(handle))" } +} + +public class UnexpectedResponse: Error {} + 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..44f7ab11c --- /dev/null +++ b/utils/bindgen/src/swift.rs @@ -0,0 +1,461 @@ +use anyhow::Result; +use heck::{AsLowerCamelCase, AsSnakeCase}; +use ouisync_api_parser::{ + ComplexEnum, Context, Docs, EnumRepr, 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)?; + + 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_complex_enum(out, "Request", &ctx.request.to_enum())?; + write_complex_enum(out, "Response", &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)?; + + Ok(()) +} + +fn write_simple_enum(out: &mut dyn Write, name: &str, item: &SimpleEnum) -> Result<()> { + let repr = match item.repr { + EnumRepr::U8 => "UInt8", + EnumRepr::U16 => "UInt16", + EnumRepr::U32 => "UInt32", + EnumRepr::U64 => "UInt64", + }; + + 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 + )?; + } + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +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, "}}")?; + writeln!(out)?; + + Ok(()) +} + +fn write_struct(out: &mut dyn Write, name: &str, item: &Struct) -> Result<()> { + write_docs(out, "", &item.docs)?; + writeln!(out, "public struct {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, "}}")?; + 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(()) +} + +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 { + writeln!(out, "{I}public let handle: {name}Handle")?; + writeln!(out)?; + writeln!( + out, + "{I}internal init(_ client: Client, _ handle: {name}Handle) {{" + )?; + 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 || variant.ret_stream_item.is_some() { + continue; + } + + let Some(op_name) = variant_name.strip_prefix(&prefix) else { + 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 == rhs.handle")?; + writeln!(out, "{I}}}")?; + writeln!(out)?; + writeln!( + out, + "{I}public var description: String {{ \"\\(type(of: self))(\\(handle))\" }}" + )?; + } + + writeln!(out, "}}")?; + writeln!(out)?; + + Ok(()) +} + +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, "{}", 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..9bf4b6d8a --- /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 \ + > bindings/swift/OuisyncLib/Sources/generated/Api.swift From f7345a0beffe815d31235a0ffaedf1580eaea0c5 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 12:36:25 -0700 Subject: [PATCH 02/23] swift codegen: rename FileHandle -> OuisyncFileHandle to avoid Foundation conflict --- .../OuisyncLib/Sources/generated/Api.swift | 22 +++++++++---------- utils/bindgen/src/swift.rs | 19 ++++++++++++---- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/bindings/swift/OuisyncLib/Sources/generated/Api.swift b/bindings/swift/OuisyncLib/Sources/generated/Api.swift index e9a7bf44c..748164843 100644 --- a/bindings/swift/OuisyncLib/Sources/generated/Api.swift +++ b/bindings/swift/OuisyncLib/Sources/generated/Api.swift @@ -512,7 +512,7 @@ public struct Datagram { public let addr: String } -public struct FileHandle { +public struct OuisyncFileHandle { public let value: UInt64 } @@ -530,13 +530,13 @@ public struct NetworkStreamHandle { internal enum Request { case cancel(id: MessageId) - case fileClose(file: FileHandle) - case fileFlush(file: FileHandle) - case fileGetLength(file: FileHandle) - case fileGetProgress(file: FileHandle) - case fileRead(file: FileHandle, offset: UInt64, size: UInt64) - case fileTruncate(file: FileHandle, len: UInt64) - case fileWrite(file: FileHandle, offset: UInt64, data: Data) + 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) @@ -665,7 +665,7 @@ internal enum Response { case directoryEntries(_ value: [DirectoryEntry]) case duration(_ value: TimeInterval) case entryType(_ value: EntryType) - case file(_ value: FileHandle) + case file(_ value: OuisyncFileHandle) case natBehavior(_ value: NatBehavior) case networkEvent(_ value: NetworkEvent) case networkSocket(_ value: NetworkSocketHandle) @@ -2275,9 +2275,9 @@ public class Repository { public class File { internal let client: Client - public let handle: FileHandle + public let handle: OuisyncFileHandle - internal init(_ client: Client, _ handle: FileHandle) { + internal init(_ client: Client, _ handle: OuisyncFileHandle) { self.client = client self.handle = handle } diff --git a/utils/bindgen/src/swift.rs b/utils/bindgen/src/swift.rs index 44f7ab11c..00510d744 100644 --- a/utils/bindgen/src/swift.rs +++ b/utils/bindgen/src/swift.rs @@ -123,7 +123,7 @@ fn write_complex_enum(out: &mut dyn Write, name: &str, item: &ComplexEnum) -> Re fn write_struct(out: &mut dyn Write, name: &str, item: &Struct) -> Result<()> { write_docs(out, "", &item.docs)?; - writeln!(out, "public struct {name} {{")?; + writeln!(out, "public struct {} {{", swift_type_name(name))?; match &item.fields { Fields::Named(fields) => { @@ -243,11 +243,13 @@ fn write_api_class( writeln!(out, "{I}internal let client: Client")?; if handle { - writeln!(out, "{I}public let handle: {name}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: {name}Handle) {{" + "{I}internal init(_ client: Client, _ handle: {handle_type}) {{" )?; writeln!(out, "{I}{I}self.client = client")?; writeln!(out, "{I}{I}self.handle = handle")?; @@ -401,6 +403,15 @@ fn write_api_class( 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}")?; @@ -452,7 +463,7 @@ impl fmt::Display for SwiftScalar<'_> { "Duration" => write!(f, "TimeInterval"), "SystemTime" => write!(f, "Date"), "StateMonitor" => write!(f, "StateMonitorNode"), - _ => write!(f, "{}", self.0), + _ => write!(f, "{}", swift_type_name(self.0)), } } } From b3fac2f529d7da403526467b8f73c0467fbeb32a Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 12:47:41 -0700 Subject: [PATCH 03/23] bindings/swift: split Package.swift into OuisyncLibCore and OuisyncLib targets OuisyncLibCore (Sources/) contains only the generated Api.swift and has no dependency on the xcframework. OuisyncLib (SourcesFFI/) wraps the FFI layer and depends on OuisyncLibCore. Tests now build against OuisyncLibCore. --- .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncClient.swift | 0 .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncEntry.swift | 0 .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncError.swift | 0 .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncFFI.swift | 0 .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncFile.swift | 0 .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncLib.swift | 0 .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncMessage.swift | 0 .../OuisyncLib/{Sources => SourcesFFI}/OuisyncRepository.swift | 0 .../swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncSession.swift | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncClient.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncEntry.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncError.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncFFI.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncFile.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncLib.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncMessage.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncRepository.swift (100%) rename bindings/swift/OuisyncLib/{Sources => SourcesFFI}/OuisyncSession.swift (100%) diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncClient.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncClient.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncClient.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncEntry.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncEntry.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncError.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncError.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncError.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncError.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFile.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncFile.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncFile.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncLib.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncLib.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncLib.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncMessage.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncMessage.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncRepository.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncRepository.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncSession.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/OuisyncSession.swift rename to bindings/swift/OuisyncLib/SourcesFFI/OuisyncSession.swift From 627d188cf823aabb47f026b29f802c6dda2bd8e9 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 12:47:46 -0700 Subject: [PATCH 04/23] bindings/swift: update Package.swift and test import for split targets --- bindings/swift/OuisyncLib/Package.swift | 14 ++++++++++---- .../swift/OuisyncLib/Tests/OuisyncLibTests.swift | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/bindings/swift/OuisyncLib/Package.swift b/bindings/swift/OuisyncLib/Package.swift index 592ff50cf..e2f8af496 100644 --- a/bindings/swift/OuisyncLib/Package.swift +++ b/bindings/swift/OuisyncLib/Package.swift @@ -9,19 +9,25 @@ 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"), .testTarget(name: "OuisyncLibTests", - dependencies: ["OuisyncLib"], + dependencies: ["OuisyncLibCore"], path: "Tests"), // FIXME: move this to a separate package / framework .binaryTarget(name: "OuisyncLibFFI", diff --git a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift index c6b9d946a..604ca86e2 100644 --- a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift +++ b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import OuisyncLib +@testable import OuisyncLibCore final class OuisyncLibTests: XCTestCase { func testExample() throws { From 2ba3904d46161a365e57cb9df81428cf37526c16 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 13:51:33 -0700 Subject: [PATCH 05/23] bindings/swift: implement Client, codec, and StateMonitor (step 3b) - swift.rs: generate encodeToMsgPack/decodeFromMsgPack on all API types (simple enums, structs, complex enums, Request, Response); fix array wrapping for single named-field enum variants - Client.swift: TCP actor with NWConnection, HMAC-SHA256 auth, frame encode/decode, CheckedContinuation pending map, cancellation support - StateMonitor.swift: MonitorId (string-encoded) and StateMonitorNode - Session.swift: Session.create(configPath:) convenience initializer - generate-swift-api.sh: suppress cargo build output on stdout --- .../swift/OuisyncLib/Sources/Client.swift | 302 ++++++++ .../swift/OuisyncLib/Sources/Session.swift | 8 + .../OuisyncLib/Sources/StateMonitor.swift | 52 ++ .../OuisyncLib/Sources/generated/Api.swift | 672 ++++++++++++++++++ utils/bindgen/src/swift.rs | 557 ++++++++++++++- utils/generate-swift-api.sh | 2 +- 6 files changed, 1582 insertions(+), 11 deletions(-) create mode 100644 bindings/swift/OuisyncLib/Sources/Client.swift create mode 100644 bindings/swift/OuisyncLib/Sources/Session.swift create mode 100644 bindings/swift/OuisyncLib/Sources/StateMonitor.swift diff --git a/bindings/swift/OuisyncLib/Sources/Client.swift b/bindings/swift/OuisyncLib/Sources/Client.swift new file mode 100644 index 000000000..6c745a299 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Client.swift @@ -0,0 +1,302 @@ +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 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 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(id: 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) + + // Resume continuation + 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) + } + } + } catch { + // Connection closed or error: fail all pending + let all = pending + pending.removeAll() + for cont in all.values { + cont.resume(throwing: error) + } + return + } + } + } + + // 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 + withUnsafeBytes(of: &v) { append(contentsOf: $0) } + } + + mutating func appendBigEndian(_ value: UInt64) { + var v = value.bigEndian + 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/Session.swift b/bindings/swift/OuisyncLib/Sources/Session.swift new file mode 100644 index 000000000..fe06c2d7b --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Session.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Session { + public static func create(configPath: String) async throws -> Session { + let client = try await Client.connect(configPath: configPath) + return Session(client) + } +} 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 index 748164843..cb1aa7b7e 100644 --- a/bindings/swift/OuisyncLib/Sources/generated/Api.swift +++ b/bindings/swift/OuisyncLib/Sources/generated/Api.swift @@ -1,6 +1,7 @@ // Auto-generated by ouisync-bindgen. Do not edit. import Foundation +import MessagePack /// Symmetric encryption/decryption secret key. /// @@ -15,6 +16,13 @@ public struct SecretKey { public let value: Data public var debugDescription: String { "\(type(of: self))(******)" } + + internal func encodeToMsgPack() -> MessagePackValue { .binary([UInt8](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 @@ -23,15 +31,37 @@ 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([UInt8](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. @@ -42,6 +72,12 @@ public enum AccessMode: UInt8 { 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. @@ -50,6 +86,30 @@ public enum LocalSecret { 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. @@ -59,12 +119,44 @@ public enum SetLocalSecret { /// 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. @@ -73,12 +165,50 @@ public enum AccessChange { 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. @@ -88,6 +218,12 @@ public enum NetworkEvent: UInt8 { 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. @@ -96,6 +232,18 @@ public struct PeerInfo { 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. @@ -110,6 +258,12 @@ public enum PeerSource: UInt8 { 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 { @@ -117,10 +271,49 @@ public enum PeerState { 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([UInt8](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. @@ -129,12 +322,34 @@ public struct Stats { 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. @@ -143,12 +358,25 @@ public struct Progress { /// each other. public struct TopicId { public let value: Data + + internal func encodeToMsgPack() -> MessagePackValue { .binary([UInt8](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 { @@ -214,6 +442,12 @@ public enum ErrorCode: UInt16 { 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 { @@ -477,10 +711,22 @@ public enum LogLevel: UInt8 { 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. @@ -488,6 +734,29 @@ 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 @@ -495,37 +764,114 @@ 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([UInt8](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 { @@ -655,6 +1001,137 @@ internal enum Request { 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([UInt8](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([UInt8](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([UInt8](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([UInt8](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 { @@ -693,6 +1170,194 @@ internal enum Response { 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([UInt8](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 { @@ -2511,3 +3176,10 @@ public class NetworkStream { 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/utils/bindgen/src/swift.rs b/utils/bindgen/src/swift.rs index 00510d744..230e60cf5 100644 --- a/utils/bindgen/src/swift.rs +++ b/utils/bindgen/src/swift.rs @@ -1,7 +1,7 @@ use anyhow::Result; -use heck::{AsLowerCamelCase, AsSnakeCase}; +use heck::{AsLowerCamelCase, AsSnakeCase, AsUpperCamelCase}; use ouisync_api_parser::{ - ComplexEnum, Context, Docs, EnumRepr, Fields, Item, RequestVariant, SimpleEnum, Struct, + ComplexEnum, Context, Docs, Fields, Item, RequestVariant, SimpleEnum, Struct, ToResponseVariantName, Type, Visibility, }; use std::{ @@ -13,6 +13,7 @@ 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 { @@ -28,8 +29,8 @@ pub(crate) fn generate(ctx: &Context, out: &mut dyn Write) -> Result<()> { } } - write_complex_enum(out, "Request", &ctx.request.to_enum())?; - write_complex_enum(out, "Response", &ctx.response.to_enum())?; + 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)?; @@ -40,16 +41,20 @@ pub(crate) fn generate(ctx: &Context, out: &mut dyn Write) -> Result<()> { 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 = match item.repr { - EnumRepr::U8 => "UInt8", - EnumRepr::U16 => "UInt16", - EnumRepr::U32 => "UInt32", - EnumRepr::U64 => "UInt64", - }; + let repr = enum_repr_str(&item.repr); write_docs(out, "", &item.docs)?; writeln!(out, "public enum {name}: {repr} {{")?; @@ -64,12 +69,29 @@ fn write_simple_enum(out: &mut dyn Write, name: &str, item: &SimpleEnum) -> Resu )?; } + // 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)?; @@ -115,12 +137,253 @@ fn write_complex_enum(out: &mut dyn Write, name: &str, item: &ComplexEnum) -> Re } } + 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([UInt8]({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))?; @@ -150,6 +413,53 @@ fn write_struct(out: &mut dyn Write, name: &str, item: &Struct) -> Result<()> { )?; } + 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)?; @@ -233,6 +543,233 @@ fn write_error(out: &mut dyn Write, item: &SimpleEnum) -> Result<()> { 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, diff --git a/utils/generate-swift-api.sh b/utils/generate-swift-api.sh index 9bf4b6d8a..848492d03 100755 --- a/utils/generate-swift-api.sh +++ b/utils/generate-swift-api.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -euo pipefail cd "$(git rev-parse --show-toplevel)" -cargo run --package ouisync-bindgen -- swift \ +cargo run --package ouisync-bindgen -- swift 2>/dev/null \ > bindings/swift/OuisyncLib/Sources/generated/Api.swift From 17019f3564ab9f2914444d9123e87d735193658b Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 13:57:14 -0700 Subject: [PATCH 06/23] bindings/swift: delete legacy hand-written types (step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove OuisyncClient, OuisyncError, OuisyncFile, OuisyncEntry, OuisyncRepository, OuisyncMessage, OuisyncLib, and OuisyncSession — all superseded by generated Api.swift types and the new Client actor. Extract NotificationStream into Sources/ since it has no FFI deps. OuisyncFFI.swift is kept as a stub pending step 5. --- .../Sources/NotificationStream.swift | 34 ++ .../OuisyncLib/SourcesFFI/OuisyncClient.swift | 77 ---- .../OuisyncLib/SourcesFFI/OuisyncEntry.swift | 166 -------- .../OuisyncLib/SourcesFFI/OuisyncError.swift | 84 ---- .../OuisyncLib/SourcesFFI/OuisyncFile.swift | 42 -- .../OuisyncLib/SourcesFFI/OuisyncLib.swift | 11 - .../SourcesFFI/OuisyncMessage.swift | 399 ------------------ .../SourcesFFI/OuisyncRepository.swift | 79 ---- .../SourcesFFI/OuisyncSession.swift | 186 -------- 9 files changed, 34 insertions(+), 1044 deletions(-) create mode 100644 bindings/swift/OuisyncLib/Sources/NotificationStream.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncClient.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncEntry.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncError.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncFile.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncLib.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncMessage.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncRepository.swift delete mode 100644 bindings/swift/OuisyncLib/SourcesFFI/OuisyncSession.swift diff --git a/bindings/swift/OuisyncLib/Sources/NotificationStream.swift b/bindings/swift/OuisyncLib/Sources/NotificationStream.swift new file mode 100644 index 000000000..2ec4dc78f --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/NotificationStream.swift @@ -0,0 +1,34 @@ +import Foundation + +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 { + state.registrations.removeValue(forKey: subscriptionId) + } +} diff --git a/bindings/swift/OuisyncLib/SourcesFFI/OuisyncClient.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncClient.swift deleted file mode 100644 index f7bf5a4e8..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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/SourcesFFI/OuisyncEntry.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncEntry.swift deleted file mode 100644 index 1bbd29827..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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/SourcesFFI/OuisyncError.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncError.swift deleted file mode 100644 index 1061c8182..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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/SourcesFFI/OuisyncFile.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFile.swift deleted file mode 100644 index 995a40705..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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/SourcesFFI/OuisyncLib.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncLib.swift deleted file mode 100644 index abed67ff0..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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/SourcesFFI/OuisyncMessage.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncMessage.swift deleted file mode 100644 index 6aa8f45ef..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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/SourcesFFI/OuisyncRepository.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncRepository.swift deleted file mode 100644 index b2cfd6632..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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/SourcesFFI/OuisyncSession.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncSession.swift deleted file mode 100644 index 9944be5d5..000000000 --- a/bindings/swift/OuisyncLib/SourcesFFI/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) - } -} - From 81d4c783e86fcced5f084ffb5d1bf245a6815938 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 14:11:57 -0700 Subject: [PATCH 07/23] bindings/swift: wire xcframework build to ouisync-service (step 5) - build.sh: build ouisync-service instead of ouisync-ffi; link libouisync_service.a; run cbindgen with service/cbindgen.toml - service/cbindgen.toml: new cbindgen config; enum style="type" so ErrorCode is exported as typedef uint16_t (clean UInt16 in Swift) - OuisyncFFI.swift: replace dead FFI symbols with async OuisyncService wrapper around start_service / stop_service / init_log --- bindings/swift/OuisyncLib/Plugins/build.sh | 8 +- .../OuisyncLib/SourcesFFI/OuisyncFFI.swift | 160 +++++++++--------- service/cbindgen.toml | 11 ++ 3 files changed, 94 insertions(+), 85 deletions(-) create mode 100644 service/cbindgen.toml 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/SourcesFFI/OuisyncFFI.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift index 36eb00ad0..24fd7ff4b 100644 --- a/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift +++ b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift @@ -1,100 +1,98 @@ -// -// File.swift -// -// -// Created by Peter Jankuliak on 19/07/2024. -// - import Foundation 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 -/* 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) - } +// MARK: - OuisyncService - // 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!))"); +public class OuisyncService { + let handle: UnsafeMutableRawPointer - let millisecond: UInt64 = 1_000_000 - let second: UInt64 = 1000 * millisecond + private init(_ handle: UnsafeMutableRawPointer) { + self.handle = handle + } - var timeout = 200 * millisecond + /// Initialize logging. Call before start(). + public static func initLog() { + init_log() + } - if elapsed > 10 * second { - timeout = second + /// 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 + } - try await Task.sleep(nanoseconds: timeout) - elapsed += timeout; + guard let h = rawHandle else { + throw OuisyncError(.other, "start_service returned null") } + return OuisyncService(h) } - // 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()) + /// 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) + } } +} - static func fromUnretainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeUnretainedValue() - } +// MARK: - Continuation boxes - static func toRetainedPtr(obj : T) -> UnsafeRawPointer { - return UnsafeRawPointer(Unmanaged.passRetained(obj).toOpaque()) - } +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.resumeWith(result: serviceResult(code)) +} + +private func serviceStopped(_ ctx: UnsafeRawPointer?, _ code: UInt16) { + guard let ctx else { return } + Unmanaged.fromOpaque(ctx).takeRetainedValue() + .continuation.resumeWith(result: serviceResult(code)) +} + +private func serviceNoop(_: UnsafeRawPointer?, _: UInt16) {} - static func fromRetainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeRetainedValue() +private func serviceResult(_ code: UInt16) -> Result { + if code == 0 { + return .success(()) } + let errorCode = ErrorCode(rawValue: code) ?? .other + return .failure(OuisyncError(errorCode, "service error (code \(code))")) } diff --git a/service/cbindgen.toml b/service/cbindgen.toml new file mode 100644 index 000000000..dd9cdaf27 --- /dev/null +++ b/service/cbindgen.toml @@ -0,0 +1,11 @@ +language = "C" +include_guard = "OUISYNC_SERVICE_H" + +# Export ErrorCode as typedef uint16_t so Swift sees it as UInt16, +# avoiding C enum size ambiguity. +[enum] +style = "type" +rename_variants = "QualifiedScreamingSnakeCase" + +[parse] +parse_deps = false From 1a60f6d61f902177e921f8a4ff792e21c4ec18c4 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 14:18:55 -0700 Subject: [PATCH 08/23] bindings/swift: integration tests (step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session.swift: add public close() that cancels the underlying client - Package.swift: test target now depends on OuisyncLib (needs xcframework) - OuisyncLibTests.swift: integration tests — start OuisyncService, connect Session, exercise repository list/create/delete and file write/read/truncate --- bindings/swift/OuisyncLib/Package.swift | 2 +- .../swift/OuisyncLib/Sources/Session.swift | 4 + .../OuisyncLib/Tests/OuisyncLibTests.swift | 124 +++++++++++++++++- 3 files changed, 123 insertions(+), 7 deletions(-) diff --git a/bindings/swift/OuisyncLib/Package.swift b/bindings/swift/OuisyncLib/Package.swift index e2f8af496..7fa1965f2 100644 --- a/bindings/swift/OuisyncLib/Package.swift +++ b/bindings/swift/OuisyncLib/Package.swift @@ -27,7 +27,7 @@ let package = Package( "OuisyncLibFFI"], path: "SourcesFFI"), .testTarget(name: "OuisyncLibTests", - dependencies: ["OuisyncLibCore"], + dependencies: ["OuisyncLib"], path: "Tests"), // FIXME: move this to a separate package / framework .binaryTarget(name: "OuisyncLibFFI", diff --git a/bindings/swift/OuisyncLib/Sources/Session.swift b/bindings/swift/OuisyncLib/Sources/Session.swift index fe06c2d7b..43502dbf6 100644 --- a/bindings/swift/OuisyncLib/Sources/Session.swift +++ b/bindings/swift/OuisyncLib/Sources/Session.swift @@ -5,4 +5,8 @@ extension 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/Tests/OuisyncLibTests.swift b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift index 604ca86e2..505e134a9 100644 --- a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift +++ b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift @@ -1,12 +1,124 @@ import XCTest -@testable import OuisyncLibCore +import OuisyncLib final class OuisyncLibTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest + var tempDir: URL! + var service: OuisyncService! + var session: Session! - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + override func setUp() async throws { + 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) + + OuisyncService.initLog() + service = try await OuisyncService.start(configDir: configDir) + session = try await Session.create(configPath: configDir) + try await session.setStoreDirs([storeDir]) + } + + override func tearDown() async throws { + await session.close() + try await service.stop() + try? FileManager.default.removeItem(at: tempDir) + } + + // MARK: - Repository tests + + func testListRepositoriesEmpty() async throws { + let repos = try await session.listRepositories() + XCTAssertTrue(repos.isEmpty) + } + + func testCreateRepository() async throws { + let repo = try await session.createRepository("repo") + defer { Task { try? await repo.close() } } + + let repos = try await session.listRepositories() + XCTAssertEqual(repos.count, 1) + } + + func testDeleteRepository() async throws { + let repo = try await session.createRepository("repo") + try await repo.delete() + + let repos = try await session.listRepositories() + XCTAssertTrue(repos.isEmpty) + } + + func testMultipleRepositories() async throws { + 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() + XCTAssertEqual(repos.count, 2) + } + + // MARK: - File tests + + func testFileWriteAndRead() async throws { + let content = "hello ouisync" + let contentData = Data(content.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, contentData) + 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) + + XCTAssertEqual(data, contentData) + } + + func testFileTruncate() async throws { + 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() + XCTAssertEqual(try await file.getLength(), 11) + + try await file.truncate(5) + XCTAssertEqual(try await file.getLength(), 5) + + let data = try await file.read(0, 5) + XCTAssertEqual(String(data: data, encoding: .utf8), "hello") + } + + func testOpenMissingFileThrows() async throws { + let repo = try await session.createRepository("repo") + defer { Task { try? await repo.close() } } + + do { + _ = try await repo.openFile("missing.txt") + XCTFail("Expected error opening missing file") + } catch let e as OuisyncError where e.code == .notFound { + // expected + } + } + + // MARK: - Session tests + + func testMultipleSessions() async throws { + let configDir = tempDir.appendingPathComponent("config").path + 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() + XCTAssertEqual(id1.value, id2.value) } } From 6c306c490c04ebed15b1cb94068e2e39f52cc3f9 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 14:28:16 -0700 Subject: [PATCH 09/23] bindings/swift: rewrite tests using Swift Testing Replace XCTest with the Swift Testing framework (@Suite, @Test, #expect). Use a withFixture() helper instead of setUp/tearDown to handle async service + session lifecycle cleanly without relying on async deinit. --- .../OuisyncLib/Tests/OuisyncLibTests.swift | 192 ++++++++++-------- 1 file changed, 105 insertions(+), 87 deletions(-) diff --git a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift index 505e134a9..b6aa05898 100644 --- a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift +++ b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift @@ -1,124 +1,142 @@ -import XCTest +import Testing import OuisyncLib -final class OuisyncLibTests: XCTestCase { - var tempDir: URL! - var service: OuisyncService! - var session: Session! - - override func setUp() async throws { - 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) - - OuisyncService.initLog() - service = try await OuisyncService.start(configDir: configDir) - session = try await Session.create(configPath: configDir) - try await session.setStoreDirs([storeDir]) +// 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) + + 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 } - override func tearDown() async throws { - await session.close() - try await service.stop() - try? FileManager.default.removeItem(at: tempDir) - } + await session.close() + try? await service.stop() + try? FileManager.default.removeItem(at: tempDir) - // MARK: - Repository tests + if let error = bodyError { throw error } +} - func testListRepositoriesEmpty() async throws { - let repos = try await session.listRepositories() - XCTAssertTrue(repos.isEmpty) - } +@Suite struct OuisyncLibTests { - func testCreateRepository() async throws { - let repo = try await session.createRepository("repo") - defer { Task { try? await repo.close() } } + // MARK: - Repository - let repos = try await session.listRepositories() - XCTAssertEqual(repos.count, 1) + @Test func listRepositoriesEmpty() async throws { + try await withFixture { session, _ in + let repos = try await session.listRepositories() + #expect(repos.isEmpty) + } } - func testDeleteRepository() async throws { - let repo = try await session.createRepository("repo") - try await repo.delete() + @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() - XCTAssertTrue(repos.isEmpty) + let repos = try await session.listRepositories() + #expect(repos.count == 1) + } } - func testMultipleRepositories() async throws { - 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() } } + @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() - XCTAssertEqual(repos.count, 2) + let repos = try await session.listRepositories() + #expect(repos.isEmpty) + } } - // MARK: - File tests + @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() } } - func testFileWriteAndRead() async throws { - let content = "hello ouisync" - let contentData = Data(content.utf8) + let repos = try await session.listRepositories() + #expect(repos.count == 2) + } + } - let repo = try await session.createRepository("repo") - defer { Task { try? await repo.close() } } + // MARK: - File - let fileW = try await repo.createFile("test.txt") - try await fileW.write(0, contentData) - try await fileW.flush() - try await fileW.close() + @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 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) + let fileW = try await repo.createFile("test.txt") + try await fileW.write(0, content) + try await fileW.flush() + try await fileW.close() - XCTAssertEqual(data, contentData) + 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) + } } - func testFileTruncate() async throws { - let repo = try await session.createRepository("repo") - defer { Task { try? await repo.close() } } + @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() } } + 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() - XCTAssertEqual(try await file.getLength(), 11) + try await file.write(0, Data("hello world".utf8)) + try await file.flush() + #expect(try await file.getLength() == 11) - try await file.truncate(5) - XCTAssertEqual(try await file.getLength(), 5) + try await file.truncate(5) + #expect(try await file.getLength() == 5) - let data = try await file.read(0, 5) - XCTAssertEqual(String(data: data, encoding: .utf8), "hello") + let slice = try await file.read(0, 5) + #expect(String(data: slice, encoding: .utf8) == "hello") + } } - func testOpenMissingFileThrows() async throws { - let repo = try await session.createRepository("repo") - defer { Task { try? await repo.close() } } + @Test func openMissingFileThrows() async throws { + try await withFixture { session, _ in + let repo = try await session.createRepository("repo") + defer { Task { try? await repo.close() } } - do { - _ = try await repo.openFile("missing.txt") - XCTFail("Expected error opening missing file") - } catch let e as OuisyncError where e.code == .notFound { - // expected + await #expect(throws: OuisyncError.NotFound.self) { + _ = try await repo.openFile("missing.txt") + } } } - // MARK: - Session tests + // MARK: - Session - func testMultipleSessions() async throws { - let configDir = tempDir.appendingPathComponent("config").path - let other = try await Session.create(configPath: configDir) - defer { Task { await other.close() } } + @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() - XCTAssertEqual(id1.value, id2.value) + let id1 = try await session.getRuntimeId() + let id2 = try await other.getRuntimeId() + #expect(id1.value == id2.value) + } } } From b415ba95b23422ebf5979a24fd796c1caaf39a33 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 15:10:19 -0700 Subject: [PATCH 10/23] bindings/swift: fix compilation errors and integration tests - Fix swift.rs: compare handle.value (UInt64) instead of handle struct (which lacks Equatable conformance) in generated == operators - Fix swift.rs: Request enum named fields use unlabeled call syntax (prefix _ ) so Swift 6 callers don't need argument labels - Fix swift.rs: Type::Bytes encodes as .binary(expr) not .binary([UInt8](expr)) - Regenerate Api.swift with the above fixes - Fix Client.swift: qualify withUnsafeBytes as Swift.withUnsafeBytes to resolve Swift 6 ambiguity with Data's instance method - Fix Client.swift: remove extraneous 'id:' label on .cancel() call site - Fix OuisyncFFI.swift: @_exported import OuisyncLibCore so Session, OuisyncError etc. are visible to OuisyncLib consumers; qualify OuisyncLibCore types to avoid clash with C ErrorCode from OuisyncLibFFI; use resume(with:) instead of resumeWith (correct CheckedContinuation API) - Fix service/cbindgen.toml: remove invalid [enum] section - Fix Package.swift: link SystemConfiguration framework (required by libouisync_service.a via the system_configuration crate) - Fix Tests: add missing 'import Foundation' for FileManager, Data, UUID - Add build-xcframework-macos.sh for native macOS host builds - All 8 integration tests now pass with swift test --- bindings/swift/OuisyncLib/Package.swift | 5 +- .../swift/OuisyncLib/Sources/Client.swift | 6 +- .../OuisyncLib/Sources/generated/Api.swift | 218 +++++++++--------- .../OuisyncLib/SourcesFFI/OuisyncFFI.swift | 11 +- .../OuisyncLib/Tests/OuisyncLibTests.swift | 1 + .../OuisyncLib/build-xcframework-macos.sh | 52 +++++ service/cbindgen.toml | 6 - utils/bindgen/src/swift.rs | 6 +- 8 files changed, 178 insertions(+), 127 deletions(-) create mode 100755 bindings/swift/OuisyncLib/build-xcframework-macos.sh diff --git a/bindings/swift/OuisyncLib/Package.swift b/bindings/swift/OuisyncLib/Package.swift index 7fa1965f2..8f89b45a8 100644 --- a/bindings/swift/OuisyncLib/Package.swift +++ b/bindings/swift/OuisyncLib/Package.swift @@ -25,7 +25,10 @@ let package = Package( dependencies: ["OuisyncLibCore", "FFIBuilder", "OuisyncLibFFI"], - path: "SourcesFFI"), + path: "SourcesFFI", + linkerSettings: [ + .linkedFramework("SystemConfiguration"), + ]), .testTarget(name: "OuisyncLibTests", dependencies: ["OuisyncLib"], path: "Tests"), diff --git a/bindings/swift/OuisyncLib/Sources/Client.swift b/bindings/swift/OuisyncLib/Sources/Client.swift index 6c745a299..09939d697 100644 --- a/bindings/swift/OuisyncLib/Sources/Client.swift +++ b/bindings/swift/OuisyncLib/Sources/Client.swift @@ -141,7 +141,7 @@ internal actor Client { } private func sendCancel(id: UInt64) async throws { - try await sendFrame(id: id, request: .cancel(id: MessageId(value: id))) + try await sendFrame(id: id, request: .cancel(MessageId(value: id))) } // MARK: - Receive loop @@ -280,12 +280,12 @@ private extension Data { mutating func appendBigEndian(_ value: UInt32) { var v = value.bigEndian - withUnsafeBytes(of: &v) { append(contentsOf: $0) } + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } } mutating func appendBigEndian(_ value: UInt64) { var v = value.bigEndian - withUnsafeBytes(of: &v) { append(contentsOf: $0) } + Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) } } var bigEndianUInt32: UInt32 { diff --git a/bindings/swift/OuisyncLib/Sources/generated/Api.swift b/bindings/swift/OuisyncLib/Sources/generated/Api.swift index cb1aa7b7e..b09d4dd95 100644 --- a/bindings/swift/OuisyncLib/Sources/generated/Api.swift +++ b/bindings/swift/OuisyncLib/Sources/generated/Api.swift @@ -17,7 +17,7 @@ public struct SecretKey { public var debugDescription: String { "\(type(of: self))(******)" } - internal func encodeToMsgPack() -> MessagePackValue { .binary([UInt8](value)) } + 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) @@ -42,7 +42,7 @@ public struct Password { public struct PasswordSalt { public let value: Data - internal func encodeToMsgPack() -> MessagePackValue { .binary([UInt8](value)) } + 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) @@ -308,7 +308,7 @@ public enum PeerState { public struct PublicRuntimeId { public let value: Data - internal func encodeToMsgPack() -> MessagePackValue { .binary([UInt8](value)) } + 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) @@ -359,7 +359,7 @@ public struct Progress { public struct TopicId { public let value: Data - internal func encodeToMsgPack() -> MessagePackValue { .binary([UInt8](value)) } + 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) @@ -823,7 +823,7 @@ public struct Datagram { public let addr: String internal func encodeToMsgPack() -> MessagePackValue { - .array([.binary([UInt8](data)), .string(addr)]) + .array([.binary(data), .string(addr)]) } internal static func decodeFromMsgPack(_ v: MessagePackValue) -> Self? { guard case .array(let arr) = v, arr.count == 2 else { return nil } @@ -875,76 +875,76 @@ public struct NetworkStreamHandle { } 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 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 @@ -962,45 +962,45 @@ internal enum Request { case sessionGetNetworkStats case sessionGetPeers case sessionGetRemoteControlListenerAddr - case sessionGetRemoteListenerAddrs(host: String) + case sessionGetRemoteListenerAddrs(_ host: String) case sessionGetRuntimeId - case sessionGetShareTokenAccessMode(token: ShareToken) - case sessionGetShareTokenInfoHash(token: ShareToken) - case sessionGetShareTokenSuggestedName(token: ShareToken) - case sessionGetStateMonitor(path: [MonitorId]) + 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 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 sessionMirrorExists(_ token: ShareToken, _ host: String) case sessionOpenNetworkSocketV4 case sessionOpenNetworkSocketV6 - case sessionOpenNetworkStream(addr: String, topicId: TopicId) - case sessionOpenRepository(path: String, localSecret: LocalSecret?) + 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 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 sessionSubscribeToStateMonitor(_ path: [MonitorId]) case sessionUnpinDht - case sessionValidateShareToken(token: String) + case sessionValidateShareToken(_ token: String) internal func encodeToMsgPack() -> MessagePackValue { switch self { @@ -1011,13 +1011,13 @@ internal enum Request { 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([UInt8](data))])]) + 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([UInt8](data)), .string(addr)])]) + 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([UInt8](buf))])]) + 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)])]) @@ -1054,7 +1054,7 @@ internal enum Request { 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([UInt8](credentials))])]) + 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() })])]) @@ -1175,7 +1175,7 @@ internal enum Response { 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([UInt8](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))]) @@ -2932,7 +2932,7 @@ public class Repository { } public static func == (lhs: Repository, rhs: Repository) -> Bool { - return lhs.handle == rhs.handle + return lhs.handle.value == rhs.handle.value } public var description: String { "\(type(of: self))(\(handle))" } @@ -3053,7 +3053,7 @@ public class File { } public static func == (lhs: File, rhs: File) -> Bool { - return lhs.handle == rhs.handle + return lhs.handle.value == rhs.handle.value } public var description: String { "\(type(of: self))(\(handle))" } @@ -3109,7 +3109,7 @@ public class NetworkSocket { } public static func == (lhs: NetworkSocket, rhs: NetworkSocket) -> Bool { - return lhs.handle == rhs.handle + return lhs.handle.value == rhs.handle.value } public var description: String { "\(type(of: self))(\(handle))" } @@ -3168,7 +3168,7 @@ public class NetworkStream { } public static func == (lhs: NetworkStream, rhs: NetworkStream) -> Bool { - return lhs.handle == rhs.handle + return lhs.handle.value == rhs.handle.value } public var description: String { "\(type(of: self))(\(handle))" } diff --git a/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift index 24fd7ff4b..e83399357 100644 --- a/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift +++ b/bindings/swift/OuisyncLib/SourcesFFI/OuisyncFFI.swift @@ -1,4 +1,5 @@ import Foundation +@_exported import OuisyncLibCore import OuisyncLibFFI // ErrorCode from cbindgen is typedef uint16_t; Swift imports it as UInt16. @@ -47,7 +48,7 @@ public class OuisyncService { } guard let h = rawHandle else { - throw OuisyncError(.other, "start_service returned null") + throw OuisyncLibCore.OuisyncError(.other, "start_service returned null") } return OuisyncService(h) } @@ -78,13 +79,13 @@ private class StopBox { private func serviceStarted(_ ctx: UnsafeRawPointer?, _ code: UInt16) { guard let ctx else { return } Unmanaged.fromOpaque(ctx).takeRetainedValue() - .continuation.resumeWith(result: serviceResult(code)) + .continuation.resume(with: serviceResult(code)) } private func serviceStopped(_ ctx: UnsafeRawPointer?, _ code: UInt16) { guard let ctx else { return } Unmanaged.fromOpaque(ctx).takeRetainedValue() - .continuation.resumeWith(result: serviceResult(code)) + .continuation.resume(with: serviceResult(code)) } private func serviceNoop(_: UnsafeRawPointer?, _: UInt16) {} @@ -93,6 +94,6 @@ private func serviceResult(_ code: UInt16) -> Result { if code == 0 { return .success(()) } - let errorCode = ErrorCode(rawValue: code) ?? .other - return .failure(OuisyncError(errorCode, "service error (code \(code))")) + 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 b6aa05898..f6e65c90e 100644 --- a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift +++ b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing import OuisyncLib 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/service/cbindgen.toml b/service/cbindgen.toml index dd9cdaf27..20a805c47 100644 --- a/service/cbindgen.toml +++ b/service/cbindgen.toml @@ -1,11 +1,5 @@ language = "C" include_guard = "OUISYNC_SERVICE_H" -# Export ErrorCode as typedef uint16_t so Swift sees it as UInt16, -# avoiding C enum size ambiguity. -[enum] -style = "type" -rename_variants = "QualifiedScreamingSnakeCase" - [parse] parse_deps = false diff --git a/utils/bindgen/src/swift.rs b/utils/bindgen/src/swift.rs index 230e60cf5..aeb284012 100644 --- a/utils/bindgen/src/swift.rs +++ b/utils/bindgen/src/swift.rs @@ -367,7 +367,7 @@ fn encode_expr(ty: &Type, expr: &str) -> String { let v_enc = encode_scalar_expr(v, "$0.value"); format!(".map(Dictionary(uniqueKeysWithValues: {expr}.map {{ ({k_enc}, {v_enc}) }}))") } - Type::Bytes => format!(".binary([UInt8]({expr}))"), + Type::Bytes => format!(".binary({expr})"), Type::Result(..) => ".nil".to_string(), } } @@ -567,7 +567,7 @@ fn write_request_enum( } write!( out, - "{}: {}", + "_ {}: {}", AsLowerCamelCase(field_name), SwiftType(&field.ty) )?; @@ -925,7 +925,7 @@ fn write_api_class( out, "{I}public static func == (lhs: {name}, rhs: {name}) -> Bool {{" )?; - writeln!(out, "{I}{I}return lhs.handle == rhs.handle")?; + writeln!(out, "{I}{I}return lhs.handle.value == rhs.handle.value")?; writeln!(out, "{I}}}")?; writeln!(out)?; writeln!( From ab24dc64ed91df994a5641543eeaf0bdc7ba44a0 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 15:34:05 -0700 Subject: [PATCH 11/23] bindings/swift: add OVERVIEW.md Documents the architecture, build pipeline, code generation workflow, and library usage with examples. --- bindings/swift/OuisyncLib/OVERVIEW.md | 218 ++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 bindings/swift/OuisyncLib/OVERVIEW.md diff --git a/bindings/swift/OuisyncLib/OVERVIEW.md b/bindings/swift/OuisyncLib/OVERVIEW.md new file mode 100644 index 000000000..58098dba6 --- /dev/null +++ b/bindings/swift/OuisyncLib/OVERVIEW.md @@ -0,0 +1,218 @@ +# 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-macos.sh One-shot build script (macOS host) +├── 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 +``` + +## 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: +bash bindings/swift/OuisyncLib/build-xcframework-macos.sh +``` + +This script: +1. Runs `cargo build --package ouisync-service --release --target -apple-darwin` +2. Runs `cbindgen` to generate `target/swift-include/bindings.h` from the Rust sources +3. Creates `OuisyncLib/output/OuisyncLibFFI.xcframework` via `xcodebuild -create-xcframework` + +> Re-run this script any time the Rust service API changes. + +## 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 xcframework is macOS-only for now; iOS targets need a multi-arch fat build. +- 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. +- The SPM `FFIBuilder` plugin rebuilds the Rust library on Xcode/SPM builds; the + `build-xcframework-macos.sh` script is the simpler path for development. From 1944fc6c5f3831bc8ed086e80c43ae970369a94d Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Wed, 27 May 2026 15:35:19 -0700 Subject: [PATCH 12/23] bindings/swift: commit Package.resolved Pins MessagePack.swift at 4.0.0 for reproducible builds. --- bindings/swift/OuisyncLib/Package.resolved | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 bindings/swift/OuisyncLib/Package.resolved 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 +} From 969cb9c27a00c53567f490e99b097ca4d190a948 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Thu, 28 May 2026 10:31:11 -0700 Subject: [PATCH 13/23] bindings/swift: allow FFIBuilder to skip cargo check when xcframework exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard in builder.swift required the "Update rust dependencies" output directory to exist before any build. This worked fine within the OuisyncLib package itself, but broke any external package that depended on OuisyncLib (or OuisyncLibCore) — the output directory lives in OuisyncLib's own .build tree, not in the downstream package's .build tree. Fix: also pass the guard when the pre-built xcframework is already present at output/OuisyncLibFFI.xcframework. Since SKIP=1 in config.sh the build script exits immediately anyway; the only thing that needs to exist is the xcframework artifact. Co-Authored-By: Claude Sonnet 4.5 --- .../swift/OuisyncLib/Plugins/Builder/builder.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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") } From c3f2a2a861eefaa6eae9f102c678d99cb989366c Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Thu, 28 May 2026 10:31:19 -0700 Subject: [PATCH 14/23] bindings/swift: document missing subscribe() API in OVERVIEW.md Add a Known limitations entry explaining why Repository.subscribe() and Session.subscribeToNetwork() are absent: the current Client resolves a single CheckedContinuation per message ID, so streaming responses are silently dropped after the first. NotificationStream.swift is already scaffolded; a channels map in the receive loop and two manual extension functions (mirroring the Kotlin implementation) are what's needed to complete it. Co-Authored-By: Claude Sonnet 4.5 --- bindings/swift/OuisyncLib/OVERVIEW.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bindings/swift/OuisyncLib/OVERVIEW.md b/bindings/swift/OuisyncLib/OVERVIEW.md index 58098dba6..d085005d5 100644 --- a/bindings/swift/OuisyncLib/OVERVIEW.md +++ b/bindings/swift/OuisyncLib/OVERVIEW.md @@ -214,5 +214,15 @@ swift test - The xcframework is macOS-only for now; iOS targets need a multi-arch fat build. - 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-macos.sh` script is the simpler path for development. From 4018b885e1f48c19c72498848cc6367ee724bdc1 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Thu, 28 May 2026 10:31:38 -0700 Subject: [PATCH 15/23] bindings/swift: add macOS SwiftUI example app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the structure of bindings/kotlin/example. The app starts the Ouisync service, opens a session, and presents three screens: - RepositoryListView — create / delete repositories; copy a write-access share token to the clipboard via the share button - FolderView — browse directory contents recursively; create files and directories with the + button - FileView — show file size and SHA-256; write UTF-8 text content via the pencil button; poll sync progress (subscribe() pending) Because swift run launches the process as a command-line tool (no keyboard focus), a run.sh script builds the binary, wraps it in a minimal .app bundle, and opens it with open(1). Co-Authored-By: Claude Sonnet 4.5 --- bindings/swift/example/.gitignore | 1 + bindings/swift/example/Info.plist | 20 ++ bindings/swift/example/Package.resolved | 14 ++ bindings/swift/example/Package.swift | 19 ++ bindings/swift/example/README.md | 65 +++++ .../OuisyncExample/ExampleViewModel.swift | 106 +++++++++ .../Sources/OuisyncExample/FileView.swift | 224 ++++++++++++++++++ .../Sources/OuisyncExample/FolderView.swift | 170 +++++++++++++ .../OuisyncExample/OuisyncExampleApp.swift | 17 ++ .../OuisyncExample/RepositoryListView.swift | 166 +++++++++++++ bindings/swift/example/run.sh | 18 ++ 11 files changed, 820 insertions(+) create mode 100644 bindings/swift/example/.gitignore create mode 100644 bindings/swift/example/Info.plist create mode 100644 bindings/swift/example/Package.resolved create mode 100644 bindings/swift/example/Package.swift create mode 100644 bindings/swift/example/README.md create mode 100644 bindings/swift/example/Sources/OuisyncExample/ExampleViewModel.swift create mode 100644 bindings/swift/example/Sources/OuisyncExample/FileView.swift create mode 100644 bindings/swift/example/Sources/OuisyncExample/FolderView.swift create mode 100644 bindings/swift/example/Sources/OuisyncExample/OuisyncExampleApp.swift create mode 100644 bindings/swift/example/Sources/OuisyncExample/RepositoryListView.swift create mode 100755 bindings/swift/example/run.sh diff --git a/bindings/swift/example/.gitignore b/bindings/swift/example/.gitignore new file mode 100644 index 000000000..30bcfa4ed --- /dev/null +++ b/bindings/swift/example/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/bindings/swift/example/Info.plist b/bindings/swift/example/Info.plist new file mode 100644 index 000000000..edbd27516 --- /dev/null +++ b/bindings/swift/example/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleExecutable + OuisyncExample + CFBundleIdentifier + net.equalitie.ouisync.example + CFBundleName + OuisyncExample + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/bindings/swift/example/Package.resolved b/bindings/swift/example/Package.resolved new file mode 100644 index 000000000..6651d5099 --- /dev/null +++ b/bindings/swift/example/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/example/Package.swift b/bindings/swift/example/Package.swift new file mode 100644 index 000000000..adcf39c89 --- /dev/null +++ b/bindings/swift/example/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "OuisyncExample", + platforms: [.macOS(.v14)], + dependencies: [ + .package(path: "../OuisyncLib"), + ], + targets: [ + .executableTarget( + name: "OuisyncExample", + dependencies: [ + .product(name: "OuisyncLib", package: "OuisyncLib"), + ], + path: "Sources/OuisyncExample" + ), + ] +) diff --git a/bindings/swift/example/README.md b/bindings/swift/example/README.md new file mode 100644 index 000000000..78f649128 --- /dev/null +++ b/bindings/swift/example/README.md @@ -0,0 +1,65 @@ +# Ouisync Swift example app + +A minimal macOS SwiftUI app that demonstrates the core Ouisync Swift API. It mirrors the +structure of the [Kotlin example](../../kotlin/example). + +## 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 | + +## Prerequisites + +Build the xcframework before running the example (from the repo root): + +```sh +bash bindings/swift/OuisyncLib/build-xcframework-macos.sh +``` + +Verify it was created: + +```sh +ls bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework +``` + +## Running + +```sh +cd bindings/swift/example +bash run.sh +``` + +`run.sh` builds the binary, wraps it in a minimal `.app` bundle, and opens it with +`open(1)` so macOS treats it as a proper GUI app (keyboard focus, Dock icon, etc.). + +> **Why not `swift run`?** Running an SPM executable directly from the terminal starts +> the process as a command-line tool. macOS never grants it keyboard focus, so the +> SwiftUI window appears but is unresponsive to typing. Packaging it as a `.app` bundle +> and launching via `open` fixes this. + +The app stores its data under `~/Library/Application Support/OuisyncExample/`. + +## Usage + +The app starts the Ouisync service automatically on launch. + +**Repository list** — the main window 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 **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. This will be fixed when the notification API is wired up. diff --git a/bindings/swift/example/Sources/OuisyncExample/ExampleViewModel.swift b/bindings/swift/example/Sources/OuisyncExample/ExampleViewModel.swift new file mode 100644 index 000000000..3a4cbf202 --- /dev/null +++ b/bindings/swift/example/Sources/OuisyncExample/ExampleViewModel.swift @@ -0,0 +1,106 @@ +import Foundation +import OuisyncLib + +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] = [:] + + 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() + } catch { + sessionError = "Failed to list repositories: \(error)" + } + } + + 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 + } + + func deleteRepository(name: String) async throws { + guard let repo = repositories[name] else { return } + repositories.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/OuisyncExample/FileView.swift b/bindings/swift/example/Sources/OuisyncExample/FileView.swift new file mode 100644 index 000000000..931332937 --- /dev/null +++ b/bindings/swift/example/Sources/OuisyncExample/FileView.swift @@ -0,0 +1,224 @@ +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)) + .frame(width: 380, height: 160) + .border(Color.secondary.opacity(0.3)) + HStack { + Spacer() + Button("Cancel") { onCancel() } + Button("Write") { onSubmit(text) } + .buttonStyle(.borderedProminent) + .disabled(text.isEmpty) + } + } + .frame(width: 400) + } +} + +// 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/OuisyncExample/FolderView.swift b/bindings/swift/example/Sources/OuisyncExample/FolderView.swift new file mode 100644 index 000000000..29eb1942b --- /dev/null +++ b/bindings/swift/example/Sources/OuisyncExample/FolderView.swift @@ -0,0 +1,170 @@ +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() } + .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 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: + // Create an empty file, then immediately close it. + 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) + } + } + .frame(width: 280) + } + + 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/OuisyncExample/OuisyncExampleApp.swift b/bindings/swift/example/Sources/OuisyncExample/OuisyncExampleApp.swift new file mode 100644 index 000000000..bd0554f1f --- /dev/null +++ b/bindings/swift/example/Sources/OuisyncExample/OuisyncExampleApp.swift @@ -0,0 +1,17 @@ +import SwiftUI + +@main +struct OuisyncExampleApp: App { + @StateObject private var viewModel = ExampleViewModel() + + var body: some Scene { + WindowGroup { + RepositoryListView() + .environmentObject(viewModel) + .frame(minWidth: 400, minHeight: 300) + } + .commands { + CommandGroup(replacing: .newItem) {} + } + } +} diff --git a/bindings/swift/example/Sources/OuisyncExample/RepositoryListView.swift b/bindings/swift/example/Sources/OuisyncExample/RepositoryListView.swift new file mode 100644 index 000000000..1f691400d --- /dev/null +++ b/bindings/swift/example/Sources/OuisyncExample/RepositoryListView.swift @@ -0,0 +1,166 @@ +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 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 { name, token in + isCreating = false + Task { + do { + try await viewModel.createRepository(name: name, token: token) + } catch { + errorMessage = error.localizedDescription + } + } + } onCancel: { + isCreating = false + } + .padding() + } + .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: name, onNavigate: { + navigationPath.append(.folder(repositoryName: name, path: "")) + }, onShare: { + Task { + if let token = await viewModel.shareRepository(name: name) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(token, forType: .string) + } + } + }, onDelete: { + Task { + do { + try await viewModel.deleteRepository(name: name) + } catch { + errorMessage = error.localizedDescription + } + } + }) + } + } + } +} + +private struct RepositoryRow: View { + let name: String + let onNavigate: () -> Void + let onShare: () -> Void + let onDelete: () -> Void + + @State private var confirmDelete = false + + var body: some View { + HStack { + Image(systemName: "folder") + Text(name) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { onNavigate() } + + Button { onShare() } label: { Image(systemName: "square.and.arrow.up") } + .buttonStyle(.borderless) + .help("Copy share token to clipboard") + + Button { confirmDelete = true } label: { Image(systemName: "trash") } + .buttonStyle(.borderless) + .foregroundStyle(.red) + } + .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 { + let onSubmit: (String, String) -> Void + let onCancel: () -> Void + + @State private var name = "" + @State private var token = "" + @State private var nameError = "" + + 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) + } + } + .frame(width: 320) + } + + 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) +} diff --git a/bindings/swift/example/run.sh b/bindings/swift/example/run.sh new file mode 100755 index 000000000..1787068c4 --- /dev/null +++ b/bindings/swift/example/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build OuisyncExample and launch it as a proper macOS .app bundle so that it +# receives keyboard focus and appears in the Dock. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +swift build + +BINARY=".build/debug/OuisyncExample" +APP=".build/OuisyncExample.app" + +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BINARY" "$APP/Contents/MacOS/OuisyncExample" +cp "Info.plist" "$APP/Contents/" + +open "$APP" From 51f3aacca6f36ffe7cf38e8f3b8e4d57132e1915 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Tue, 2 Jun 2026 10:20:16 -0700 Subject: [PATCH 16/23] bindings/swift: fix iOS build errors in fs_util and logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fs_util/Cargo.toml: add ios to the libc target cfg so the crate links correctly when cross-compiling for iOS targets - fs_util/src/safe_move.rs: extend blocking_rename_no_replace_atomic cfg to any(macos, ios) — renamex_np has been available on iOS 10+ - service/src/logger/mod.rs: route ios through stdout.rs, resolving the pre-existing TODO comment --- fs_util/Cargo.toml | 2 +- fs_util/src/safe_move.rs | 2 +- service/src/logger/mod.rs | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) 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/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}; From 48564c475f683d37d3e1cbbd825f636aa6a05471 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Tue, 2 Jun 2026 10:22:35 -0700 Subject: [PATCH 17/23] bindings/swift: add build-xcframework.sh for all platforms (macOS + iOS) Replaces the macOS-only script with one that reads all targets from config.sh, builds natively with cargo (no Docker), combines simulator slices with lipo, and produces a single xcframework containing macOS, iOS device, and iOS simulator slices. OVERVIEW.md updated: prerequisites now include the rustup target add one-liner for iOS toolchains, the build section documents both scripts, and the iOS limitation bullet is removed from Known Limitations. --- bindings/swift/OuisyncLib/OVERVIEW.md | 34 +++-- .../swift/OuisyncLib/build-xcframework.sh | 120 ++++++++++++++++++ .../Info.plist.sample | 13 -- 3 files changed, 146 insertions(+), 21 deletions(-) create mode 100755 bindings/swift/OuisyncLib/build-xcframework.sh delete mode 100644 bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample diff --git a/bindings/swift/OuisyncLib/OVERVIEW.md b/bindings/swift/OuisyncLib/OVERVIEW.md index d085005d5..0bc206e1c 100644 --- a/bindings/swift/OuisyncLib/OVERVIEW.md +++ b/bindings/swift/OuisyncLib/OVERVIEW.md @@ -37,7 +37,9 @@ response type; `Client.swift` handles the framing and multiplexing. ``` bindings/swift/OuisyncLib/ ├── Package.swift SPM package definition -├── build-xcframework-macos.sh One-shot build script (macOS host) +├── 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 @@ -78,23 +80,40 @@ Verify the active developer tools point at Xcode (not CommandLineTools): 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: -bash bindings/swift/OuisyncLib/build-xcframework-macos.sh +# From anywhere inside the repo — builds all targets in config.sh: +bash bindings/swift/OuisyncLib/build-xcframework.sh ``` This script: -1. Runs `cargo build --package ouisync-service --release --target -apple-darwin` -2. Runs `cbindgen` to generate `target/swift-include/bindings.h` from the Rust sources -3. Creates `OuisyncLib/output/OuisyncLibFFI.xcframework` via `xcodebuild -create-xcframework` +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 @@ -211,7 +230,6 @@ swift test ## Known limitations / TODOs -- The xcframework is macOS-only for now; iOS targets need a multi-arch fat build. - 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` @@ -225,4 +243,4 @@ swift test `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-macos.sh` script is the simpler path for development. + `build-xcframework.sh` script is the simpler path for development. 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 - - From 7aaaa8ec8ebd3ae4eb5c357b7c81d5a4059d7605 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Tue, 2 Jun 2026 10:22:57 -0700 Subject: [PATCH 18/23] bindings/swift: replace SPM example with multi-platform Xcode project Replaces the macOS-only SPM package with a single Xcode project targeting macOS 14+ and iOS 17+ from one shared codebase. Platform differences are isolated to #if os(macOS) in four spots. Notable changes: - ShareLink with ouisync:// scheme (converted from the API's https://ouisync.net/... token) so AirDrop works natively on both platforms - Incoming ouisync:// links registered in Info.plist open the app and pre-fill the Create Repository sheet with the token and suggested name - ouisync:// <-> https://ouisync.net/ conversion at share/receive time keeps custom scheme handling simple without Universal Links --- bindings/swift/example/.gitignore | 3 + bindings/swift/example/Info.plist | 20 - .../OuisyncExample.xcodeproj/project.pbxproj | 344 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm}/Package.resolved | 3 +- bindings/swift/example/Package.swift | 19 - bindings/swift/example/README.md | 63 ++-- .../ExampleViewModel.swift | 32 ++ .../{OuisyncExample => }/FileView.swift | 6 + .../{OuisyncExample => }/FolderView.swift | 3 +- bindings/swift/example/Sources/Info.plist | 46 +++ .../Sources/OuisyncExample.entitlements | 5 + .../OuisyncExampleApp.swift | 7 + .../RepositoryListView.swift | 90 +++-- bindings/swift/example/run.sh | 18 - 15 files changed, 552 insertions(+), 114 deletions(-) delete mode 100644 bindings/swift/example/Info.plist create mode 100644 bindings/swift/example/OuisyncExample.xcodeproj/project.pbxproj create mode 100644 bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename bindings/swift/example/{ => OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm}/Package.resolved (74%) delete mode 100644 bindings/swift/example/Package.swift rename bindings/swift/example/Sources/{OuisyncExample => }/ExampleViewModel.swift (72%) rename bindings/swift/example/Sources/{OuisyncExample => }/FileView.swift (98%) rename bindings/swift/example/Sources/{OuisyncExample => }/FolderView.swift (98%) create mode 100644 bindings/swift/example/Sources/Info.plist create mode 100644 bindings/swift/example/Sources/OuisyncExample.entitlements rename bindings/swift/example/Sources/{OuisyncExample => }/OuisyncExampleApp.swift (70%) rename bindings/swift/example/Sources/{OuisyncExample => }/RepositoryListView.swift (63%) delete mode 100755 bindings/swift/example/run.sh diff --git a/bindings/swift/example/.gitignore b/bindings/swift/example/.gitignore index 30bcfa4ed..62b58cad9 100644 --- a/bindings/swift/example/.gitignore +++ b/bindings/swift/example/.gitignore @@ -1 +1,4 @@ .build/ +xcuserdata/ +*.xcuserstate +DerivedData/ diff --git a/bindings/swift/example/Info.plist b/bindings/swift/example/Info.plist deleted file mode 100644 index edbd27516..000000000 --- a/bindings/swift/example/Info.plist +++ /dev/null @@ -1,20 +0,0 @@ - - - - - CFBundleExecutable - OuisyncExample - CFBundleIdentifier - net.equalitie.ouisync.example - CFBundleName - OuisyncExample - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - NSHighResolutionCapable - - NSPrincipalClass - NSApplication - - 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/Package.resolved b/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 74% rename from bindings/swift/example/Package.resolved rename to bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6651d5099..85ca83fae 100644 --- a/bindings/swift/example/Package.resolved +++ b/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "d2e72d155c715972f5e925aef3afa3d54dbed2eb3339b79bba0d0ec63916a035", "pins" : [ { "identity" : "messagepack.swift", @@ -10,5 +11,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/bindings/swift/example/Package.swift b/bindings/swift/example/Package.swift deleted file mode 100644 index adcf39c89..000000000 --- a/bindings/swift/example/Package.swift +++ /dev/null @@ -1,19 +0,0 @@ -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "OuisyncExample", - platforms: [.macOS(.v14)], - dependencies: [ - .package(path: "../OuisyncLib"), - ], - targets: [ - .executableTarget( - name: "OuisyncExample", - dependencies: [ - .product(name: "OuisyncLib", package: "OuisyncLib"), - ], - path: "Sources/OuisyncExample" - ), - ] -) diff --git a/bindings/swift/example/README.md b/bindings/swift/example/README.md index 78f649128..fad7f72c9 100644 --- a/bindings/swift/example/README.md +++ b/bindings/swift/example/README.md @@ -1,12 +1,14 @@ -# Ouisync Swift example app +# OuisyncExample -A minimal macOS SwiftUI app that demonstrates the core Ouisync Swift API. It mirrors the -structure of the [Kotlin example](../../kotlin/example). +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: +`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 | |---|---|---| @@ -14,34 +16,30 @@ to the UI via `@Published` properties. The UI is split across three views: | `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 running the example (from the repo root): +Build the xcframework before opening the project (from the repo root): ```sh -bash bindings/swift/OuisyncLib/build-xcframework-macos.sh +bash bindings/swift/OuisyncLib/build-xcframework.sh ``` -Verify it was created: - -```sh -ls bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework -``` +This compiles the Rust service for all targets in `config.sh` and produces +`OuisyncLib/output/OuisyncLibFFI.xcframework`. ## Running +Open the project in Xcode: + ```sh -cd bindings/swift/example -bash run.sh +open bindings/swift/example/OuisyncExample.xcodeproj ``` -`run.sh` builds the binary, wraps it in a minimal `.app` bundle, and opens it with -`open(1)` so macOS treats it as a proper GUI app (keyboard focus, Dock icon, etc.). - -> **Why not `swift run`?** Running an SPM executable directly from the terminal starts -> the process as a command-line tool. macOS never grants it keyboard focus, so the -> SwiftUI window appears but is unresponsive to typing. Packaging it as a `.app` bundle -> and launching via `open` fixes this. +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/`. @@ -49,17 +47,18 @@ The app stores its data under `~/Library/Application Support/OuisyncExample/`. The app starts the Ouisync service automatically on launch. -**Repository list** — the main window 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. +**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. +**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 **refresh** button -to re-read after the file syncs further. +**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. This will be fixed when the notification API is wired up. +> **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/OuisyncExample/ExampleViewModel.swift b/bindings/swift/example/Sources/ExampleViewModel.swift similarity index 72% rename from bindings/swift/example/Sources/OuisyncExample/ExampleViewModel.swift rename to bindings/swift/example/Sources/ExampleViewModel.swift index 3a4cbf202..d05c41e32 100644 --- a/bindings/swift/example/Sources/OuisyncExample/ExampleViewModel.swift +++ b/bindings/swift/example/Sources/ExampleViewModel.swift @@ -1,6 +1,36 @@ 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 @@ -18,6 +48,8 @@ class ExampleViewModel: ObservableObject { @Published var sessionError: String? @Published var repositories: [String: Repository] = [:] + @Published var pendingShare: PendingShare? + init() { Task { await start() } diff --git a/bindings/swift/example/Sources/OuisyncExample/FileView.swift b/bindings/swift/example/Sources/FileView.swift similarity index 98% rename from bindings/swift/example/Sources/OuisyncExample/FileView.swift rename to bindings/swift/example/Sources/FileView.swift index 931332937..d9cdd9226 100644 --- a/bindings/swift/example/Sources/OuisyncExample/FileView.swift +++ b/bindings/swift/example/Sources/FileView.swift @@ -178,7 +178,11 @@ private struct WriteContentSheet: View { 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() @@ -188,7 +192,9 @@ private struct WriteContentSheet: View { .disabled(text.isEmpty) } } +#if os(macOS) .frame(width: 400) +#endif } } diff --git a/bindings/swift/example/Sources/OuisyncExample/FolderView.swift b/bindings/swift/example/Sources/FolderView.swift similarity index 98% rename from bindings/swift/example/Sources/OuisyncExample/FolderView.swift rename to bindings/swift/example/Sources/FolderView.swift index 29eb1942b..ce10000fe 100644 --- a/bindings/swift/example/Sources/OuisyncExample/FolderView.swift +++ b/bindings/swift/example/Sources/FolderView.swift @@ -106,7 +106,6 @@ struct FolderView: View { do { switch kind { case .file: - // Create an empty file, then immediately close it. let file = try await repo.createFile(entryPath) try await file.close() case .directory: @@ -158,7 +157,9 @@ private struct CreateEntrySheet: View { .buttonStyle(.borderedProminent) } } +#if os(macOS) .frame(width: 280) +#endif } private func validate() -> Bool { 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/OuisyncExample/OuisyncExampleApp.swift b/bindings/swift/example/Sources/OuisyncExampleApp.swift similarity index 70% rename from bindings/swift/example/Sources/OuisyncExample/OuisyncExampleApp.swift rename to bindings/swift/example/Sources/OuisyncExampleApp.swift index bd0554f1f..5a72429b4 100644 --- a/bindings/swift/example/Sources/OuisyncExample/OuisyncExampleApp.swift +++ b/bindings/swift/example/Sources/OuisyncExampleApp.swift @@ -8,10 +8,17 @@ struct OuisyncExampleApp: App { 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/OuisyncExample/RepositoryListView.swift b/bindings/swift/example/Sources/RepositoryListView.swift similarity index 63% rename from bindings/swift/example/Sources/OuisyncExample/RepositoryListView.swift rename to bindings/swift/example/Sources/RepositoryListView.swift index 1f691400d..10f916daa 100644 --- a/bindings/swift/example/Sources/OuisyncExample/RepositoryListView.swift +++ b/bindings/swift/example/Sources/RepositoryListView.swift @@ -5,6 +5,8 @@ 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 { @@ -28,7 +30,7 @@ struct RepositoryListView: View { } } .sheet(isPresented: $isCreating) { - CreateRepositorySheet { name, token in + CreateRepositorySheet(initialName: initialName, initialToken: initialToken) { name, token in isCreating = false Task { do { @@ -42,6 +44,13 @@ struct RepositoryListView: View { } .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 } } @@ -60,24 +69,24 @@ struct RepositoryListView: View { 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: name, onNavigate: { - navigationPath.append(.folder(repositoryName: name, path: "")) - }, onShare: { - Task { - if let token = await viewModel.shareRepository(name: name) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(token, forType: .string) - } - } - }, onDelete: { - Task { - do { - try await viewModel.deleteRepository(name: name) - } catch { - errorMessage = error.localizedDescription + RepositoryRow( + name: 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 + } } } - }) + ) } } } @@ -86,9 +95,10 @@ struct RepositoryListView: View { private struct RepositoryRow: View { let name: String let onNavigate: () -> Void - let onShare: () -> Void + let onShare: () async -> String? let onDelete: () -> Void + @State private var shareToken: String? @State private var confirmDelete = false var body: some View { @@ -99,14 +109,26 @@ private struct RepositoryRow: View { .contentShape(Rectangle()) .onTapGesture { onNavigate() } - Button { onShare() } label: { Image(systemName: "square.and.arrow.up") } - .buttonStyle(.borderless) - .help("Copy share token to clipboard") + 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) {} @@ -117,13 +139,24 @@ private struct RepositoryRow: View { } private struct CreateRepositorySheet: View { + var initialName: String = "" + var initialToken: String = "" let onSubmit: (String, String) -> Void let onCancel: () -> Void - @State private var name = "" - @State private var token = "" + @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) @@ -147,7 +180,9 @@ private struct CreateRepositorySheet: View { .buttonStyle(.borderedProminent) } } +#if os(macOS) .frame(width: 320) +#endif } private func validate() -> Bool { @@ -164,3 +199,12 @@ 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/bindings/swift/example/run.sh b/bindings/swift/example/run.sh deleted file mode 100755 index 1787068c4..000000000 --- a/bindings/swift/example/run.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Build OuisyncExample and launch it as a proper macOS .app bundle so that it -# receives keyboard focus and appears in the Dock. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR" - -swift build - -BINARY=".build/debug/OuisyncExample" -APP=".build/OuisyncExample.app" - -mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -cp "$BINARY" "$APP/Contents/MacOS/OuisyncExample" -cp "Info.plist" "$APP/Contents/" - -open "$APP" From e67bb7548be9a8f5cf449caaa113b9a448954d1f Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Tue, 2 Jun 2026 10:34:16 -0700 Subject: [PATCH 19/23] bindings/swift/example: add gitignore --- bindings/swift/example/.gitignore | 11 ++++++++++- .../xcshareddata/swiftpm/Package.resolved | 15 --------------- 2 files changed, 10 insertions(+), 16 deletions(-) delete mode 100644 bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/bindings/swift/example/.gitignore b/bindings/swift/example/.gitignore index 62b58cad9..6258da182 100644 --- a/bindings/swift/example/.gitignore +++ b/bindings/swift/example/.gitignore @@ -1,4 +1,13 @@ +# Build output .build/ +DerivedData/ +build/ + +# Xcode user-specific files xcuserdata/ *.xcuserstate -DerivedData/ +*.xcscmblueprint +*.xccheckout + +# Xcode auto-generated workspace data (schemes, package pins, etc.) +xcshareddata/ diff --git a/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 85ca83fae..000000000 --- a/bindings/swift/example/OuisyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "d2e72d155c715972f5e925aef3afa3d54dbed2eb3339b79bba0d0ec63916a035", - "pins" : [ - { - "identity" : "messagepack.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/a2/MessagePack.swift.git", - "state" : { - "revision" : "27b35fd49e92fcae395bf8ccb233499d89cc7890", - "version" : "4.0.0" - } - } - ], - "version" : 3 -} From ba0f95ca25a7162e6471c1ad73b7e0de8d8624e0 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Mon, 8 Jun 2026 14:11:26 -0700 Subject: [PATCH 20/23] bindings/swift/example: show repository info hash in repo list Display the first 16 chars of each repository's info hash as a subtitle in the repo list, and log the full hash on startup. This makes it easy to verify that two devices share the same repository token when debugging sync issues. Also fix repo display names to show only the filename without the store path and .ouisyncdb extension. --- .../example/Sources/ExampleViewModel.swift | 18 +++++++++++++++++ .../example/Sources/RepositoryListView.swift | 20 ++++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/bindings/swift/example/Sources/ExampleViewModel.swift b/bindings/swift/example/Sources/ExampleViewModel.swift index d05c41e32..4d74c5071 100644 --- a/bindings/swift/example/Sources/ExampleViewModel.swift +++ b/bindings/swift/example/Sources/ExampleViewModel.swift @@ -48,6 +48,7 @@ class ExampleViewModel: ObservableObject { @Published var sessionError: String? @Published var repositories: [String: Repository] = [:] + @Published var repositoryInfoHashes: [String: String] = [:] @Published var pendingShare: PendingShare? @@ -107,11 +108,23 @@ class ExampleViewModel: ObservableObject { guard let session else { return } do { repositories = try await session.listRepositories() + 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 } @@ -123,11 +136,16 @@ class ExampleViewModel: ObservableObject { // 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() } diff --git a/bindings/swift/example/Sources/RepositoryListView.swift b/bindings/swift/example/Sources/RepositoryListView.swift index 10f916daa..ea51047ae 100644 --- a/bindings/swift/example/Sources/RepositoryListView.swift +++ b/bindings/swift/example/Sources/RepositoryListView.swift @@ -70,7 +70,8 @@ struct RepositoryListView: View { } else { List(Array(viewModel.repositories.keys.sorted()), id: \.self) { name in RepositoryRow( - name: name, + name: URL(fileURLWithPath: name).deletingPathExtension().lastPathComponent, + infoHash: viewModel.repositoryInfoHashes[name], onNavigate: { navigationPath.append(.folder(repositoryName: name, path: "")) }, @@ -94,6 +95,7 @@ struct RepositoryListView: View { private struct RepositoryRow: View { let name: String + let infoHash: String? let onNavigate: () -> Void let onShare: () async -> String? let onDelete: () -> Void @@ -104,10 +106,18 @@ private struct RepositoryRow: View { var body: some View { HStack { Image(systemName: "folder") - Text(name) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { onNavigate() } + 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:)) { From e7226bd5d5fab479415cf3c0ced4a7eec4eef028 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Mon, 8 Jun 2026 14:14:56 -0700 Subject: [PATCH 21/23] bindings/swift/example: re-enable sync on repos after restart The ouisync service does not persist the sync-enabled flag, so repos loaded from the store directory on startup come up with sync disabled. Call setSyncEnabled(true) on each repo after listRepositories() so syncing resumes every session without requiring explicit user action. --- bindings/swift/example/Sources/ExampleViewModel.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bindings/swift/example/Sources/ExampleViewModel.swift b/bindings/swift/example/Sources/ExampleViewModel.swift index 4d74c5071..aed977db5 100644 --- a/bindings/swift/example/Sources/ExampleViewModel.swift +++ b/bindings/swift/example/Sources/ExampleViewModel.swift @@ -108,6 +108,9 @@ class ExampleViewModel: ObservableObject { 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)" From 29758584ac0257b76e51e2ed8d90682c4234bbf9 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Mon, 8 Jun 2026 15:38:51 -0700 Subject: [PATCH 22/23] bindings/swift: add subscribe() API for repository/session change notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client.swift gains a subscriptions registry (UInt64 → AsyncStream.Continuation) alongside the existing pending map. A new subscribe() method sends the request, waits for the server ack via the normal pending path, then returns an AsyncStream that receives all subsequent server-pushed events for that message ID. Cancellation sends Request.cancel to the server; connection drop finishes all active streams. swift.rs is updated to generate AsyncStream methods for #[api(stream(T))] variants instead of skipping them. The generated wrapper filters response cases and hooks onTermination to cancel the inner forwarding task. Regenerated Api.swift exposes four new public methods: Repository.subscribe() -> AsyncStream Session.subscribeToNetwork() -> AsyncStream Session.subscribeToStateMonitor(_:) -> AsyncStream Session.dhtLookup(_:_:) -> AsyncStream NotificationStream.swift is deleted; it was never wired up and is superseded by the subscriptions map in Client. --- .../swift/OuisyncLib/Sources/Client.swift | 71 +++++++++++++++- .../Sources/NotificationStream.swift | 34 -------- .../OuisyncLib/Sources/generated/Api.swift | 81 +++++++++++++++++++ utils/bindgen/src/swift.rs | 69 +++++++++++++++- 4 files changed, 218 insertions(+), 37 deletions(-) delete mode 100644 bindings/swift/OuisyncLib/Sources/NotificationStream.swift diff --git a/bindings/swift/OuisyncLib/Sources/Client.swift b/bindings/swift/OuisyncLib/Sources/Client.swift index 09939d697..f76d4e7df 100644 --- a/bindings/swift/OuisyncLib/Sources/Client.swift +++ b/bindings/swift/OuisyncLib/Sources/Client.swift @@ -13,6 +13,7 @@ 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 @@ -92,6 +93,44 @@ internal actor Client { } } + 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() } @@ -163,7 +202,7 @@ internal actor Client { guard let (value, _) = try? unpack(Data(payload)) else { continue } let result = decodeResponseResult(value) - // Resume continuation + // Route to pending one-shot or active subscription stream if let cont = pending.removeValue(forKey: messageId) { switch result { case .success(let response): @@ -171,19 +210,47 @@ internal actor Client { 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 + // 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 { diff --git a/bindings/swift/OuisyncLib/Sources/NotificationStream.swift b/bindings/swift/OuisyncLib/Sources/NotificationStream.swift deleted file mode 100644 index 2ec4dc78f..000000000 --- a/bindings/swift/OuisyncLib/Sources/NotificationStream.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -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 { - state.registrations.removeValue(forKey: subscriptionId) - } -} diff --git a/bindings/swift/OuisyncLib/Sources/generated/Api.swift b/bindings/swift/OuisyncLib/Sources/generated/Api.swift index b09d4dd95..8c8d9f08d 100644 --- a/bindings/swift/OuisyncLib/Sources/generated/Api.swift +++ b/bindings/swift/OuisyncLib/Sources/generated/Api.swift @@ -1519,6 +1519,32 @@ public class Session { } } + /// 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, @@ -2212,6 +2238,42 @@ public class Session { } } + 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 { @@ -2918,6 +2980,25 @@ public class Repository { } } + 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, diff --git a/utils/bindgen/src/swift.rs b/utils/bindgen/src/swift.rs index aeb284012..6437b3e6e 100644 --- a/utils/bindgen/src/swift.rs +++ b/utils/bindgen/src/swift.rs @@ -801,7 +801,7 @@ fn write_api_class( let prefix = format!("{}_", AsSnakeCase(name)); for (variant_name, variant) in request_variants { - if variant.skip || variant.ret_stream_item.is_some() { + if variant.skip { continue; } @@ -809,6 +809,73 @@ fn write_api_class( 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, From 3810ded7c7af923b565340d7255d3cbacba80ca9 Mon Sep 17 00:00:00 2001 From: Vlad Panfilov Date: Mon, 8 Jun 2026 15:39:15 -0700 Subject: [PATCH 23/23] bindings/swift/example: auto-refresh folder view on repository changes Subscribe to repository change notifications in FolderView so synced files appear automatically without requiring a manual refresh. --- bindings/swift/example/Sources/FolderView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bindings/swift/example/Sources/FolderView.swift b/bindings/swift/example/Sources/FolderView.swift index ce10000fe..aa898d5d0 100644 --- a/bindings/swift/example/Sources/FolderView.swift +++ b/bindings/swift/example/Sources/FolderView.swift @@ -36,6 +36,7 @@ struct FolderView: View { } .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") } @@ -84,6 +85,14 @@ struct FolderView: View { // 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"