From 15fe377793fabaf798b273459fc5e00f6b5235b0 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Mon, 6 Jan 2025 16:05:40 +0200 Subject: [PATCH 01/24] Move FFI callback to a global Skipping the closure captures somewhat reduces mapped memory --- .../OuisyncLib/Sources/OuisyncClient.swift | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift b/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift index f7bf5a4e8..dcbea9136 100644 --- a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift +++ b/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift @@ -8,6 +8,18 @@ import Foundation import OuisyncLibFFI + +fileprivate func OnReceiveFromBackend(context: FFIContext?, + dataPointer: UnsafePointer?, + size: UInt64) -> Void { + 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)!))) +} + + public class OuisyncClient { var clientHandle: SessionHandle let ffi: OuisyncFFI @@ -20,15 +32,8 @@ public class OuisyncClient { 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)!))) - } - + .init(mutating: OuisyncFFI.toUnretainedPtr(obj: client)), + OnReceiveFromBackend) if result.error_code != 0 { throw SessionCreateError("Failed to create session, code:\(result.error_code), message:\(result.error_message!)") } From 93e60ad3c5f5e89006afe872b7f84eb53e89ed93 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 8 Jan 2025 01:08:50 +0200 Subject: [PATCH 02/24] Better mac build system --- bindings/swift/OuisyncLib/.gitignore | 3 +- bindings/swift/OuisyncLib/Package.swift | 6 +- .../OuisyncLib/Plugins/Updater/updater.swift | 9 +- bindings/swift/OuisyncLib/Plugins/build.sh | 47 +++++----- bindings/swift/OuisyncLib/Plugins/update.sh | 51 +++++------ bindings/swift/OuisyncLib/config.sh | 11 --- .../Info.plist.sample | 13 --- bindings/swift/OuisyncLib/reset-output.sh | 86 ------------------- 8 files changed, 46 insertions(+), 180 deletions(-) delete mode 100644 bindings/swift/OuisyncLib/config.sh delete mode 100644 bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample delete mode 100755 bindings/swift/OuisyncLib/reset-output.sh diff --git a/bindings/swift/OuisyncLib/.gitignore b/bindings/swift/OuisyncLib/.gitignore index 72917e5fd..878cfd1fb 100644 --- a/bindings/swift/OuisyncLib/.gitignore +++ b/bindings/swift/OuisyncLib/.gitignore @@ -1,4 +1,5 @@ -/output +/OuisyncLibFFI.xcframework +/config.sh .DS_Store /.build /Packages diff --git a/bindings/swift/OuisyncLib/Package.swift b/bindings/swift/OuisyncLib/Package.swift index 592ff50cf..49a169653 100644 --- a/bindings/swift/OuisyncLib/Package.swift +++ b/bindings/swift/OuisyncLib/Package.swift @@ -1,7 +1,6 @@ // swift-tools-version: 5.9 import PackageDescription - let package = Package( name: "OuisyncLib", platforms: [.macOS(.v13), .iOS(.v16)], @@ -25,7 +24,7 @@ let package = Package( path: "Tests"), // FIXME: move this to a separate package / framework .binaryTarget(name: "OuisyncLibFFI", - path: "output/OuisyncLibFFI.xcframework"), + path: "OuisyncLibFFI.xcframework"), .plugin(name: "FFIBuilder", capability: .buildTool(), path: "Plugins/Builder"), @@ -34,8 +33,7 @@ let package = Package( description: "Update rust dependencies"), permissions: [ .allowNetworkConnections(scope: .all(), - reason: "Downloads dependencies defined by Cargo.toml"), - .writeToPackageDirectory(reason: "These are not the droids you are looking for")]), + reason: "Downloads dependencies defined by Cargo.toml")]), path: "Plugins/Updater"), ] ) diff --git a/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift b/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift index a32fb6d60..af74e20e6 100644 --- a/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift +++ b/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift @@ -18,18 +18,11 @@ import PackagePlugin func performCommand(context: PackagePlugin.PluginContext, arguments: [String] = []) async throws { - let update = context.pluginWorkDirectory - - // FIXME: this path is very unstable; we might need to search the tree instead - let build = update - .removingLastComponent() - .appending(["\(context.package.id).output", "OuisyncLib", "FFIBuilder"]) - let task = Process() let exe = context.package.directory.appending(["Plugins", "update.sh"]).string task.standardInput = nil task.executableURL = URL(fileURLWithPath: exe) - task.arguments = [update.string, build.string] + task.arguments = [context.pluginWorkDirectory.string] do { try task.run() } catch { panic("Unable to start \(exe): \(error)") } task.waitUntilExit() diff --git a/bindings/swift/OuisyncLib/Plugins/build.sh b/bindings/swift/OuisyncLib/Plugins/build.sh index a08e07b3c..1e616d649 100755 --- a/bindings/swift/OuisyncLib/Plugins/build.sh +++ b/bindings/swift/OuisyncLib/Plugins/build.sh @@ -1,26 +1,19 @@ #!/usr/bin/env zsh # Command line tool which produces a `OuisyncLibFFI` framework for all configured llvm triples from -# OuisyncLib/config.sh -# -# This tool runs in a sandboxed process that can only write to a `output` folder and cannot access -# the network, so it relies on the `Updater` companion plugin to download the required dependencies -# before hand. Unfortunately, this does not work 100% of the time since both rust and especially -# cargo like to touch the lockfiles or the network for various reasons even when told not to. -# -# Called by the builder plugin which passes its own environment as well as the input, dependency -# and output paths. The builder checks that the dependency folder exists, but does not otherwise -# FIXME: validate that it contains all necessary dependencies as defined in Cargo.toml -# -# Hic sunt dracones! These might be of interest to anyone thinking they can do better than this mess: +# OuisyncLib/config.sh (currently generated in the ouisync-app repository) # +# This tool runs in a sandboxed process that cannot access the network, so it relies on the updater +# companion plugin to download the required dependencies ahead of time. Called by the builder plugin +# which passes both plugins' output paths as arguments. Hic sunt dracones! These may be of interest: # [1] https://forums.developer.apple.com/forums/thread/666335 # [2] https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/Plugins.md#build-tool-target-dependencies # [3] https://www.amyspark.me/blog/posts/2024/01/10/stripping-rust-libraries.html +fatal() { echo "Error $@" && exit $1 } PROJECT_HOME=$(realpath "$(dirname "$0")/../../../../") -PACKAGE_HOME=$(realpath "$PROJECT_HOME/bindings/swift/OuisyncLib") -export CARGO_HOME="$1" +export CARGO_HOME=$(realpath "$1") +export PATH="$CARGO_HOME/bin:$PATH" export RUSTUP_HOME="$CARGO_HOME/.rustup" -BUILD_OUTPUT="$2" +BUILD_OUTPUT=$(realpath "$2") # cargo builds some things that confuse xcode such as fingerprints and depfiles which cannot be # (easily) disabled; additionally, xcode does pick up the xcframework and reports it as a duplicate @@ -29,7 +22,7 @@ BUILD_OUTPUT="$2" mkdir -p "$BUILD_OUTPUT/dummy" # read config and prepare to build -source "$PACKAGE_HOME/config.sh" +source "$PROJECT_HOME/bindings/swift/OuisyncLib/config.sh" if [ $SKIP ] && [ $SKIP -gt 0 ]; then exit 0 fi @@ -49,12 +42,12 @@ for TARGET in $LIST[@]; do TARGETS[$TARGET]="" done # build configured targets cd $PROJECT_HOME for TARGET in ${(k)TARGETS}; do - "$CARGO_HOME/bin/cross" build \ + cross build \ --frozen \ --package ouisync-ffi \ --target $TARGET \ --target-dir "$BUILD_OUTPUT" \ - $FLAGS || exit 1 + $FLAGS || fatal 1 "Unable to compile for $TARGET" done # generate include files @@ -64,11 +57,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 - -# 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 -rm -Rf $BUILD_OUTPUT/output/OuisyncLibFFI.xcframework +cbindgen --lang C --crate ouisync-ffi > "$INCLUDE/bindings.h" || fatal 2 "Unable to generate bindings.h" # xcodebuild refuses multiple architectures per platform, instead expecting fat libraries when the # destination operating system supports multiple architectures; apple also explicitly rejects any @@ -96,11 +85,17 @@ for PLATFORM OUTPUTS in ${(kv)TREE}; do else # at least two architectures; run lipo on all matches and link the output instead LIBRARY="$BUILD_OUTPUT/$PLATFORM/libouisync_ffi.a" mkdir -p "$(dirname "$LIBRARY")" - lipo -create $MATCHED[@] -output $LIBRARY || exit 3 + lipo -create $MATCHED[@] -output $LIBRARY || fatal 3 "Unable to run lipo for ${MATCHED[@]}" fi PARAMS+=("-library" "$LIBRARY" "-headers" "$INCLUDE") done -echo ${PARAMS[@]} + +# TODO: skip xcodebuild and manually create symlinks instead (faster but Info.plist would be tricky) +rm -Rf "$BUILD_OUTPUT/temp.xcframework" +find "$BUILD_OUTPUT/OuisyncLibFFI.xcframework" -mindepth 1 -delete xcodebuild \ -create-xcframework ${PARAMS[@]} \ - -output "$BUILD_OUTPUT/output/OuisyncLibFFI.xcframework" || exit 4 + -output "$BUILD_OUTPUT/temp.xcframework" || fatal 4 "Unable to build xcframework" +for FILE in $(ls "$BUILD_OUTPUT/temp.xcframework"); do + mv "$BUILD_OUTPUT/temp.xcframework/$FILE" "$BUILD_OUTPUT/OuisyncLibFFI.xcframework/$FILE" +done diff --git a/bindings/swift/OuisyncLib/Plugins/update.sh b/bindings/swift/OuisyncLib/Plugins/update.sh index 570fe5859..445ea331c 100755 --- a/bindings/swift/OuisyncLib/Plugins/update.sh +++ b/bindings/swift/OuisyncLib/Plugins/update.sh @@ -1,39 +1,28 @@ #!/usr/bin/env zsh # Command line tool which pulls all dependencies needed to build the rust core library. -# -# Assumes that `cargo` and `rustup` are installed and available in REAL_PATH and it is run with the -# two plugin output paths (update and build) +fatal() { echo "Error $@" >&2 && exit $1 } PROJECT_HOME=$(realpath "$(dirname "$0")/../../../../") -PACKAGE_HOME=$(realpath "$PROJECT_HOME/bindings/swift/OuisyncLib") -export CARGO_HOME="$1" -export CARGO_HTTP_CHECK_REVOKE="false" # unclear why this fails, but it does -export RUSTUP_USE_CURL=1 # https://github.com/rust-lang/rustup/issues/1856 - -# download all possible toolchains: they only take up about 100MiB in total -mkdir -p .rustup +export CARGO_HOME=$(realpath "$1") +export PATH="$CARGO_HOME/bin:$PATH" export RUSTUP_HOME="$CARGO_HOME/.rustup" -rustup default stable -rustup target install aarch64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim \ - x86_64-apple-darwin x86_64-apple-ios - -cd "$PROJECT_HOME" -cargo fetch --locked || exit 1 # this is currently only fixable by moving the plugin location -cargo install cbindgen cross || exit 2 # build.sh also needs `cbindgen` and `cross` -# as part of the updater, we also perform the xcode symlink hack: we replace the existing -# $PACKAGE_HOME/output folder (either stub checked out by git or symlink to a previous build) with -# a link to the $BUILD_OUTPUT/output folder which will eventually contain an actual framework -BUILD_OUTPUT="$2" -mkdir -p "$BUILD_OUTPUT" -cd "$BUILD_OUTPUT" > /dev/null -# if this is the first time we build at this location, generate a new stub library to keep xcode -# happy in case the build process fails later down the line -if ! [ -d "output/OuisyncLibFFI.xcframework" ]; then - "$PACKAGE_HOME/reset-output.sh" +# install rust or update to latest version +export RUSTUP_USE_CURL=1 # https://github.com/rust-lang/rustup/issues/1856 +if [ -f "$CARGO_HOME/bin/rustup" ]; then + rustup update || fatal 1 "Unable to update rust" +else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path \ + || fatal 1 "Unable to install rust" fi -# we can now replace the local stub (or prior link) with a link to the most recent build location -rm -rf "$PACKAGE_HOME/output" -ln -s "$BUILD_OUTPUT/output" "$PACKAGE_HOME/output" +# also install all possible toolchains since they only take up about 100MiB in total +export CARGO_HTTP_CHECK_REVOKE="false" # unclear it fails without this, but it does +rustup target install aarch64-apple-darwin aarch64-apple-ios \ + aarch64-apple-ios-sim x86_64-apple-darwin x86_64-apple-ios || fatal 2 "Unable to install rust via rustup" + +# build.sh needs `cbindgen` and `cross` to build as a multiplatform framework +cargo install cbindgen cross || fatal 3 "Unable to install header generator or cross compiler" -# unfortunately, we can't trigger a build from here because `build.sh` runs in a different sandbox +# fetch all up to date package dependencies for the next build (which must run offline) +cd "$PROJECT_HOME" +cargo fetch --locked || fatal 4 "Unable to fetch library dependencies" diff --git a/bindings/swift/OuisyncLib/config.sh b/bindings/swift/OuisyncLib/config.sh deleted file mode 100644 index 32dc0832a..000000000 --- a/bindings/swift/OuisyncLib/config.sh +++ /dev/null @@ -1,11 +0,0 @@ -# this file is sourced by `build.sh`; keep as many options enabled as you have patience for -SKIP=1 -#DEBUG=1 # set to 0 to generate release builds (much faster) -TARGETS=( - aarch64-apple-darwin # mac on apple silicon - x86_64-apple-darwin # mac on intel - aarch64-apple-ios # all real devices (ios 11+ are 64 bit only) - aarch64-apple-ios-sim # simulators when running on M chips - x86_64-apple-ios # simulator running on intel chips -) -# make sure to re-run "Update rust dependencies" after making changes here diff --git a/bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample b/bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample deleted file mode 100644 index 670c55dbb..000000000 --- a/bindings/swift/OuisyncLib/output/OuisyncLibFFI.xcframework/Info.plist.sample +++ /dev/null @@ -1,13 +0,0 @@ - - - - - AvailableLibraries - - - CFBundlePackageType - XFWK - XCFrameworkFormatVersion - 1.0 - - diff --git a/bindings/swift/OuisyncLib/reset-output.sh b/bindings/swift/OuisyncLib/reset-output.sh deleted file mode 100755 index 2d3489aed..000000000 --- a/bindings/swift/OuisyncLib/reset-output.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env sh -# Command line tool which produces a stub `OuisyncLibFFI` framework in the current directory -# -# Xcode expects a valid binary target to be available at package resolution time at the location -# specified in Package.swift, otherwise it refuses to register any plugins. To work around this -# limitation, we include a gitignored stub in the repository that is then then first replaced by the -# the updater plugin with a link to the same stub in the build folder (the only folder writable -# within the build plugin sandbox), then replaced with a real framework by the build plugin. -# -# While the process seems to work, we may run into edge cases where the framework gets corrupted, -# resulting in the inability to run the updater script that would fix it. If and when that happens, -# run this script to reset the framework to its original stub version from git. -# -echo d - ./output -rm -Rf ./output -# -# Generated by shar $(find output -print) -# -# This is a shell archive. Save it in a file, remove anything before -# this line, and then unpack it by entering "sh file". Note, it may -# create directories; files and directories will be owned by you and -# have default permissions. -# -# This archive contains: -# -# . -# ./output -# ./output/OuisyncLibFFI.xcframework -# ./output/OuisyncLibFFI.xcframework/macos-x86_64 -# ./output/OuisyncLibFFI.xcframework/macos-x86_64/Headers -# ./output/OuisyncLibFFI.xcframework/macos-x86_64/Headers/module.modulemap -# ./output/OuisyncLibFFI.xcframework/macos-x86_64/libouisync_ffi.a -# ./output/OuisyncLibFFI.xcframework/Info.plist -# -echo c - . -mkdir -p . > /dev/null 2>&1 -echo c - ./output -mkdir -p ./output > /dev/null 2>&1 -echo c - ./output/OuisyncLibFFI.xcframework -mkdir -p ./output/OuisyncLibFFI.xcframework > /dev/null 2>&1 -echo c - ./output/OuisyncLibFFI.xcframework/macos-x86_64 -mkdir -p ./output/OuisyncLibFFI.xcframework/macos-x86_64 > /dev/null 2>&1 -echo c - ./output/OuisyncLibFFI.xcframework/macos-x86_64/Headers -mkdir -p ./output/OuisyncLibFFI.xcframework/macos-x86_64/Headers > /dev/null 2>&1 -echo x - ./output/OuisyncLibFFI.xcframework/macos-x86_64/Headers/module.modulemap -sed 's/^X//' >./output/OuisyncLibFFI.xcframework/macos-x86_64/Headers/module.modulemap << '27ba995dcca9d28af9ee52fafa7cdc12' -Xmodule OuisyncLibFFI { -X export * -X} -27ba995dcca9d28af9ee52fafa7cdc12 -echo x - ./output/OuisyncLibFFI.xcframework/macos-x86_64/libouisync_ffi.a -sed 's/^X//' >./output/OuisyncLibFFI.xcframework/macos-x86_64/libouisync_ffi.a << '452e73dffcd1e38b0e076852cbd16868' -452e73dffcd1e38b0e076852cbd16868 -echo x - ./output/OuisyncLibFFI.xcframework/Info.plist -sed 's/^X//' >./output/OuisyncLibFFI.xcframework/Info.plist << '176f576dd1dba006b62db63408ad24c2' -X -X -X -X -X AvailableLibraries -X -X -X BinaryPath -X libouisync_ffi.a -X HeadersPath -X Headers -X LibraryIdentifier -X macos-x86_64 -X LibraryPath -X libouisync_ffi.a -X SupportedArchitectures -X -X x86_64 -X -X SupportedPlatform -X macos -X -X -X CFBundlePackageType -X XFWK -X XCFrameworkFormatVersion -X 1.0 -X -X -176f576dd1dba006b62db63408ad24c2 -exit From a0be495ed504294d551091ddec263a24500cb1d6 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Tue, 14 Jan 2025 23:51:21 +0200 Subject: [PATCH 03/24] Expose LogLevel via FFI This makes cbindgen include the LogLevel enum in the generated headers --- service/src/ffi.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/src/ffi.rs b/service/src/ffi.rs index 2c195e812..813da178d 100644 --- a/service/src/ffi.rs +++ b/service/src/ffi.rs @@ -178,7 +178,7 @@ fn init( #[no_mangle] pub unsafe extern "C" fn log_init( file: *const c_char, - callback: Option, + callback: Option, tag: *const c_char, ) -> ErrorCode { try_log_init(file, callback, tag).to_error_code() @@ -188,7 +188,7 @@ static LOGGER: OnceLock = OnceLock::new(); unsafe fn try_log_init( file: *const c_char, - callback: Option, + callback: Option, tag: *const c_char, ) -> Result<(), Error> { let file = if file.is_null() { From 70c7380632b09976efa1d54634d29bbe20bffb75 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 15 Jan 2025 00:17:57 +0200 Subject: [PATCH 04/24] Move logInit to before Server.start --- bindings/dart/lib/ouisync.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bindings/dart/lib/ouisync.dart b/bindings/dart/lib/ouisync.dart index 5c9e50e94..01eadfe76 100644 --- a/bindings/dart/lib/ouisync.dart +++ b/bindings/dart/lib/ouisync.dart @@ -56,11 +56,11 @@ class Session { // that one instead. If we do spawn, we are responsible for logging if (startServer) { try { + logInit(callback: logger, tag: 'Server'); server = await Server.start( configPath: configPath, debugLabel: debugLabel, ); - logInit(callback: logger, tag: 'Server'); } on ServiceAlreadyRunning catch (_) { debugPrint('Service already started'); } From 900150d390e0c35ca3568ff263e43614b39da2a1 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 15 Jan 2025 00:20:22 +0200 Subject: [PATCH 05/24] Remove old swift bindings --- .../OuisyncLib/Sources/OuisyncClient.swift | 82 ---- .../OuisyncLib/Sources/OuisyncEntry.swift | 166 -------- .../OuisyncLib/Sources/OuisyncError.swift | 84 ---- .../swift/OuisyncLib/Sources/OuisyncFFI.swift | 100 ----- .../OuisyncLib/Sources/OuisyncFile.swift | 42 -- .../swift/OuisyncLib/Sources/OuisyncLib.swift | 11 - .../OuisyncLib/Sources/OuisyncMessage.swift | 399 ------------------ .../Sources/OuisyncRepository.swift | 79 ---- .../OuisyncLib/Sources/OuisyncSession.swift | 186 -------- 9 files changed, 1149 deletions(-) delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncClient.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncError.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncFile.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncLib.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/OuisyncSession.swift diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift b/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift deleted file mode 100644 index dcbea9136..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// File.swift -// -// -// Created by Peter Jankuliak on 23/07/2024. -// - -import Foundation -import OuisyncLibFFI - - -fileprivate func OnReceiveFromBackend(context: FFIContext?, - dataPointer: UnsafePointer?, - size: UInt64) -> Void { - 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)!))) -} - - -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)), - OnReceiveFromBackend) - if result.error_code != 0 { - throw SessionCreateError("Failed to create session, code:\(result.error_code), message:\(result.error_message!)") - } - - client.clientHandle = result.session - return client - } - - fileprivate init(_ clientHandle: SessionHandle, _ ffi: OuisyncFFI) { - self.clientHandle = clientHandle - self.ffi = ffi - } - - public func sendToBackend(_ data: [UInt8]) { - let count = data.count; - data.withUnsafeBufferPointer({ maybePointer in - if let pointer = maybePointer.baseAddress { - ffi.ffiSessionChannelSend(clientHandle, .init(mutating: pointer), UInt64(count)) - } - }) - } - - public func close() async { - typealias Continuation = CheckedContinuation - - class Context { - let clientHandle: SessionHandle - let continuation: Continuation - init(_ clientHandle: SessionHandle, _ continuation: Continuation) { - self.clientHandle = clientHandle - self.continuation = continuation - } - } - - await withCheckedContinuation(function: "FFI.closeSession", { continuation in - let context = OuisyncFFI.toRetainedPtr(obj: Context(clientHandle, continuation)) - let callback: FFICallback = { context, dataPointer, size in - let context: Context = OuisyncFFI.fromRetainedPtr(ptr: context!) - context.continuation.resume() - } - ffi.ffiSessionClose(clientHandle, .init(mutating: context), callback) - }) - } -} - -public typealias OuisyncOnReceiveFromBackend = ([UInt8]) -> Void diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift b/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift deleted file mode 100644 index 1bbd29827..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift +++ /dev/null @@ -1,166 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System - -public enum OuisyncEntryType { - case file - case directory -} - -public enum OuisyncEntry { - case file(OuisyncFileEntry) - case directory(OuisyncDirectoryEntry) - - public func name() -> String { - switch self { - case .file(let e): return e.name() - case .directory(let e): return e.name() - } - } - - public func type() -> OuisyncEntryType { - switch self { - case .file: return .file - case .directory: return .directory - } - } - - public func parent() -> OuisyncEntry? { - switch self { - case .file(let file): return .directory(file.parent()) - case .directory(let directory): - guard let parent = directory.parent() else { - return nil - } - return .directory(parent) - } - } -} - -public class OuisyncFileEntry { - public let path: FilePath - public let repository: OuisyncRepository - - public init(_ path: FilePath, _ repository: OuisyncRepository) { - self.path = path - self.repository = repository - } - - public func parent() -> OuisyncDirectoryEntry { - return OuisyncDirectoryEntry(Self.parent(path), repository) - } - - public func name() -> String { - return Self.name(path) - } - - public static func name(_ path: FilePath) -> String { - return path.lastComponent!.string - } - - public func exists() async throws -> Bool { - return try await repository.session.sendRequest(.fileExists(repository.handle, path)).toBool() - } - - public func delete() async throws { - try await repository.deleteFile(path) - } - - public func getVersionHash() async throws -> Data { - try await repository.getEntryVersionHash(path) - } - - public static func parent(_ path: FilePath) -> FilePath { - var parentPath = path - parentPath.components.removeLast() - return parentPath - } - - public func open() async throws -> OuisyncFile { - try await repository.openFile(path) - } - - public func create() async throws -> OuisyncFile { - try await repository.createFile(path) - } -} - -public class OuisyncDirectoryEntry: CustomDebugStringConvertible { - public let repository: OuisyncRepository - public let path: FilePath - - public init(_ path: FilePath, _ repository: OuisyncRepository) { - self.repository = repository - self.path = path - } - - public func name() -> String { - return OuisyncDirectoryEntry.name(path) - } - - public static func name(_ path: FilePath) -> String { - if let c = path.lastComponent { - return c.string - } - return "/" - } - - public func listEntries() async throws -> [OuisyncEntry] { - let response = try await repository.session.sendRequest(OuisyncRequest.listEntries(repository.handle, path)) - let entries = response.value.arrayValue! - return entries.map({entry in - let name: String = entry[0]!.stringValue! - let typeNum = entry[1]!.uint8Value! - - switch typeNum { - case 1: return .file(OuisyncFileEntry(path.appending(name), repository)) - case 2: return .directory(OuisyncDirectoryEntry(path.appending(name), repository)) - default: - fatalError("Invalid EntryType returned from OuisyncLib \(typeNum)") - } - }) - } - - public func isRoot() -> Bool { - return path.components.isEmpty - } - - public func parent() -> OuisyncDirectoryEntry? { - guard let parentPath = OuisyncDirectoryEntry.parent(path) else { - return nil - } - return OuisyncDirectoryEntry(parentPath, repository) - } - - public func exists() async throws -> Bool { - let response = try await repository.session.sendRequest(OuisyncRequest.directoryExists(repository.handle, path)) - return response.value.boolValue! - } - - public func delete(recursive: Bool) async throws { - try await repository.deleteDirectory(path, recursive: recursive) - } - - public func getVersionHash() async throws -> Data { - try await repository.getEntryVersionHash(path) - } - - public static func parent(_ path: FilePath) -> FilePath? { - if path.components.isEmpty { - return nil - } else { - var parentPath = path - parentPath.components.removeLast() - return parentPath - } - } - - public var debugDescription: String { - return "OuisyncDirectory(\(path), \(repository))" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncError.swift b/bindings/swift/OuisyncLib/Sources/OuisyncError.swift deleted file mode 100644 index 1061c8182..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncError.swift +++ /dev/null @@ -1,84 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation - -public enum OuisyncErrorCode: UInt16 { - /// Store error - case Store = 1 - /// Insuficient permission to perform the intended operation - case PermissionDenied = 2 - /// Malformed data - case MalformedData = 3 - /// Entry already exists - case EntryExists = 4 - /// Entry doesn't exist - case EntryNotFound = 5 - /// Multiple matching entries found - case AmbiguousEntry = 6 - /// The intended operation requires the directory to be empty but it isn't - case DirectoryNotEmpty = 7 - /// The indended operation is not supported - case OperationNotSupported = 8 - /// Failed to read from or write into the config file - case Config = 10 - /// Argument passed to a function is not valid - case InvalidArgument = 11 - /// Request or response is malformed - case MalformedMessage = 12 - /// Storage format version mismatch - case StorageVersionMismatch = 13 - /// Connection lost - case ConnectionLost = 14 - /// Invalid handle to a resource (e.g., Repository, File, ...) - case InvalidHandle = 15 - /// Entry has been changed and no longer matches the expected value - case EntryChanged = 16 - - // These can't happen and apple devices - // case VfsInvalidMountPoint = 2048 - // case VfsDriverInstall = 2049 - // case VfsBackend = 2050 - - /// Unspecified error - case Other = 65535 -} - -public class OuisyncError : Error, CustomDebugStringConvertible { - public let code: OuisyncErrorCode - public let message: String - - init(_ code: OuisyncErrorCode, _ message: String) { - self.code = code - self.message = message - } - - public var debugDescription: String { - let codeStr: String - - switch code { - case .Store: codeStr = "Store error" - case .PermissionDenied: codeStr = "Insuficient permission to perform the intended operation" - case .MalformedData: codeStr = "Malformed data" - case .EntryExists: codeStr = "Entry already exists" - case .EntryNotFound: codeStr = "Entry doesn't exist" - case .AmbiguousEntry: codeStr = "Multiple matching entries found" - case .DirectoryNotEmpty: codeStr = "The intended operation requires the directory to be empty but it isn't" - case .OperationNotSupported: codeStr = "The indended operation is not supported" - case .Config: codeStr = "Failed to read from or write into the config file" - case .InvalidArgument: codeStr = "Argument passed to a function is not valid" - case .MalformedMessage: codeStr = "Request or response is malformed" - case .StorageVersionMismatch: codeStr = "Storage format version mismatch" - case .ConnectionLost: codeStr = "Connection lost" - case .InvalidHandle: codeStr = "Invalid handle to a resource (e.g., Repository, File, ...)" - case .EntryChanged: codeStr = "Entry has been changed and no longer matches the expected value" - - case .Other: codeStr = "Unspecified error" - } - - return "OuisyncError(code:\(code), codeStr:\"\(codeStr)\", message:\"\(message)\")" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift b/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift deleted file mode 100644 index 36eb00ad0..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// File.swift -// -// -// Created by Peter Jankuliak on 19/07/2024. -// - -import Foundation -import OuisyncLibFFI - - -/* TODO: ⬇️ - - Since we're now linking statically and both rust-cbindgen and swift do a reasonable job at guessing - the intended types, I don't expect these types to ever make it to the main branch because this file - will most likely go away. For now they are kept to avoid touching too much code in a single commit. - */ -typealias FFISessionKind = UInt8 // swift gets confused here and imports a UInt32 enum as well as a UInt8 typealias -typealias FFIContext = UnsafeMutableRawPointer // exported as `* mut ()` in rust so this is correct, annoyingly -typealias FFICallback = @convention(c) (FFIContext?, UnsafePointer?, UInt64) -> Void; -typealias FFISessionCreate = @convention(c) (FFISessionKind, UnsafePointer?, UnsafePointer?, UnsafePointer?, FFIContext?, FFICallback?) -> SessionCreateResult; -typealias FFISessionGrab = @convention(c) (FFIContext?, FFICallback?) -> SessionCreateResult; -typealias FFISessionClose = @convention(c) (SessionHandle, FFIContext?, FFICallback?) -> Void; -typealias FFISessionChannelSend = @convention(c) (SessionHandle, UnsafeMutablePointer?, UInt64) -> Void; - -class SessionCreateError : Error, CustomStringConvertible { - let message: String - init(_ message: String) { self.message = message } - var description: String { message } -} - -public class OuisyncFFI { - // let handle: UnsafeMutableRawPointer - let ffiSessionGrab: FFISessionGrab - let ffiSessionCreate: FFISessionCreate - let ffiSessionChannelSend: FFISessionChannelSend - let ffiSessionClose: FFISessionClose - let sessionKindShared: FFISessionKind = 0; - - public init() { - // The .dylib is created using the OuisyncDyLibBuilder package plugin in this Swift package. - // let libraryName = "libouisync_ffi.dylib" - // let resourcePath = Bundle.main.resourcePath! + "/OuisyncLib_OuisyncLibFFI.bundle/Contents/Resources" - // handle = dlopen("\(resourcePath)/\(libraryName)", RTLD_NOW)! - - ffiSessionGrab = session_grab - ffiSessionChannelSend = session_channel_send - ffiSessionClose = session_close - ffiSessionCreate = session_create - - //ffiSessionGrab = unsafeBitCast(dlsym(handle, "session_grab"), to: FFISessionGrab.self) - //ffiSessionChannelSend = unsafeBitCast(dlsym(handle, "session_channel_send"), to: FFISessionChannelSend.self) - //ffiSessionClose = unsafeBitCast(dlsym(handle, "session_close"), to: FFISessionClose.self) - //ffiSessionCreate = unsafeBitCast(dlsym(handle, "session_create"), to: FFISessionCreate.self) - } - - // Blocks until Dart creates a session, then returns it. - func waitForSession(_ context: FFIContext, _ callback: FFICallback) async throws -> SessionHandle { - // TODO: Might be worth change the ffi function to call a callback when the session becomes created instead of bussy sleeping. - var elapsed: UInt64 = 0; - while true { - let result = ffiSessionGrab(context, callback) - if result.error_code == 0 { - NSLog("😀 Got Ouisync session"); - return result.session - } - NSLog("🤨 Ouisync session not yet ready. Code: \(result.error_code) Message:\(String(cString: result.error_message!))"); - - let millisecond: UInt64 = 1_000_000 - let second: UInt64 = 1000 * millisecond - - var timeout = 200 * millisecond - - if elapsed > 10 * second { - timeout = second - } - - try await Task.sleep(nanoseconds: timeout) - elapsed += timeout; - } - } - - // Retained pointers have their reference counter incremented by 1. - // https://stackoverflow.com/a/33310021/273348 - static func toUnretainedPtr(obj : T) -> UnsafeRawPointer { - return UnsafeRawPointer(Unmanaged.passUnretained(obj).toOpaque()) - } - - static func fromUnretainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeUnretainedValue() - } - - static func toRetainedPtr(obj : T) -> UnsafeRawPointer { - return UnsafeRawPointer(Unmanaged.passRetained(obj).toOpaque()) - } - - static func fromRetainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeRetainedValue() - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift b/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift deleted file mode 100644 index 995a40705..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System - -public class OuisyncFile { - public let repository: OuisyncRepository - let handle: FileHandle - - init(_ handle: FileHandle, _ repository: OuisyncRepository) { - self.repository = repository - self.handle = handle - } - - public func read(_ offset: UInt64, _ length: UInt64) async throws -> Data { - try await session.sendRequest(.fileRead(handle, offset, length)).toData() - } - - public func write(_ offset: UInt64, _ data: Data) async throws { - let _ = try await session.sendRequest(.fileWrite(handle, offset, data)) - } - - public func size() async throws -> UInt64 { - try await session.sendRequest(.fileLen(handle)).toUInt64() - } - - public func truncate(_ len: UInt64) async throws { - let _ = try await session.sendRequest(.fileTruncate(handle, len)) - } - - public func close() async throws { - let _ = try await session.sendRequest(.fileClose(handle)) - } - - var session: OuisyncSession { - repository.session - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift b/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift deleted file mode 100644 index abed67ff0..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift +++ /dev/null @@ -1,11 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation - -public typealias MessageId = UInt64 -public typealias RepositoryHandle = UInt64 -public typealias FileHandle = UInt64 diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift b/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift deleted file mode 100644 index 6aa8f45ef..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift +++ /dev/null @@ -1,399 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import MessagePack -import System - -//-------------------------------------------------------------------- - -public class OuisyncRequest { - let functionName: String - let functionArguments: MessagePackValue - - init(_ functionName: String, _ functionArguments: MessagePackValue) { - self.functionName = functionName - self.functionArguments = functionArguments - } - - public static func listRepositories() -> OuisyncRequest { - return OuisyncRequest("list_repositories", MessagePackValue.nil) - } - - public static func subscribeToRepositoryListChange() -> OuisyncRequest { - return OuisyncRequest("list_repositories_subscribe", MessagePackValue.nil) - } - - public static func subscribeToRepositoryChange(_ handle: RepositoryHandle) -> OuisyncRequest { - return OuisyncRequest("repository_subscribe", MessagePackValue(handle)) - } - - public static func getRepositoryName(_ handle: RepositoryHandle) -> OuisyncRequest { - return OuisyncRequest("repository_name", MessagePackValue(handle)) - } - - public static func repositoryMoveEntry(_ repoHandle: RepositoryHandle, _ srcPath: FilePath, _ dstPath: FilePath) -> OuisyncRequest { - return OuisyncRequest("repository_move_entry", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("src"): MessagePackValue(srcPath.description), - MessagePackValue("dst"): MessagePackValue(dstPath.description), - ])) - } - - public static func listEntries(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_open", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func getEntryVersionHash(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("repository_entry_version_hash", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func directoryExists(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_exists", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func directoryRemove(_ handle: RepositoryHandle, _ path: FilePath, _ recursive: Bool) -> OuisyncRequest { - return OuisyncRequest("directory_remove", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - MessagePackValue("recursive"): MessagePackValue(recursive), - ])) - } - - public static func directoryCreate(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_create", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileOpen(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_open", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileExists(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_exists", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileRemove(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_remove", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileClose(_ fileHandle: FileHandle) -> OuisyncRequest { - return OuisyncRequest("file_close", MessagePackValue(fileHandle)) - } - - public static func fileRead(_ fileHandle: FileHandle, _ offset: UInt64, _ len: UInt64) -> OuisyncRequest { - return OuisyncRequest("file_read", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("offset"): MessagePackValue(offset), - MessagePackValue("len"): MessagePackValue(len), - ])) - } - - public static func fileTruncate(_ fileHandle: FileHandle, _ len: UInt64) -> OuisyncRequest { - return OuisyncRequest("file_truncate", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("len"): MessagePackValue(len), - ])) - } - - public static func fileLen(_ fileHandle: FileHandle) -> OuisyncRequest { - return OuisyncRequest("file_len", MessagePackValue(fileHandle)) - } - - public static func fileCreate(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_create", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileWrite(_ fileHandle: FileHandle, _ offset: UInt64, _ data: Data) -> OuisyncRequest { - return OuisyncRequest("file_write", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("offset"): MessagePackValue(offset), - MessagePackValue("data"): MessagePackValue(data), - ])) - } -} - -//-------------------------------------------------------------------- - -public class OuisyncRequestMessage { - public let messageId: MessageId - public let request: OuisyncRequest - - init(_ messageId: MessageId, _ request: OuisyncRequest) { - self.messageId = messageId - self.request = request - } - - public func serialize() -> [UInt8] { - var message: [UInt8] = [] - message.append(contentsOf: withUnsafeBytes(of: messageId.bigEndian, Array.init)) - let payload = [MessagePackValue.string(request.functionName): request.functionArguments] - message.append(contentsOf: pack(MessagePackValue.map(payload))) - return message - } - - public static func deserialize(_ data: [UInt8]) -> OuisyncRequestMessage? { - guard let (id, data) = readMessageId(data) else { - return nil - } - - let unpacked = (try? unpack(data))?.0 - - guard case let .map(m) = unpacked else { return nil } - if m.count != 1 { return nil } - guard let e = m.first else { return nil } - guard let functionName = e.key.stringValue else { return nil } - let functionArguments = e.value - - return OuisyncRequestMessage(id, OuisyncRequest(functionName, functionArguments)) - } -} - -public class OuisyncResponseMessage { - public let messageId: MessageId - public let payload: OuisyncResponsePayload - - public init(_ messageId: MessageId, _ payload: OuisyncResponsePayload) { - self.messageId = messageId - self.payload = payload - } - - public func serialize() -> [UInt8] { - var message: [UInt8] = [] - message.append(contentsOf: withUnsafeBytes(of: messageId.bigEndian, Array.init)) - let body: MessagePackValue; - switch payload { - case .response(let response): - body = MessagePackValue.map(["success": Self.responseValue(response.value)]) - case .notification(let notification): - body = MessagePackValue.map(["notification": notification.value]) - case .error(let error): - let code = Int64(exactly: error.code.rawValue)! - body = MessagePackValue.map(["failure": .array([.int(code), .string(error.message)])]) - } - message.append(contentsOf: pack(body)) - return message - } - - static func responseValue(_ value: MessagePackValue) -> MessagePackValue { - switch value { - case .nil: return .string("none") - default: - // The flutter code doesn't read the key which is supposed to be a type, - // would still be nice to have a proper mapping. - return .map(["todo-type": value]) - } - } - - public static func deserialize(_ bytes: [UInt8]) -> OuisyncResponseMessage? { - guard let (id, data) = readMessageId(bytes) else { - return nil - } - - let unpacked = (try? unpack(Data(data)))?.0 - - if case let .map(m) = unpacked { - if let success = m[.string("success")] { - if let value = parseResponse(success) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.response(value)) - } - } else if let error = m[.string("failure")] { - if let response = parseFailure(error) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.error(response)) - } - } else if let notification = m[.string("notification")] { - if let value = parseNotification(notification) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.notification(value)) - } - } - } - - return nil - } -} - -extension OuisyncResponseMessage: CustomStringConvertible { - public var description: String { - return "IncomingMessage(\(messageId), \(payload))" - } -} - -fileprivate func readMessageId(_ data: [UInt8]) -> (MessageId, Data)? { - let idByteCount = (MessageId.bitWidth / UInt8.bitWidth) - - if data.count < idByteCount { - return nil - } - - let bigEndianValue = data.withUnsafeBufferPointer { - ($0.baseAddress!.withMemoryRebound(to: MessageId.self, capacity: 1) { $0 }) - }.pointee - - let id = MessageId(bigEndian: bigEndianValue) - - return (id, Data(data[idByteCount...])) -} -//-------------------------------------------------------------------- - -public enum OuisyncResponsePayload { - case response(Response) - case notification(OuisyncNotification) - case error(OuisyncError) -} - -extension OuisyncResponsePayload: CustomStringConvertible { - public var description: String { - switch self { - case .response(let response): - return "response(\(response))" - case .notification(let notification): - return "notification(\(notification))" - case .error(let error): - return "error(\(error))" - } - } -} - -//-------------------------------------------------------------------- - -public enum IncomingSuccessPayload { - case response(Response) - case notification(OuisyncNotification) -} - -extension IncomingSuccessPayload: CustomStringConvertible { - public var description: String { - switch self { - case .response(let value): - return "response(\(value))" - case .notification(let value): - return "notificateion(\(value))" - } - } -} - -//-------------------------------------------------------------------- - -public class Response { - public let value: MessagePackValue - - // Note about unwraps in these methods. It is expected that the - // caller knows what type the response is. If the expected and - // the actual types differ, then it is likely that there is a - // mismatch between the front end and the backend in the FFI API. - - public init(_ value: MessagePackValue) { - self.value = value - } - - public func toData() -> Data { - return value.dataValue! - } - - public func toUInt64Array() -> [UInt64] { - return value.arrayValue!.map({ $0.uint64Value! }) - } - - public func toUInt64() -> UInt64 { - return value.uint64Value! - } - - public func toBool() -> Bool { - return value.boolValue! - } -} - -extension Response: CustomStringConvertible { - public var description: String { - return "Response(\(value))" - } -} - -//-------------------------------------------------------------------- - -public class OuisyncNotification { - let value: MessagePackValue - init(_ value: MessagePackValue) { - self.value = value - } -} - -extension OuisyncNotification: CustomStringConvertible { - public var description: String { - return "Notification(\(value))" - } -} - -//-------------------------------------------------------------------- - -func parseResponse(_ value: MessagePackValue) -> Response? { - if case let .map(m) = value { - if m.count != 1 { - return nil - } - return Response(m.first!.value) - } else if case let .string(str) = value, str == "none" { - // A function was called which has a `void` return value. - return Response(.nil) - } - return nil -} - -func parseFailure(_ value: MessagePackValue) -> OuisyncError? { - if case let .array(arr) = value { - if arr.count != 2 { - return nil - } - if case let .uint(code) = arr[0] { - if case let .string(message) = arr[1] { - guard let codeU16 = UInt16(exactly: code) else { - fatalError("Error code from backend is out of range") - } - guard let codeEnum = OuisyncErrorCode(rawValue: codeU16) else { - fatalError("Invalid error code from backend") - } - return OuisyncError(codeEnum, message) - } - } - } - return nil -} - -func parseNotification(_ value: MessagePackValue) -> OuisyncNotification? { - if case .string(_) = value { - return OuisyncNotification(MessagePackValue.nil) - } - if case let .map(m) = value { - if m.count != 1 { - return nil - } - return OuisyncNotification(m.first!.value) - } - return nil -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift b/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift deleted file mode 100644 index b2cfd6632..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift +++ /dev/null @@ -1,79 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System -import MessagePack - -public class OuisyncRepository: Hashable, CustomDebugStringConvertible { - let session: OuisyncSession - public let handle: RepositoryHandle - - public init(_ handle: RepositoryHandle, _ session: OuisyncSession) { - self.handle = handle - self.session = session - } - - public func getName() async throws -> String { - let data = try await session.sendRequest(.getRepositoryName(handle)).toData() - return String(decoding: data, as: UTF8.self) - } - - public func fileEntry(_ path: FilePath) -> OuisyncFileEntry { - OuisyncFileEntry(path, self) - } - - public func getEntryVersionHash(_ path: FilePath) async throws -> Data { - try await session.sendRequest(.getEntryVersionHash(handle, path)).toData() - } - - public func directoryEntry(_ path: FilePath) -> OuisyncDirectoryEntry { - OuisyncDirectoryEntry(path, self) - } - - public func getRootDirectory() -> OuisyncDirectoryEntry { - return OuisyncDirectoryEntry(FilePath("/"), self) - } - - public func createFile(_ path: FilePath) async throws -> OuisyncFile { - let handle = try await session.sendRequest(.fileCreate(handle, path)).toUInt64() - return OuisyncFile(handle, self) - } - - public func openFile(_ path: FilePath) async throws -> OuisyncFile { - let handle = try await session.sendRequest(.fileOpen(handle, path)).toUInt64() - return OuisyncFile(handle, self) - } - - public func deleteFile(_ path: FilePath) async throws { - let _ = try await session.sendRequest(.fileRemove(handle, path)) - } - - public func createDirectory(_ path: FilePath) async throws { - let _ = try await session.sendRequest(.directoryCreate(handle, path)) - } - - public func deleteDirectory(_ path: FilePath, recursive: Bool) async throws { - let _ = try await session.sendRequest(.directoryRemove(handle, path, recursive)) - } - - public func moveEntry(_ sourcePath: FilePath, _ destinationPath: FilePath) async throws { - let _ = try await session.sendRequest(.repositoryMoveEntry(handle, sourcePath, destinationPath)) - } - - public static func == (lhs: OuisyncRepository, rhs: OuisyncRepository) -> Bool { - return lhs.session === rhs.session && lhs.handle == rhs.handle - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(session)) - hasher.combine(handle) - } - - public var debugDescription: String { - return "OuisyncRepository(handle: \(handle))" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift b/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift deleted file mode 100644 index 9944be5d5..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift +++ /dev/null @@ -1,186 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import MessagePack - -public class OuisyncSession { - let configsPath: String - let logsPath: String - - public let client: OuisyncClient - - var nextMessageId: MessageId = 0 - var pendingResponses: [MessageId: CheckedContinuation] = [:] - var notificationSubscriptions: NotificationStream.State = NotificationStream.State() - - public init(_ configsPath: String, _ logsPath: String, _ ffi: OuisyncFFI) throws { - self.configsPath = configsPath - self.logsPath = logsPath - - client = try OuisyncClient.create(configsPath, logsPath, ffi) - client.onReceiveFromBackend = { [weak self] data in - self?.onReceiveDataFromOuisyncLib(data) - } - } - - public func connectNewClient() throws -> OuisyncClient { - return try OuisyncClient.create(configsPath, logsPath, client.ffi) - } - - // Can be called from a separate thread. - public func invoke(_ requestMsg: OuisyncRequestMessage) async -> OuisyncResponseMessage { - let responsePayload: OuisyncResponsePayload - - do { - responsePayload = .response(try await sendRequest(requestMsg.request)) - } catch let e as OuisyncError { - responsePayload = .error(e) - } catch let e { - fatalError("Unhandled exception in OuisyncSession.invoke: \(e)") - } - - return OuisyncResponseMessage(requestMsg.messageId, responsePayload) - } - - public func listRepositories() async throws -> [OuisyncRepository] { - let response = try await sendRequest(OuisyncRequest.listRepositories()); - let handles = response.toUInt64Array() - return handles.map({ OuisyncRepository($0, self) }) - } - - public func subscribeToRepositoryListChange() async throws -> NotificationStream { - let subscriptionId = try await sendRequest(OuisyncRequest.subscribeToRepositoryListChange()).toUInt64(); - return NotificationStream(subscriptionId, notificationSubscriptions) - } - - public func subscribeToRepositoryChange(_ repo: RepositoryHandle) async throws -> NotificationStream { - let subscriptionId = try await sendRequest(OuisyncRequest.subscribeToRepositoryChange(repo)).toUInt64(); - return NotificationStream(subscriptionId, notificationSubscriptions) - } - - // Can be called from a separate thread. - internal func sendRequest(_ request: OuisyncRequest) async throws -> Response { - let messageId = generateMessageId() - - async let onResponse = withCheckedThrowingContinuation { [weak self] continuation in - guard let session = self else { return } - - synchronized(session) { - session.pendingResponses[messageId] = continuation - let data = OuisyncRequestMessage(messageId, request).serialize() - session.client.sendToBackend(data) - } - } - - return try await onResponse - } - - // Can be called from a separate thread. - fileprivate func generateMessageId() -> MessageId { - synchronized(self) { - let messageId = nextMessageId - nextMessageId += 1 - return messageId - } - } - - // Use this function to pass data from the backend. - // It may be called from a separate thread. - public func onReceiveDataFromOuisyncLib(_ data: [UInt8]) { - let maybe_message = OuisyncResponseMessage.deserialize(data) - - guard let message = maybe_message else { - let hex = data.map({String(format:"%02x", $0)}).joined(separator: ",") - // Likely cause is a version mismatch between the backend (Rust) and frontend (Swift) code. - fatalError("Failed to parse incoming message from OuisyncLib [\(hex)]") - } - - switch message.payload { - case .response(let response): - handleResponse(message.messageId, response) - case .notification(let notification): - handleNotification(message.messageId, notification) - case .error(let error): - handleError(message.messageId, error) - } - } - - fileprivate func handleResponse(_ messageId: MessageId, _ response: Response) { - let maybePendingResponse = synchronized(self) { pendingResponses.removeValue(forKey: messageId) } - - guard let pendingResponse = maybePendingResponse else { - fatalError("❗ Failed to match response to a request. messageId:\(messageId), repsponse:\(response) ") - } - - pendingResponse.resume(returning: response) - } - - fileprivate func handleNotification(_ messageId: MessageId, _ response: OuisyncNotification) { - let maybeTx = synchronized(self) { notificationSubscriptions.registrations[messageId] } - - if let tx = maybeTx { - tx.yield(()) - } else { - NSLog("❗ Received unsolicited notification") - } - } - - fileprivate func handleError(_ messageId: MessageId, _ response: OuisyncError) { - let maybePendingResponse = synchronized(self) { pendingResponses.removeValue(forKey: messageId) } - - guard let pendingResponse = maybePendingResponse else { - fatalError("❗ Failed to match error response to a request. messageId:\(messageId), response:\(response)") - } - - pendingResponse.resume(throwing: response) - } - -} - -fileprivate func synchronized(_ lock: AnyObject, _ closure: () throws -> T) rethrows -> T { - objc_sync_enter(lock) - defer { objc_sync_exit(lock) } - return try closure() -} - -public class NotificationStream { - typealias Id = UInt64 - typealias Rx = AsyncStream<()> - typealias RxIter = Rx.AsyncIterator - typealias Tx = Rx.Continuation - - class State { - var registrations: [Id: Tx] = [:] - } - - let subscriptionId: Id - let rx: Rx - var rx_iter: RxIter - var state: State - - init(_ subscriptionId: Id, _ state: State) { - self.subscriptionId = subscriptionId - var tx: Tx! - rx = Rx (bufferingPolicy: Tx.BufferingPolicy.bufferingOldest(1), { tx = $0 }) - self.rx_iter = rx.makeAsyncIterator() - - self.state = state - - state.registrations[subscriptionId] = tx - } - - public func next() async -> ()? { - return await rx_iter.next() - } - - deinit { - // TODO: We should have a `close() async` function where we unsubscripbe - // from the notifications. - state.registrations.removeValue(forKey: subscriptionId) - } -} - From 58d68ccf650a54b1ee51dfd779538990d234c172 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 15 Jan 2025 00:21:12 +0200 Subject: [PATCH 06/24] Add ouisync-service ffi bindings --- bindings/swift/OuisyncLib/Plugins/build.sh | 7 +- .../swift/OuisyncLib/Sources/Service.swift | 65 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 bindings/swift/OuisyncLib/Sources/Service.swift diff --git a/bindings/swift/OuisyncLib/Plugins/build.sh b/bindings/swift/OuisyncLib/Plugins/build.sh index 1e616d649..e8eead9e9 100755 --- a/bindings/swift/OuisyncLib/Plugins/build.sh +++ b/bindings/swift/OuisyncLib/Plugins/build.sh @@ -13,6 +13,7 @@ PROJECT_HOME=$(realpath "$(dirname "$0")/../../../../") export CARGO_HOME=$(realpath "$1") export PATH="$CARGO_HOME/bin:$PATH" export RUSTUP_HOME="$CARGO_HOME/.rustup" +export MACOSX_DEPLOYMENT_TARGET=13.0 BUILD_OUTPUT=$(realpath "$2") # cargo builds some things that confuse xcode such as fingerprints and depfiles which cannot be @@ -44,7 +45,7 @@ cd $PROJECT_HOME for TARGET in ${(k)TARGETS}; do cross build \ --frozen \ - --package ouisync-ffi \ + --package ouisync-service \ --target $TARGET \ --target-dir "$BUILD_OUTPUT" \ $FLAGS || fatal 1 "Unable to compile for $TARGET" @@ -57,7 +58,9 @@ echo "module OuisyncLibFFI { header \"bindings.h\" export * }" > "$INCLUDE/module.modulemap" -cbindgen --lang C --crate ouisync-ffi > "$INCLUDE/bindings.h" || fatal 2 "Unable to generate bindings.h" +cbindgen --lang C --crate ouisync-service > "$INCLUDE/bindings.h" || fatal 2 "Unable to generate bindings.h" +# hack for autoimporting enums https://stackoverflow.com/questions/60559599/swift-c-api-enum-in-swift +perl -i -p0e 's/enum\s+(\w+)([^}]+});\ntypedef (\w+) \1/typedef enum __attribute__\(\(enum_extensibility\(open\)\)\) : \3\2 \1/sg' "$INCLUDE/bindings.h" # xcodebuild refuses multiple architectures per platform, instead expecting fat libraries when the # destination operating system supports multiple architectures; apple also explicitly rejects any diff --git a/bindings/swift/OuisyncLib/Sources/Service.swift b/bindings/swift/OuisyncLib/Sources/Service.swift new file mode 100644 index 000000000..17aae792d --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Service.swift @@ -0,0 +1,65 @@ +import OuisyncLibFFI + +extension ErrorCode: Swift.Error {} +public typealias Error = ErrorCode + +public class Service { + // An opaque handle which must be passed to service_stop in order to terminate the service. + private var handle: UnsafeMutableRawPointer?; + + /* Starts a Ouisync service in a new thread and binds it to the port set in `configDir`. + * + * Returns after the service has been initialized successfully and is ready to accept client + * connections, or throws a `Ouisync.Error` indicating what went wrong. + * + * On success, the service remains active until `.stop()` is called. An attempt will be made to + * stop the service once all references are dropped, however this is strongly discouraged as in + * this case it's not possible to determine whether the shutdown was successful. */ + public init(configDir: String, debugLabel: String) async throws { + handle = try await withUnsafeThrowingContinuation { + service_start(configDir, debugLabel, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) + } + } + + deinit { + guard let handle else { return } + service_stop(handle, Ignore, nil) + } + + /* Stops a running Ouisync service. + * + * Returns after the service shutdown has been completed or throws a `Ouisync.Error` on failure. + * Returns immediately when called a second time, but doing so is not thread safe! */ + public func stop() async throws { + guard let handle else { return } + self.handle = nil + try await withUnsafeThrowingContinuation { + service_stop(handle, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) + } as Void + } + + /* Initialize logging to stdout. Should be called before `service_start`. + * + * If `filename` is not null, additionally logs to that file. + * If `handler` is not null, it is called for every message. + * + * Throws a `Ouisync.Error` on failure. Should not be called more than once per process! + */ + public static func configureLogging(filename: String? = nil, + handler: LogHandler? = nil, + tag: String = "Server") throws { + let err = log_init(filename, handler, tag) + if err != .Ok { throw err } + } + public typealias LogHandler = @convention(c) (LogLevel, UnsafePointer?) -> Void +} + +// ffi callbacks +fileprivate func Resume(context: UnsafeRawPointer?, error: Error) { + let continuation = unsafeBitCast(context, to: UnsafeContinuation.self) + switch error { + case .Ok: continuation.resume() + default: continuation.resume(throwing: error) + } +} +fileprivate func Ignore(context: UnsafeRawPointer?, error: Error) {} From 21c95e470dd397df2836a54d04995544b6aa53c4 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 15 Jan 2025 11:37:19 +0200 Subject: [PATCH 07/24] Set explicit enum values --- lib/src/network/peer_source.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/network/peer_source.rs b/lib/src/network/peer_source.rs index 4ba5fc53d..cd99906e9 100644 --- a/lib/src/network/peer_source.rs +++ b/lib/src/network/peer_source.rs @@ -20,15 +20,15 @@ use std::fmt; #[serde(into = "u8", try_from = "u8")] pub enum PeerSource { /// Explicitly added by the user. - UserProvided, + UserProvided = 0, /// Peer connected to us. - Listener, + Listener = 1, /// Discovered on the Local Discovery. - LocalDiscovery, + LocalDiscovery = 2, /// Discovered on the DHT. - Dht, + Dht = 3, /// Discovered on the Peer Exchange. - PeerExchange, + PeerExchange = 4, } impl fmt::Display for PeerSource { From 147958a4fa7dd1f26406f78f7fdfadfc09259e30 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 15 Jan 2025 11:42:08 +0200 Subject: [PATCH 08/24] More explicit enum values --- lib/src/network/peer_state.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/network/peer_state.rs b/lib/src/network/peer_state.rs index 335308c98..4a1be6965 100644 --- a/lib/src/network/peer_state.rs +++ b/lib/src/network/peer_state.rs @@ -137,13 +137,13 @@ impl<'de> Deserialize<'de> for PeerState { pub enum PeerStateKind { /// The peer is known (discovered or explicitly added by the user) but we haven't started /// establishing a connection to them yet. - Known, + Known = 0, /// A connection to the peer is being established. - Connecting, + Connecting = 1, /// The peer is connected but the protocol handshake is still in progress. - Handshaking, + Handshaking = 2, /// The peer connection is active. - Active, + Active = 3, } #[cfg(test)] From a3de4e770e7a19262afbbfc16dbe8096b253f501 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 15 Jan 2025 11:45:06 +0200 Subject: [PATCH 09/24] Manually add missing enums --- bindings/swift/OuisyncLib/Sources/Enums.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 bindings/swift/OuisyncLib/Sources/Enums.swift diff --git a/bindings/swift/OuisyncLib/Sources/Enums.swift b/bindings/swift/OuisyncLib/Sources/Enums.swift new file mode 100644 index 000000000..be5780e61 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Enums.swift @@ -0,0 +1,47 @@ +// TODO: automatically generate this file after https://github.com/mozilla/cbindgen/issues/1039 +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 +} + +public enum EntryType: UInt8 { + case File = 1 + case Directory = 2 +} + +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 +} + +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 PeerStateKind: UInt8 { + /// The peer is known (discovered or explicitly added by the user) but we haven't started + /// establishing a connection to them yet. + case Known = 0 + /// A connection to the peer is being established. + case Connecting = 1 + /// The peer is connected but the protocol handshake is still in progress. + case Handshaking = 2 + /// The peer connection is active. + case Active = 3 +} From 9ba24346d3ffb9e54a83b1cb8d41d900099e926a Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Thu, 16 Jan 2025 15:38:10 +0200 Subject: [PATCH 10/24] Remove no longer used channel errors --- bindings/dart/lib/errors.dart | 78 ----------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 bindings/dart/lib/errors.dart diff --git a/bindings/dart/lib/errors.dart b/bindings/dart/lib/errors.dart deleted file mode 100644 index 02c91e0d6..000000000 --- a/bindings/dart/lib/errors.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/services.dart'; - - -/* Thrown when a native call could not be completed because the -flutter application host is itself shutting down. Usually when -this happens, the dart vm is probably shutting down as well. - -Corresponds to error code OS00 */ -class HostShutdown extends Error {} - -/* Thrown when the application host encountered an unexpected error -while processing the request. This differs from BindingsOutOfDate -by the host explicitly notifying us of this situation. - -Corresponds to error code OS01 */ -class InternalError extends Error {} - -/* Thrown when the application host was unable to open a channel -to the rust library. The usual way to handle this is to show it -to the user and try to create a new session at a later time. - -Corresponds to error code OS02 */ -class ProviderUnavailable implements Exception {} - -/* Thrown when the application host was unable to collect the -arguments of a method call. This likely means that an argument -was not provided when it was required by a native call. - -Corresponds to error code OS03 */ -class HostArgumentError extends ArgumentError {} - -/* Thrown for every request made after the session has been closed. - -Corresponds to error code OS04 */ -class SessionClosed extends StateError { - SessionClosed() : super('Session closed'); -} - -/* Thrown if the application host attempts to communicate with the -library host (sometimes called file provider extension) over an -unsupported channel. Currently this is specific to macOS and there -is no reasonable way to handle it other than craashing. - -Corresponds to error code OS05 */ -class HostLinkingError extends UnsupportedError { - HostLinkingError(super.message); -} - -/* Thrown if the bindings attempt to invoke a method that is not -exported by the application host. De facto equivalent with -NoSuchMethodError, but presently without the stack information. - -Corresponds to error code OS06 */ -class MethodNotExported extends UnsupportedError { - MethodNotExported(super.message); -} - -/* Thrown when an error was thrown with an associated error code -that was not recognized by this version of the package. While you -can inspect the error code and message manually, it's probably -better to update the bindings or report this. - -Corresponds to any other error code not described here */ -class BindingsOutOfDate extends UnimplementedError { - final String code; - BindingsOutOfDate(this.code, super.message); -} - -Object mapError(PlatformException err) => switch (err.code) { - 'OS00' => HostShutdown(), - 'OS01' => InternalError(), - 'OS02' => ProviderUnavailable(), - 'OS03' => HostArgumentError(), - 'OS04' => SessionClosed(), - 'OS06' => HostLinkingError(err.message!), - 'OS07' => MethodNotExported(err.message!), - _ => BindingsOutOfDate(err.code, err.message) -}; \ No newline at end of file From a181f851f13f94ff5e15002367670b4ddb5f839c Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Sat, 18 Jan 2025 01:22:03 +0200 Subject: [PATCH 11/24] Type-safe bindings --- .../swift/OuisyncLib/Sources/Client.swift | 233 +++++++++++++++ .../swift/OuisyncLib/Sources/Directory.swift | 36 +++ bindings/swift/OuisyncLib/Sources/Enums.swift | 47 --- bindings/swift/OuisyncLib/Sources/File.swift | 93 ++++++ .../swift/OuisyncLib/Sources/Ouisync.swift | 114 ++++++++ .../swift/OuisyncLib/Sources/Repository.swift | 271 ++++++++++++++++++ .../swift/OuisyncLib/Sources/Secret.swift | 90 ++++++ .../swift/OuisyncLib/Sources/Server.swift | 95 ++++++ .../swift/OuisyncLib/Sources/Service.swift | 65 ----- .../swift/OuisyncLib/Sources/ShareToken.swift | 42 +++ .../swift/OuisyncLib/Sources/_Structs.swift | 112 ++++++++ 11 files changed, 1086 insertions(+), 112 deletions(-) create mode 100644 bindings/swift/OuisyncLib/Sources/Client.swift create mode 100644 bindings/swift/OuisyncLib/Sources/Directory.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/Enums.swift create mode 100644 bindings/swift/OuisyncLib/Sources/File.swift create mode 100644 bindings/swift/OuisyncLib/Sources/Ouisync.swift create mode 100644 bindings/swift/OuisyncLib/Sources/Repository.swift create mode 100644 bindings/swift/OuisyncLib/Sources/Secret.swift create mode 100644 bindings/swift/OuisyncLib/Sources/Server.swift delete mode 100644 bindings/swift/OuisyncLib/Sources/Service.swift create mode 100644 bindings/swift/OuisyncLib/Sources/ShareToken.swift create mode 100644 bindings/swift/OuisyncLib/Sources/_Structs.swift diff --git a/bindings/swift/OuisyncLib/Sources/Client.swift b/bindings/swift/OuisyncLib/Sources/Client.swift new file mode 100644 index 000000000..62d2604bc --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Client.swift @@ -0,0 +1,233 @@ +import CryptoKit +import Foundation +import MessagePack +import Network + + +@MainActor public class Client { + let sock: NWConnection + let limit: Int + private(set) var invocations = [UInt64: UnsafeContinuation]() + private(set) var subscriptions = [UInt64: Subscription.Continuation]() + + /** Connects to `127.0.0.1:port` and attempts to authenticate the peer using `key`. + * + * Throws on connection error or if the peer could not be authenticated. */ + public init(_ port: UInt16, _ key: SymmetricKey, maxMessageSize: Int = 1<<18) async throws { + limit = maxMessageSize + sock = NWConnection(to: .hostPort(host: .ipv4(.loopback), + port: .init(rawValue: port)!), + using: .tcp) + sock.start(queue: .main) + + // generate and send client challenge; 256 bytes is a bit large, but we'll probably... + var clientChallenge = Data.secureRandom(256) // ... reserve portions for non-random headers + try await send(clientChallenge) + + // receive server challenge and send proof + let serverChallenge = try await recv(exactly: clientChallenge.count) + try await send(Data(HMAC.authenticationCode(for: serverChallenge, using: key))) + + // receive and validate server proof + guard HMAC.isValidAuthenticationCode(try await recv(exactly: SHA256.byteCount), + authenticating: clientChallenge, using: key) + else { throw CryptoKitError.authenticationFailure } // early eof or key mismatch + + read() // this keeps calling itself until the socket is closed + + // NWConnection predates concurrency, so we need these to ping-pong during this handshake + func recv(exactly count: Int) async throws -> Data { + try await withUnsafeThrowingContinuation { main in + sock.receive(minimumIncompleteLength: count, maximumLength: count) { data, _, _, err in + if let err { + main.resume(throwing: err) + } else if let data, data.count == count { + main.resume(returning: data) + } else { + return main.resume(throwing: CryptoKitError.authenticationFailure) + } + } + } + } + func send(_ data: any DataProtocol) async throws { + try await withUnsafeThrowingContinuation { main in + sock.send(content: data, completion: .contentProcessed({ err in + guard let err else { return main.resume() } + main.resume(throwing: err) + })) + } + } + } + + /** Notifies all callers of the intent to gracefully terminate this connection. + * + * All active subscriptions are marked as completely consumed + * All pending remote procedure calls are cancelled with a `CancellationError` + * + * The underlying connection is *NOT* closed (in fact it is still actively being used to send + * any `unsubscribe` messages) and this client can continue be used to send additional requests + * for as long as there's at least one reference to it. + * + * When investigating dangling references in debug mode, you can set the `abortingInDebug` + * argument to `true` to explicitly abort the connection, but this does not work in release! */ + public func cancel(abortingInDebug: Bool = false) { + assert({ + if abortingInDebug { abort("User triggered abort for debugging purposes") } + return true + // the remaining code is a no-op since we're locked and abort() flushes all state + }()) + subscriptions.values.forEach { $0.finish() } + subscriptions.removeAll() + invocations.values.forEach { $0.resume(throwing: CancellationError()) } + invocations.removeAll() + } + + /** Deinitializers are currently synchronous so we can't gracefully unsubscribe, but we can + * schedule a `RST` packet in order to allow the server to clean up after this connection */ + deinit { sock.cancel() } + + // MARK: end of public API + /** This internal function prints `reason` to the standard log, then closes the socket and fails + * all outstanding requests with `OuisyncError.ConnectionAborted`; intended for use as a generic + * __panic handler__ whenever a non-recoverable protocol error occurs. */ + private func abort(_ reason: String) { + print(reason) + sock.cancel() + subscriptions.values.forEach { $0.finish(throwing: OuisyncError.ConnectionAborted) } + subscriptions.removeAll() + invocations.values.forEach { $0.resume(throwing: OuisyncError.ConnectionAborted) } + invocations.removeAll() + } + + /** Runs the remote procedure call `method`, optionally sending `arg` and returns its result. + * + * Throws `OuisyncError.ConnectionAborted` if the client is no longer connected to the server. + * Throws `CancellationError` if the `cancel()` method is called while this call is pending. */ + @discardableResult func invoke(_ method: String, + with arg: MessagePackValue = .nil, + as: UInt64? = nil) async throws -> MessagePackValue { + guard case .ready = sock.state else { throw OuisyncError.ConnectionAborted } + return try await withUnsafeThrowingContinuation { + let id = `as` ?? Self.next() + let body = pack(arg) + var message = Data(count: 12) + message.withUnsafeMutableBytes { + $0.storeBytes(of: UInt32(exactly: body.count + 8)!.bigEndian, as: UInt32.self) + $0.storeBytes(of: id, toByteOffset: 4, as: UInt64.self) + } + message.append(body) + invocations[id] = $0 + // TODO: schedule this manually to set an upper limit on memory usage + sock.send(content: message, completion: .contentProcessed({ err in + guard let err else { return } + MainActor.assumeIsolated { self.abort("Unexpected IO error during send: \(err)") } + })) + } + } + + /** Starts a new subscription to `topic`, optionally sending `arg`. + * + * Throws `OuisyncError.ConnectionAborted` if the client is no longer connected to the server. + * Completes normally if the `cancel()` method is called while the subscription is active. + * + * The subscription retains the client and until it either goes out of scope. */ + func subscribe(to topic: String, with arg: MessagePackValue = .nil) -> Subscription { + let id = Self.next() + let result = Subscription { + subscriptions[id] = $0 + $0.onTermination = { _ in Task { @MainActor in + self.subscriptions.removeValue(forKey: id) + do { try await self.invoke("unsubscribe", with: .uint(id)) } + catch { print("Unexpected error during unsubscribe: \(error)") } + } } + } + Task { + do { try await invoke("\(topic)_subscribe", with: arg, as: id) } + catch { subscriptions.removeValue(forKey: id)?.finish(throwing: error) } + } + return result + } + public typealias Subscription = AsyncThrowingStream + + /** Internal function that recursively schedules itself until a permanent error occurs + * + * Implemented using callbacks here because while continuations are _cheap_, they are not + * _free_ and non-main actors are still a bit too thread-hoppy with regards to performance */ + private func read() { + sock.receive(minimumIncompleteLength: 12, maximumLength: 12) { header, _ , _, err in + MainActor.assumeIsolated { + guard err == nil else { + return self.abort("Unexpected IO error while reading header: \(err!)") + } + guard let header, header.count == 12 else { + return self.abort("Unexpected EOF while reading header") + } + + var size = Int(0) + var id = UInt64(0) + header.withUnsafeBytes { + size = Int(UInt32(bigEndian: $0.load(as: UInt32.self))) + id = $0.load(fromByteOffset: 4, as: UInt64.self) + } + guard size <= self.limit else { + return self.abort("Received \(size) byte packet which exceeds \(self.limit)") + } + self.sock.receive(minimumIncompleteLength: size, maximumLength: size) { body, _, _, err in + MainActor.assumeIsolated { + guard err == nil else { + return self.abort("Unexpected IO error while reading body: \(err!)") + } + guard let body, header.count == size else { + return self.abort("Unexpected EOF while reading body") + } + guard let (message, rest) = try? unpack(body) else { + return self.abort("MessagePack deserialization error") + } + guard rest.isEmpty else { + return self.abort("Received trailing data after MessagePack response") + } + + // TODO: fix message serialization on the rust side to make this simpler + let result: Result + guard let payload = message.dictionaryValue else { + return self.abort("Received non-dictionary MessagePack response") + } + if let success = payload["success"] { + if success.stringValue != nil { + result = .success(.nil) + } else if let sub = success.dictionaryValue, sub.count == 1 { + result = .success(sub.values.first!) + } else { + return self.abort("Received unrecognized result: \(success)") + } + } else if let failure = payload["failure"] { + guard let info = failure.arrayValue, + let code = info[0].uint16Value, + let err = OuisyncError(rawValue: code) + else { return self.abort("Received unrecognized error: \(failure)") } + result = .failure(err) + } else { return self.abort("Received unercognized message: \(payload)") } + + if let callback = self.invocations.removeValue(forKey: id) { + callback.resume(with: result) + } else if let subscription = self.subscriptions[id] { + subscription.yield(with: result) + } else { + print("Ignoring unexpected message with \(id)") + } + DispatchQueue.main.async{ self.read() } + } + } + } + } + } + + /** Global message counter; 64 bits are enough that we probably won't run into overflows and + * having non-reusable values helps with debugging; we also skip 0 because it's ambiguous we + * could use an atomic here, but it's currently not necessary since we're tied to @MainActor */ + static private(set) var seq = UInt64(0) + static private func next() -> UInt64 { + seq += 1 + return seq + } +} diff --git a/bindings/swift/OuisyncLib/Sources/Directory.swift b/bindings/swift/OuisyncLib/Sources/Directory.swift new file mode 100644 index 000000000..a8df13a79 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Directory.swift @@ -0,0 +1,36 @@ +import Foundation +import MessagePack + + +public extension Repository { + /** Lists all entries from an existing directory at `path`. + * + * Throws `OuisyncError` if `path` doesn't exist or is not a directory. */ + func listDirectory(at path: String) async throws -> [(name: String, type: EntryType)] { + // FIXME: replace this with an AsyncStream to future-proof the API + try await client.invoke("directory_read", with: ["repository": handle, + "path": .string(path)]).arrayValue.orThrow.map { + guard let arr = $0.arrayValue, arr.count == 2 else { throw OuisyncError.InvalidData } + return try (name: arr[0].stringValue.orThrow, + type: EntryType(rawValue: arr[1].uint8Value.orThrow).orThrow) + } + } + + /** Creates a new empty directory at `path`. + * + * Throws `OuisyncError` if `path` already exists of if the parent folder doesn't exist. */ + func createDirectory(at Path: String) async throws -> File { + try await File(self, client.invoke("directory_create", with: ["repository": handle, + "path": .string(path)])) + } + + /** Remove a directory from `path`. + * + * If `recursive` is `false` (which is the default), the directory must be empty otherwise an + * exception is thrown. Otherwise, the contents of the directory are also removed. */ + func remove(at path: String, recursive: Bool = false) async throws { + try await client.invoke("directory_remove", with: ["repository": handle, + "path": .string(path), + "recursive": .bool(recursive)]) + } +} diff --git a/bindings/swift/OuisyncLib/Sources/Enums.swift b/bindings/swift/OuisyncLib/Sources/Enums.swift deleted file mode 100644 index be5780e61..000000000 --- a/bindings/swift/OuisyncLib/Sources/Enums.swift +++ /dev/null @@ -1,47 +0,0 @@ -// TODO: automatically generate this file after https://github.com/mozilla/cbindgen/issues/1039 -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 -} - -public enum EntryType: UInt8 { - case File = 1 - case Directory = 2 -} - -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 -} - -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 PeerStateKind: UInt8 { - /// The peer is known (discovered or explicitly added by the user) but we haven't started - /// establishing a connection to them yet. - case Known = 0 - /// A connection to the peer is being established. - case Connecting = 1 - /// The peer is connected but the protocol handshake is still in progress. - case Handshaking = 2 - /// The peer connection is active. - case Active = 3 -} diff --git a/bindings/swift/OuisyncLib/Sources/File.swift b/bindings/swift/OuisyncLib/Sources/File.swift new file mode 100644 index 000000000..17fe83a09 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/File.swift @@ -0,0 +1,93 @@ +// +// File.swift +// OuisyncLib +// +// Created by Radu Dan on 17.01.2025 and this generated comment was preserved because: +// +// How many times throughout Xcode's history has `File.swift` ever been the intended name? + +import Foundation +import MessagePack + + +public extension Repository { + /** Opens an existing file from the current repository at `path`. + * + * Throws `OuisyncError` if `path` doesn't exist or is a directory. */ + func openFile(at path: String) async throws -> File { + try await File(self, client.invoke("file_open", with: ["repository": handle, + "path": .string(path)])) + } + + /** Creates a new file at `path`. + * + * Throws `OuisyncError` if `path` already exists of if the parent folder doesn't exist. */ + func createFile(at Path: String) async throws -> File { + try await File(self, client.invoke("file_create", with: ["repository": handle, + "path": .string(path)])) + } + + /// Removes (deletes) the file at `path`. + func removeFile(at path: String) async throws { + try await client.invoke("file_remove", with: ["repository": handle, + "path": .string(path)]) + } +} + + +public class File { + let repository: Repository + let handle: MessagePackValue + + init(_ repo: Repository, _ handle: MessagePackValue) throws { + _ = try handle.uint64Value.orThrow + repository = repo + self.handle = handle + } + + /// Flush and close the handle once the file goes out of scope + deinit { + let client = repository.client, handle = handle + Task { try await client.invoke("file_close", with: handle) } + } +} + + +public extension File { + /** Reads and returns at most `size` bytes from this file, starting at `offset`. + * + * Returns 0 bytes if `offset` is at or past the end of the file */ + func read(_ size: UInt64, fromOffset offset: UInt64) async throws -> Data { + try await repository.client.invoke("file_read", + with: ["file": handle, + "offset": .uint(offset), + "size": .uint(size)]).dataValue.orThrow + } + + /// Writes `data` to this file, starting at `offset` + func write(_ data: Data, toOffset offset: UInt64) async throws -> Data { + try await repository.client.invoke("file_write", + with: ["file": handle, + "offset": .uint(offset), + "data": .binary(data)]).dataValue.orThrow + } + + /// Flushes any pending writes to persistent storage. + func flush() async throws { + try await repository.client.invoke("file_flush", with: handle) + } + + /// Truncates the file to `len` bytes. + func truncate(to len: UInt64 = 0) async throws { + try await repository.client.invoke("file_truncate", with: ["file": handle, + "len": .uint(len)]) + } + + var len: UInt64 { get async throws { + try await repository.client.invoke("file_len", with: handle).uint64Value.orThrow + } } + + var progress: UInt64 { get async throws { + try await repository.client.invoke("file_progress", with: handle).uint64Value.orThrow + } } +} diff --git a/bindings/swift/OuisyncLib/Sources/Ouisync.swift b/bindings/swift/OuisyncLib/Sources/Ouisync.swift new file mode 100644 index 000000000..61685861d --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Ouisync.swift @@ -0,0 +1,114 @@ +public extension Client { + // MARK: repository + var storeDir: String { get async throws { + try await invoke("repository_get_store_dir").stringValue.orThrow + } } + func setStoreDir(to path: String) async throws { + try await invoke("repository_set_store_dir", with: .string(path)) + } + + var runtimeId: String { get async throws { + try await invoke("network_get_runtime_id").stringValue.orThrow + } } + + @available(*, deprecated, message: "Not supported on darwin") + var mountRoot: String { get async throws { + try await invoke("repository_get_mount_root").stringValue.orThrow + } } + + @available(*, deprecated, message: "Not supported on darwin") + func setMountRoot(to path: String) async throws { + try await invoke("repository_set_mount_root", with: .string(path)) + } + + // MARK: network + /// Initializes library network stack using the provided config. + func initNetwork(bindTo addresses: [String] = [], + portForwarding: Bool = false, + localDiscovery: Bool = false) async throws { + try await invoke("network_init", with: ["bind": .array(addresses.map { .string($0) }), + "port_forwarding_enabled": .bool(portForwarding), + "local_discovery_enabled": .bool(localDiscovery)]) + } + + var listenerAddrs: [String] { get async throws { + try await invoke("network_get_listener_addrs").arrayValue.orThrow.map { + try $0.stringValue.orThrow + } + } } + /// Binds network to the specified addresses. + func bindNetwork(to addresses: [String]) async throws { + try await invoke("network_bind", with: .array(addresses.map { .string($0) })) + } + + /// Is port forwarding (UPnP) enabled? + var portForwarding: Bool { get async throws { + try await invoke("network_is_port_forwarding_enabled").boolValue.orThrow + } } + /// Enable/disable port forwarding (UPnP) + func setPortForwarding(enabled: Bool) async throws { + try await invoke("network_set_port_forwarding_enabled", with: .bool(enabled)) + } + + /// Is local discovery enabled? + var localDiscovery: Bool { get async throws { + try await invoke("network_is_local_discovery_enabled").boolValue.orThrow + } } + /// Enable/disable local discovery + func setLocalDiscovery(enabled: Bool) async throws { + try await invoke("network_set_local_discovery_enabled", with: .bool(enabled)) + } + + var networkEvents: AsyncThrowingMapSequence { + subscribe(to: "network").map { try NetworkEvent(rawValue: $0.uint8Value.orThrow).orThrow } + } + + var currentProtocolVersion: UInt64 { get async throws { + try await invoke("network_get_current_protocol_version").uint64Value.orThrow + } } + + var highestObservedProtocolVersion: UInt64 { get async throws { + try await invoke("network_get_highest_seen_protocol_version").uint64Value.orThrow + } } + + var natBehavior: String { get async throws { + try await invoke("network_get_nat_behavior").stringValue.orThrow + } } + + var externalAddressV4: String { get async throws { + try await invoke("network_get_external_addr_v4").stringValue.orThrow + } } + + var externalAddressV6: String { get async throws { + try await invoke("network_get_external_addr_v6").stringValue.orThrow + } } + + var networkStats: NetworkStats { get async throws { + try await NetworkStats(invoke("network_stats")) + } } + + // MARK: peers + var peers: [PeerInfo] { get async throws { + try await invoke("network_get_peers").arrayValue.orThrow.map { try PeerInfo($0) } + } } + + // user provided + var userProvidedPeers: [String] { get async throws { + try await invoke("network_get_user_provided_peers").arrayValue.orThrow.map { + try $0.stringValue.orThrow + } + } } + + func addUserProvidedPeers(from: [String]) async throws { + try await invoke("network_add_user_provided_peers", with: .array(from.map { .string($0) })) + } + + func removeUserProvidedPeers(from: [String]) async throws { + try await invoke("network_remove_user_provided_peers", with: .array(from.map { .string($0) })) + } + + // StateMonitor get rootStateMonitor => StateMonitor.getRoot(_client); + // StateMonitor? get stateMonitor => StateMonitor.getRoot(_client) + // .child(MonitorId.expectUnique("Repositories")) + // .child(MonitorId.expectUnique(_path)); +} diff --git a/bindings/swift/OuisyncLib/Sources/Repository.swift b/bindings/swift/OuisyncLib/Sources/Repository.swift new file mode 100644 index 000000000..a2ec80d4d --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Repository.swift @@ -0,0 +1,271 @@ +import Foundation +import MessagePack + + +public extension Client { + /** Creates a new repository (or imports an existing repository) with optional local encryption + * + * If a `token` is provided, the operation will be an `import`. Otherwise, a new, empty, + * fully writable repository is created at `path`. + * + * The optional `readSecret` and `writeSecret` are intended to function as a second + * authentication factor and are used to encrypt the repository's true access keys. Secrets + * are ignored when the underlying `token` doesn't contain the corresponding key (e.g. for + * "blind" or "read-only" tokens). + * + * Finally, due to our current key distribution mechanism, `writeSecret` becomes mandatory + * when a `readSecret` is set. You may reuse the same secret for both values. */ + func createRepository(at path: String, + importingFrom token: ShareToken? = nil, + readSecret: Secret? = nil, + writeSecret: Secret? = nil) async throws { + // FIXME: the backend does buggy things here, so we bail out; see also `unsafeSetSecrets` + if readSecret != nil && writeSecret == nil { throw OuisyncError.Unsupported } + try await invoke("repository_create", with: ["path": .string(path), + "read_secret": readSecret?.value ?? .nil, + "write_secret": writeSecret?.value ?? .nil, + "token": token?.value ?? .nil, + "sync_enabled": false, + "dht_enabled": false, + "pex_enabled": false]) + } + + /** Opens an existing repository from a `path`, optionally using a known `secret`. + * + * If the same repository is opened again, a new handle pointing to the same underlying + * repository is returned. Closed automatically when all references go out of scope. */ + func openRepository(at path: String, using secret: Secret? = nil) async throws -> Repository { + try await Repository(self, invoke("repository_open", with: ["path": .string(path), + "secret": secret?.value ?? .nil, + "sync_enabled": false])) + } + + /// All currently open repositories. + var repositories: [Repository] { get async throws { + try await invoke("repository_list").arrayValue.orThrow.map { try Repository(self, $0) } + } } +} + + +public class Repository { + let client: Client + let handle: MessagePackValue + init(_ client: Client, _ handle: MessagePackValue) throws { + _ = try handle.uintValue.orThrow + self.client = client + self.handle = handle + } + + deinit { + // we're going out of scope so we need to copy the state that the async closure will capture + let client = client, handle = handle + Task { try await client.invoke("repository_close", with: handle) } + } +} + + +public extension Repository { + /// Deletes this repository. It's an error to invoke any operation on it after it's been deleted. + func delete() async throws { + try await client.invoke("repository_delete", with: handle) + } + + func move(to location: String) async throws { + try await client.invoke("repository_move", with: ["repository": handle, + "to": .string(location)]) + } + + var path: String { get async throws { + try await client.invoke("repository_get_path", with: handle).stringValue.orThrow + } } + + /// Whether syncing with other replicas is enabled. + var syncing: Bool { get async throws { + try await client.invoke("repository_is_sync_enabled", with: handle).boolValue.orThrow + } } + + /// Enables or disables syncing with other replicas. + func setSyncing(enabled: Bool) async throws { + try await client.invoke("repository_set_sync_enabled", with: ["repository": handle, + "enabled": .bool(enabled)]) + } + + /** Resets access using `token` and reset any values encrypted with local secrets to random + * values. Currently that is only the writer ID. */ + func resetAccess(using token: ShareToken) async throws { + try await client.invoke("repository_reset_access", with: ["repository": handle, + "token": token.value]) + } + + /** The current repository credentials. + * + * They can be used to restore repository access via `setCredentials()` after the repo has been + * closed and re-opened, without needing the local secret (e.g. when moving the database). */ + var credentials: Data { get async throws { + try await client.invoke("repository_credentials", with: handle).dataValue.orThrow + } } + func setCredentials(from credentials: Data) async throws { + try await client.invoke("repository_set_credentials", + with: ["repository": handle, "credentials": .binary(credentials)]) + } + + var accessMode: AccessMode { get async throws { + try await AccessMode(rawValue: client.invoke("repository_get_access_mode", + with: handle).uint8Value.orThrow).orThrow + } } + func setAccessMode(to mode: AccessMode, using secret: Secret? = nil) async throws { + try await client.invoke("repository_set_access_mode", + with: ["repository": handle, + "access_mode": .uint(UInt64(mode.rawValue)), + "secret": secret?.value ?? .nil]) + } + + /// Returns the `EntryType` at `path`, or `nil` if there's nothing that location + func entryType(at path: String) async throws -> EntryType? { + let res = try await client.invoke("repository_entry_type", with: ["repository": handle, + "path": .string(path)]) + if case .nil = res { return nil } // FIXME: this should just be an enum type + return try EntryType(rawValue: res.uint8Value.orThrow).orThrow + } + + /// Returns whether the entry (file or directory) at `path` exists. + @available(*, deprecated, message: "use `entryType(at:)` instead") + func entryExists(at path: String) async throws -> Bool { try await entryType(at: path) != nil } + + /// Move or rename the entry at `src` to `dst` + func moveEntry(from src: String, to dst: String) async throws { + try await client.invoke("repository_move_entry", with: ["repository": handle, + "src": .string(src), + "dst": .string(dst)]) + } + + /// This is a lot of overhead for a glorified event handler + @MainActor var events: AsyncThrowingMapSequence { + client.subscribe(to: "repository").map { _ in () } + } + + var dht: Bool { get async throws { + try await client.invoke("repository_is_dht_enabled", with: handle).boolValue.orThrow + } } + func setDht(enabled: Bool) async throws { + try await client.invoke("repository_set_dht_enabled", with: ["repository": handle, + "enabled": .bool(enabled)]) + } + + var pex: Bool { get async throws { + try await client.invoke("repository_is_pex_enabled", with: handle).boolValue.orThrow + } } + func setPex(enabled: Bool) async throws { + try await client.invoke("repository_set_pex_enabled", with: ["repository": handle, + "enabled": .bool(enabled)]) + } + + /// Create a share token providing access to this repository with the given mode. + func share(for mode: AccessMode, using secret: Secret? = nil) async throws -> ShareToken { + try await ShareToken(client, client.invoke("repository_share", + with: ["repository": handle, + "secret": secret?.value ?? .nil, + "mode": .uint(UInt64(mode.rawValue))])) + } + + var syncProgress: Progress { get async throws { + try await Progress(client.invoke("repository_sync_progress", with: handle)) + } } + + var infoHash: String { get async throws { + try await client.invoke("repository_get_info_hash", with: handle).stringValue.orThrow + } } + + /// Create mirror of this repository on a cache server. + func createMirror(to host: String) async throws { + try await client.invoke("repository_create_mirror", with: ["repository": handle, + "host": .string(host)]) + } + + /// Check if this repository is mirrored on a cache server. + func mirrorExists(on host: String) async throws -> Bool { + try await client.invoke("repository_mirror_exists", + with: ["repository": handle, + "host": .string(host)]).boolValue.orThrow + } + + /// Delete mirror of this repository from a cache server. + func deleteMirror(from host: String) async throws { + try await client.invoke("repository_delete_mirror", with: ["repository": handle, + "host": .string(host)]) + } + + func metadata(for key: String) async throws -> String? { + let res = try await client.invoke("repository_get_metadata", with: ["repository": handle, + "key": .string(key)]) + if case .nil = res { return nil } + return try res.stringValue.orThrow + } + + /// Performs an (presumably atomic) CAS on `edits`, returning `true` if they were updated + func updateMetadata(with edits: [String:(from: String, to: String)]) async throws -> Bool { + try await client.invoke("repository_set_metadata", + with: ["repository": handle, + "edits": .array(edits.map {["key": .string($0.key), + "old": .string($0.value.from), + "new": .string($0.value.to)]})] + ).boolValue.orThrow + } + + /// Mount the repository if supported by the platform. + @available(*, deprecated, message: "Not supported on darwin") + func mount() async throws { + try await client.invoke("repository_mount", with: handle) + } + + /// Unmount the repository. + @available(*, deprecated, message: "Not supported on darwin") + func unmount() async throws { + try await client.invoke("repository_unmount", with: handle) + } + + /// The mount point of this repository (or `nil` if not mounted). + @available(*, deprecated, message: "Not supported on darwin") + var mountPoint: String? { get async throws { + let res = try await client.invoke("repository_get_mount_point", with: handle) + if case .nil = res { return nil } + return try res.stringValue.orThrow + } } + + var networkStats: NetworkStats { get async throws { + try await NetworkStats(client.invoke("repository_get_stats", with: handle)) + } } + + // FIXME: nominally called setAccess which is easily confused with setAccessMode + /** Updates the secrets used to access this repository + * + * The default value maintains the existing key whereas explicitly passing `nil` removes it. + * + * `Known issue`: keeping an existing `read password` while removing the `write password` can + * result in a `read-only` repository. If you break it, you get to keep all the pieces! + */ + func unsafeSetSecrets(readSecret: Secret? = KEEP_EXISTING, + writeSecret: Secret? = KEEP_EXISTING) async throws { + // FIXME: the implementation requires a distinction between "no key" and "remove key"... + /** ...which is currently implemented in terms of a `well known` default value + * + * On a related matter, we are currently leaking an _important_ bit in the logs (the + * existence or lack thereof of some secret) because we can't override `Optional.toString`. + * + * While the root cause is different, many languages have a similar + * `nil is a singleton` problem which we can however fix via convention: + * + * If we agree that a secret of length `0` means `no password`, we can then use `nil` here + * as a default argument for `keep existing secret` on both sides of the ffi */ + func encode(_ arg: Secret?) -> MessagePackValue { + guard let arg else { return .string("disable") } + return arg.value == KEEP_EXISTING.value ? .nil : ["enable": readSecret!.value] + } + try await client.invoke("repository_set_access", with: ["repository": handle, + "read": encode(readSecret), + "write": encode(writeSecret)]) + } +} + +// feel free to swap this with your preferred subset of the output of `head /dev/random | base64` +@usableFromInline let KEEP_EXISTING = Password("i2jchEQApyAkAD79uPYvuO1jiTAumhAwwSOQx5GGuNu3NZPKc") diff --git a/bindings/swift/OuisyncLib/Sources/Secret.swift b/bindings/swift/OuisyncLib/Sources/Secret.swift new file mode 100644 index 000000000..3c9a7d090 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Secret.swift @@ -0,0 +1,90 @@ +import CryptoKit +import Foundation +import MessagePack + +/** `Secret` is used to encrypt and decrypt "global" read and write keys stored inside repositories + * which are consequently used to encrypt, decrypt and sign repository data. + * + * There may be two `Secret`s, one for decrypting the global read and one decrypting the global + * write keys. Note the that decrypting the global write key will enable repository reading as well + * because the global read key is derived from the global write key. + * + * When opening a repository with a `Secret` the library will attempt to gain the highest possible + * access. That is, it will use the local secret to decrypt the global write key first and, if that + * fails, it'll attempt to decrypt the global read key. + * + * `Secret` can be either a `Password` or a `SecretKey`. In case a `Password` is provided to the + * library, it is internally converted to `SecretKey` using a KDF and a `Salt`. Ouisync uses two + * `Salt`s: one for the "read" and one for the "write" local secret keys and they are stored inside + * each repository database individually. + * + * Since secrets should not be logged by default, we require (but provide a default implementation + * for) `CustomDebugStringConvertible` conformance + */ +public protocol Secret: CustomDebugStringConvertible { + var value: MessagePackValue { get } +} +public extension Secret { + var debugDescription: String { "\(Self.self)(***)" } +} + +public struct Password: Secret { + public let value: MessagePackValue + public init(_ string: String) { value = ["password": .string(string)] } +} + +public struct SecretKey: Secret { + public let value: MessagePackValue + public init(_ bytes: Data) { value = ["secret_key": .binary(bytes)] } + /// Generates a random 256-bit key as required by the ChaCha20 implementation Ouisync is using. + public static var random: Self { get throws { try Self(.secureRandom(32)) } } +} + +public struct Salt: Secret { + public let value: MessagePackValue + public init(_ bytes: Data) { value = .binary(bytes) } + /// Generates a random 128-bit nonce as recommended by the Argon2 KDF used by Ouisync. + public static var random: Self { get throws { try Self(.secureRandom(16)) } } + +} + +public struct SaltedSecretKey: Secret { + public let value: MessagePackValue + public init(_ key: SecretKey, _ salt: Salt) { value = ["key_and_salt": ["key": key.value, + "salt": salt.value]] } + + /// Generates a random 256-bit key and a random 128-bit salt + public static var random: Self { get throws { try Self(.random, .random) } } +} + + +extension Data { + /// Returns `size` random bytes generated using a cryptographically secure algorithm + static func secureRandom(_ size: Int) throws -> Self { + guard let buff = malloc(size) else { + throw CryptoKitError.underlyingCoreCryptoError(error: errSecMemoryError) + } + switch SecRandomCopyBytes(kSecRandomDefault, size, buff) { + case errSecSuccess: + return Data(bytesNoCopy: buff, count: size, deallocator: .free) + case let code: + free(buff) + throw CryptoKitError.underlyingCoreCryptoError(error: code) + } + } +} + + +public extension Client { + /// Remotely generate a password salt + func generateSalt() async throws -> Salt { + try await Salt(invoke("password_generate_salt").dataValue.orThrow) + } + + /// Remotely derive a `SecretKey` from `password` and `salt` using a secure KDF + func deriveSecretKey(from password: Password, with salt: Salt) async throws -> SecretKey { + try await SecretKey(invoke("password_derive_secret_key", + with: ["password": password.value, + "salt": salt.value]).dataValue.orThrow) + } +} diff --git a/bindings/swift/OuisyncLib/Sources/Server.swift b/bindings/swift/OuisyncLib/Sources/Server.swift new file mode 100644 index 000000000..d805713c2 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/Server.swift @@ -0,0 +1,95 @@ +import CryptoKit +import Foundation +import OuisyncLibFFI + +extension ErrorCode: Error {} // @retroactive doesn't work in Ventura, which I still use +public typealias OuisyncError = ErrorCode + +public class Server { + /** Starts a Ouisync server in a new thread and binds it to the port set in `configDir`. + * + * Returns after the socket has been initialized successfully and is ready to accept client + * connections, or throws a `OuisyncError` indicating what went wrong. + * + * On success, the server remains active until `.stop()` is called. An attempt will be made to + * stop the server once all references are dropped, however this is strongly discouraged since + * in this case it's not possible to determine whether the shutdown was successful or not. */ + public init(configDir: String, debugLabel: String) async throws { + self.configDir = URL(fileURLWithPath: configDir) + self.debugLabel = debugLabel + handle = try await withUnsafeThrowingContinuation { + service_start(configDir, debugLabel, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) + } + } + /// the configDir passed to the constructor when the server was started + public let configDir: URL + /// the debugLabel passed to the constructor when the server was started + public let debugLabel: String + /// The localhost port that can be used to interact with the server + public var port: UInt16 { get async throws { try JSONDecoder().decode(UInt16.self, from: + Data(contentsOf: configDir.appending(component: "local_control_port.conf"))) + } } + /// The HMAC key required to authenticate to the server listening on `port` + public var authKey: SymmetricKey { get async throws { + let file = configDir.appending(component: "local_control_auth_key.conf") + let str = try JSONDecoder().decode(String.self, from: Data(contentsOf: file)) + guard str.count&1 == 0 else { throw CryptoKitError.incorrectParameterSize } + + // unfortunately, swift doesn't provide an (easy) way to do hex decoding + var curr = str.startIndex + return try SymmetricKey(data: (0..>1).map { _ in + let next = str.index(after: curr) + defer { curr = next } + if let res = UInt8(str[curr...next], radix: 16) { return res } + throw CryptoKitError.invalidParameter + }) + } } + + /// An opaque handle which must be passed to `service_stop` in order to terminate the service. + private var handle: UnsafeMutableRawPointer?; + deinit { + guard let handle else { return } + service_stop(handle, Ignore, nil) + } + + /** Stops a running Ouisync server. + * + * Returns after the server shutdown has been completed or throws a `OuisyncError` on failure. + * Returns immediately when called a second time, but doing so is not thread safe! */ + public func stop() async throws { + guard let handle else { return } + self.handle = nil + try await withUnsafeThrowingContinuation { + service_stop(handle, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) + } as Void + } + + /** Opens a new client connection to this server. */ + public func connect() async throws -> Client { try await .init(port, authKey) } + + /** Initialize logging to stdout. Should be called before constructing a `Server`. + * + * If `filename` is not null, additionally logs to that file. + * If `handler` is not null, it is called for every message. + * + * Throws a `OuisyncError` on failure. Should not be called more than once per process! + */ + public static func configureLogging(filename: String? = nil, + handler: LogHandler? = nil, + tag: String = "Server") throws { + let err = log_init(filename, handler, tag) + if err != .Ok { throw err } + } + public typealias LogHandler = @convention(c) (LogLevel, UnsafePointer?) -> Void +} + +/// FFI callback that expects a continuation in the context which it resumes, throwing if necessary +fileprivate func Resume(context: UnsafeRawPointer?, error: OuisyncError) { + let continuation = unsafeBitCast(context, to: UnsafeContinuation.self) + switch error { + case .Ok: continuation.resume() + default: continuation.resume(throwing: error) + } +} +/// FFI callback that does nothing; can be removed if upstream allows null function pointers +fileprivate func Ignore(context: UnsafeRawPointer?, error: OuisyncError) {} diff --git a/bindings/swift/OuisyncLib/Sources/Service.swift b/bindings/swift/OuisyncLib/Sources/Service.swift deleted file mode 100644 index 17aae792d..000000000 --- a/bindings/swift/OuisyncLib/Sources/Service.swift +++ /dev/null @@ -1,65 +0,0 @@ -import OuisyncLibFFI - -extension ErrorCode: Swift.Error {} -public typealias Error = ErrorCode - -public class Service { - // An opaque handle which must be passed to service_stop in order to terminate the service. - private var handle: UnsafeMutableRawPointer?; - - /* Starts a Ouisync service in a new thread and binds it to the port set in `configDir`. - * - * Returns after the service has been initialized successfully and is ready to accept client - * connections, or throws a `Ouisync.Error` indicating what went wrong. - * - * On success, the service remains active until `.stop()` is called. An attempt will be made to - * stop the service once all references are dropped, however this is strongly discouraged as in - * this case it's not possible to determine whether the shutdown was successful. */ - public init(configDir: String, debugLabel: String) async throws { - handle = try await withUnsafeThrowingContinuation { - service_start(configDir, debugLabel, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) - } - } - - deinit { - guard let handle else { return } - service_stop(handle, Ignore, nil) - } - - /* Stops a running Ouisync service. - * - * Returns after the service shutdown has been completed or throws a `Ouisync.Error` on failure. - * Returns immediately when called a second time, but doing so is not thread safe! */ - public func stop() async throws { - guard let handle else { return } - self.handle = nil - try await withUnsafeThrowingContinuation { - service_stop(handle, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) - } as Void - } - - /* Initialize logging to stdout. Should be called before `service_start`. - * - * If `filename` is not null, additionally logs to that file. - * If `handler` is not null, it is called for every message. - * - * Throws a `Ouisync.Error` on failure. Should not be called more than once per process! - */ - public static func configureLogging(filename: String? = nil, - handler: LogHandler? = nil, - tag: String = "Server") throws { - let err = log_init(filename, handler, tag) - if err != .Ok { throw err } - } - public typealias LogHandler = @convention(c) (LogLevel, UnsafePointer?) -> Void -} - -// ffi callbacks -fileprivate func Resume(context: UnsafeRawPointer?, error: Error) { - let continuation = unsafeBitCast(context, to: UnsafeContinuation.self) - switch error { - case .Ok: continuation.resume() - default: continuation.resume(throwing: error) - } -} -fileprivate func Ignore(context: UnsafeRawPointer?, error: Error) {} diff --git a/bindings/swift/OuisyncLib/Sources/ShareToken.swift b/bindings/swift/OuisyncLib/Sources/ShareToken.swift new file mode 100644 index 000000000..08f096b47 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/ShareToken.swift @@ -0,0 +1,42 @@ +import MessagePack + + +public extension Client { + func shareToken(fromString value: String) async throws -> ShareToken { + try await ShareToken(self, invoke("share_token_normalize", with: .string(value))) + } +} + + +// this is a fucking secret too +public struct ShareToken: Secret { + let client: Client + public let value: MessagePackValue + + init(_ client: Client, _ value: MessagePackValue) { + self.client = client + self.value = value + } + + /// The repository name suggested from this token. + var suggestedName: String { get async throws { + try await client.invoke("share_token_get_suggested_name", with: value).stringValue.orThrow + } } + + var infoHash: String { get async throws { + try await client.invoke("share_token_get_info_hash", with: value).stringValue.orThrow + } } + + /// The access mode this token provides. + var accessMode: AccessMode { get async throws { + try await AccessMode(rawValue: client.invoke("share_token_get_access_mode", + with: value).uint8Value.orThrow).orThrow + } } + + /// Check if the repository of this share token is mirrored on the cache server. + func mirrorExists(at host: String) async throws -> Bool { + try await client.invoke("share_token_mirror_exists", + with: ["share_token": value, + "host": .string(host)]).boolValue.orThrow + } +} diff --git a/bindings/swift/OuisyncLib/Sources/_Structs.swift b/bindings/swift/OuisyncLib/Sources/_Structs.swift new file mode 100644 index 000000000..3cde846b2 --- /dev/null +++ b/bindings/swift/OuisyncLib/Sources/_Structs.swift @@ -0,0 +1,112 @@ +import MessagePack + + +extension Optional { + /// A softer version of unwrap (!) that throws `OuisyncError.InvalidData` instead of crashing + var orThrow: Wrapped { get throws { + guard let self else { throw OuisyncError.InvalidData } + return self + } } +} + + +public struct NetworkStats { + public let bytesTx: UInt64 + public let bytesRx: UInt64 + public let throughputTx: UInt64 + public let throughputRx: UInt64 + + init(_ messagePack: MessagePackValue) throws { + guard let arr = messagePack.arrayValue, arr.count == 4 else { throw OuisyncError.InvalidData } + bytesTx = try arr[0].uint64Value.orThrow + bytesRx = try arr[1].uint64Value.orThrow + throughputTx = try arr[2].uint64Value.orThrow + throughputRx = try arr[3].uint64Value.orThrow + } +} + + +public struct PeerInfo { + public let addr: String + public let source: PeerSource + public let state: PeerStateKind + public let runtimeId: String? + public let stats: NetworkStats + + init(_ messagePack: MessagePackValue) throws { + guard let arr = messagePack.arrayValue, arr.count == 4 else { throw OuisyncError.InvalidData } + addr = try arr[0].stringValue.orThrow + source = try PeerSource(rawValue: arr[1].uint8Value.orThrow).orThrow + if let kind = arr[2].uint8Value { + state = try PeerStateKind(rawValue: kind).orThrow + runtimeId = nil + } else if let arr = arr[2].arrayValue, arr.count == 2 { + state = try PeerStateKind(rawValue: arr[0].uint8Value.orThrow).orThrow + runtimeId = try arr[1].dataValue.orThrow.map({ String(format: "%02hhx", $0) }).joined() + } else { + throw OuisyncError.InvalidData + } + stats = try NetworkStats(arr[3]) + } +} + + +public struct Progress { + public let value: UInt64 + public let total: UInt64 + + init(_ messagePack: MessagePackValue) throws { + guard let arr = messagePack.arrayValue, arr.count == 2 else { throw OuisyncError.InvalidData } + value = try arr[0].uint64Value.orThrow + total = try arr[1].uint64Value.orThrow + } +} + + +// TODO: automatically generate these enums after https://github.com/mozilla/cbindgen/issues/1039 +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 +} + +public enum EntryType: UInt8 { + case File = 1 + case Directory = 2 +} + +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 +} + +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 PeerStateKind: UInt8 { + /// The peer is known (discovered or explicitly added by the user) but we haven't started + /// establishing a connection to them yet. + case Known = 0 + /// A connection to the peer is being established. + case Connecting = 1 + /// The peer is connected but the protocol handshake is still in progress. + case Handshaking = 2 + /// The peer connection is active. + case Active = 3 +} From 2e17b34ec6a0901a133a91425b9a4118a09ac6d4 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Tue, 21 Jan 2025 14:47:26 +0200 Subject: [PATCH 12/24] `swift build` compatibility --- .../swift/{OuisyncLib => Ouisync}/.gitignore | 2 +- bindings/swift/Ouisync/Package.resolved | 14 +++++ .../{OuisyncLib => Ouisync}/Package.swift | 23 ++++---- .../Plugins/Builder/builder.swift | 22 ++----- .../Plugins/Updater/updater.swift | 0 .../{OuisyncLib => Ouisync}/Plugins/build.sh | 58 ++++++++++-------- .../{OuisyncLib => Ouisync}/Plugins/update.sh | 0 .../Sources/Client.swift | 2 +- .../Sources/Directory.swift | 0 .../Sources/File.swift | 0 .../Sources/Ouisync.swift | 0 .../Sources/Repository.swift | 0 .../Sources/Secret.swift | 1 - .../Sources/Server.swift | 2 +- .../Sources/ShareToken.swift | 0 .../swift/Ouisync/Sources/StateMonitor.swift | 59 +++++++++++++++++++ .../Sources/_Structs.swift | 0 .../Tests/OuisyncLibTests.swift | 0 bindings/swift/Ouisync/init.sh | 51 ++++++++++++++++ 19 files changed, 177 insertions(+), 57 deletions(-) rename bindings/swift/{OuisyncLib => Ouisync}/.gitignore (85%) create mode 100644 bindings/swift/Ouisync/Package.resolved rename bindings/swift/{OuisyncLib => Ouisync}/Package.swift (66%) rename bindings/swift/{OuisyncLib => Ouisync}/Plugins/Builder/builder.swift (62%) rename bindings/swift/{OuisyncLib => Ouisync}/Plugins/Updater/updater.swift (100%) rename bindings/swift/{OuisyncLib => Ouisync}/Plugins/build.sh (66%) rename bindings/swift/{OuisyncLib => Ouisync}/Plugins/update.sh (100%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/Client.swift (99%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/Directory.swift (100%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/File.swift (100%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/Ouisync.swift (100%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/Repository.swift (100%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/Secret.swift (99%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/Server.swift (99%) rename bindings/swift/{OuisyncLib => Ouisync}/Sources/ShareToken.swift (100%) create mode 100644 bindings/swift/Ouisync/Sources/StateMonitor.swift rename bindings/swift/{OuisyncLib => Ouisync}/Sources/_Structs.swift (100%) rename bindings/swift/{OuisyncLib => Ouisync}/Tests/OuisyncLibTests.swift (100%) create mode 100755 bindings/swift/Ouisync/init.sh diff --git a/bindings/swift/OuisyncLib/.gitignore b/bindings/swift/Ouisync/.gitignore similarity index 85% rename from bindings/swift/OuisyncLib/.gitignore rename to bindings/swift/Ouisync/.gitignore index 878cfd1fb..ffcc782cd 100644 --- a/bindings/swift/OuisyncLib/.gitignore +++ b/bindings/swift/Ouisync/.gitignore @@ -1,4 +1,4 @@ -/OuisyncLibFFI.xcframework +/OuisyncService.xcframework /config.sh .DS_Store /.build diff --git a/bindings/swift/Ouisync/Package.resolved b/bindings/swift/Ouisync/Package.resolved new file mode 100644 index 000000000..6651d5099 --- /dev/null +++ b/bindings/swift/Ouisync/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "messagepack.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/a2/MessagePack.swift.git", + "state" : { + "revision" : "27b35fd49e92fcae395bf8ccb233499d89cc7890", + "version" : "4.0.0" + } + } + ], + "version" : 2 +} diff --git a/bindings/swift/OuisyncLib/Package.swift b/bindings/swift/Ouisync/Package.swift similarity index 66% rename from bindings/swift/OuisyncLib/Package.swift rename to bindings/swift/Ouisync/Package.swift index 49a169653..b2309d708 100644 --- a/bindings/swift/OuisyncLib/Package.swift +++ b/bindings/swift/Ouisync/Package.swift @@ -2,30 +2,29 @@ import PackageDescription let package = Package( - name: "OuisyncLib", + name: "Ouisync", platforms: [.macOS(.v13), .iOS(.v16)], products: [ - .library(name: "OuisyncLib", + .library(name: "Ouisync", type: .static, - targets: ["OuisyncLib"]), + targets: ["Ouisync"]), ], dependencies: [ .package(url: "https://github.com/a2/MessagePack.swift.git", from: "4.0.0"), ], targets: [ - .target(name: "OuisyncLib", + .target(name: "Ouisync", dependencies: [.product(name: "MessagePack", package: "MessagePack.swift"), - "FFIBuilder", - "OuisyncLibFFI"], + "CargoBuild", + "OuisyncService"], path: "Sources"), - .testTarget(name: "OuisyncLibTests", - dependencies: ["OuisyncLib"], + .testTarget(name: "OuisyncTests", + dependencies: ["Ouisync"], path: "Tests"), - // FIXME: move this to a separate package / framework - .binaryTarget(name: "OuisyncLibFFI", - path: "OuisyncLibFFI.xcframework"), - .plugin(name: "FFIBuilder", + .binaryTarget(name: "OuisyncService", + path: "OuisyncService.xcframework"), + .plugin(name: "CargoBuild", capability: .buildTool(), path: "Plugins/Builder"), .plugin(name: "Update rust dependencies", diff --git a/bindings/swift/OuisyncLib/Plugins/Builder/builder.swift b/bindings/swift/Ouisync/Plugins/Builder/builder.swift similarity index 62% rename from bindings/swift/OuisyncLib/Plugins/Builder/builder.swift rename to bindings/swift/Ouisync/Plugins/Builder/builder.swift index 99d8b8f22..d12df9e14 100644 --- a/bindings/swift/OuisyncLib/Plugins/Builder/builder.swift +++ b/bindings/swift/Ouisync/Plugins/Builder/builder.swift @@ -22,23 +22,9 @@ import PackagePlugin @main struct Builder: BuildToolPlugin { func createBuildCommands(context: PackagePlugin.PluginContext, target: PackagePlugin.Target) async throws -> [PackagePlugin.Command] { - let build = context.pluginWorkDirectory - - // FIXME: this path is very unstable; we might need to search the tree instead - let update = build - .removingLastComponent() // FFIBuilder - .removingLastComponent() // OuisyncLibFFI - .removingLastComponent() // ouisync.output - .appending("Update rust dependencies.output") - - guard FileManager.default.fileExists(atPath: update.string) else { - Diagnostics.error("Please run `Update rust dependencies` on the OuisyncLib package") - fatalError("Unable to build LibOuisyncFFI.xcframework") - } - - return [.prebuildCommand(displayName: "Build OuisyncLibFFI.xcframework", - executable: context.package.directory.appending(["Plugins", "build.sh"]), - arguments: [update.string, build.string], - outputFilesDirectory: build.appending("dummy"))] + [.prebuildCommand(displayName: "Build OuisyncService.xcframework", + executable: context.package.directory.appending(["Plugins", "build.sh"]), + arguments: [context.pluginWorkDirectory.string], + outputFilesDirectory: context.pluginWorkDirectory.appending("dummy"))] } } diff --git a/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift b/bindings/swift/Ouisync/Plugins/Updater/updater.swift similarity index 100% rename from bindings/swift/OuisyncLib/Plugins/Updater/updater.swift rename to bindings/swift/Ouisync/Plugins/Updater/updater.swift diff --git a/bindings/swift/OuisyncLib/Plugins/build.sh b/bindings/swift/Ouisync/Plugins/build.sh similarity index 66% rename from bindings/swift/OuisyncLib/Plugins/build.sh rename to bindings/swift/Ouisync/Plugins/build.sh index e8eead9e9..f1dba2a93 100755 --- a/bindings/swift/OuisyncLib/Plugins/build.sh +++ b/bindings/swift/Ouisync/Plugins/build.sh @@ -1,29 +1,41 @@ #!/usr/bin/env zsh -# Command line tool which produces a `OuisyncLibFFI` framework for all configured llvm triples from -# OuisyncLib/config.sh (currently generated in the ouisync-app repository) +# Command line tool which produces a OuisyncService xcframework for all llvm +# triples from config.sh (defaults to macos x86_64, macos arm64 and ios arm64) # -# This tool runs in a sandboxed process that cannot access the network, so it relies on the updater -# companion plugin to download the required dependencies ahead of time. Called by the builder plugin -# which passes both plugins' output paths as arguments. Hic sunt dracones! These may be of interest: +# This tool runs in a sandboxed process that cannot access the network, so it +# relies on the updater companion plugin to download the required dependencies +# ahead of time. Hic sunt dracones! These may be of interest: # [1] https://forums.developer.apple.com/forums/thread/666335 # [2] https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/Plugins.md#build-tool-target-dependencies # [3] https://www.amyspark.me/blog/posts/2024/01/10/stripping-rust-libraries.html fatal() { echo "Error $@" && exit $1 } PROJECT_HOME=$(realpath "$(dirname "$0")/../../../../") -export CARGO_HOME=$(realpath "$1") +export MACOSX_DEPLOYMENT_TARGET=13.0 +BUILD_OUTPUT=$(realpath "$1") + +# find the rust toolchain installed by `update.sh` by searching for swift's +# package plugin root directory (called "plugins") in the build output path +cd "$BUILD_OUTPUT"; +while : ; do + PWD=$(basename "$(pwd)") + test "$PWD" != / || fatal 1 "Unable to find swift package plugin root" + test "$PWD" != "plugins" || break + cd .. +done +CROSS=$(find . -path "**/bin/cross" -print -quit) +test -f $CROSS || fatal 2 "Please run `Update rust dependencies` on the Ouisync package" +export CARGO_HOME=$(realpath "$(dirname "$(dirname "$CROSS")")") export PATH="$CARGO_HOME/bin:$PATH" export RUSTUP_HOME="$CARGO_HOME/.rustup" -export MACOSX_DEPLOYMENT_TARGET=13.0 -BUILD_OUTPUT=$(realpath "$2") -# cargo builds some things that confuse xcode such as fingerprints and depfiles which cannot be -# (easily) disabled; additionally, xcode does pick up the xcframework and reports it as a duplicate -# target if present in the output folder, so other than the symlink hack from `update.sh`, we have -# to tell xcode that our output is in an empty `dummy` folder +# cargo builds some things that confuse xcode such as fingerprints and depfiles +# which cannot be (easily) disabled; additionally, xcode does pick up the +# xcframework and reports it as a duplicate target if present in the output +# folder, so we tell xcode that all our output is in this empty `dummy` folder mkdir -p "$BUILD_OUTPUT/dummy" # read config and prepare to build -source "$PROJECT_HOME/bindings/swift/OuisyncLib/config.sh" +source "$PROJECT_HOME/bindings/swift/Ouisync/config.sh" || fatal 1 "Unable to find config file" if [ $SKIP ] && [ $SKIP -gt 0 ]; then exit 0 fi @@ -41,24 +53,24 @@ declare -A TARGETS for TARGET in $LIST[@]; do TARGETS[$TARGET]="" done # build configured targets -cd $PROJECT_HOME +cd "$PROJECT_HOME" for TARGET in ${(k)TARGETS}; do cross build \ --frozen \ --package ouisync-service \ --target $TARGET \ --target-dir "$BUILD_OUTPUT" \ - $FLAGS || fatal 1 "Unable to compile for $TARGET" + $FLAGS || fatal 3 "Unable to compile for $TARGET" done # generate include files INCLUDE="$BUILD_OUTPUT/include" mkdir -p "$INCLUDE" -echo "module OuisyncLibFFI { +echo "module OuisyncService { header \"bindings.h\" export * }" > "$INCLUDE/module.modulemap" -cbindgen --lang C --crate ouisync-service > "$INCLUDE/bindings.h" || fatal 2 "Unable to generate bindings.h" +cbindgen --lang C --crate ouisync-service > "$INCLUDE/bindings.h" || fatal 4 "Unable to generate bindings.h" # hack for autoimporting enums https://stackoverflow.com/questions/60559599/swift-c-api-enum-in-swift perl -i -p0e 's/enum\s+(\w+)([^}]+});\ntypedef (\w+) \1/typedef enum __attribute__\(\(enum_extensibility\(open\)\)\) : \3\2 \1/sg' "$INCLUDE/bindings.h" @@ -78,7 +90,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 @@ -86,19 +98,19 @@ 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 || fatal 3 "Unable to run lipo for ${MATCHED[@]}" + lipo -create $MATCHED[@] -output $LIBRARY || fatal 5 "Unable to run lipo for ${MATCHED[@]}" fi PARAMS+=("-library" "$LIBRARY" "-headers" "$INCLUDE") done # TODO: skip xcodebuild and manually create symlinks instead (faster but Info.plist would be tricky) rm -Rf "$BUILD_OUTPUT/temp.xcframework" -find "$BUILD_OUTPUT/OuisyncLibFFI.xcframework" -mindepth 1 -delete +find "$BUILD_OUTPUT/OuisyncService.xcframework" -mindepth 1 -delete xcodebuild \ -create-xcframework ${PARAMS[@]} \ - -output "$BUILD_OUTPUT/temp.xcframework" || fatal 4 "Unable to build xcframework" + -output "$BUILD_OUTPUT/temp.xcframework" || fatal 6 "Unable to build xcframework" for FILE in $(ls "$BUILD_OUTPUT/temp.xcframework"); do - mv "$BUILD_OUTPUT/temp.xcframework/$FILE" "$BUILD_OUTPUT/OuisyncLibFFI.xcframework/$FILE" + mv "$BUILD_OUTPUT/temp.xcframework/$FILE" "$BUILD_OUTPUT/OuisyncService.xcframework/$FILE" done diff --git a/bindings/swift/OuisyncLib/Plugins/update.sh b/bindings/swift/Ouisync/Plugins/update.sh similarity index 100% rename from bindings/swift/OuisyncLib/Plugins/update.sh rename to bindings/swift/Ouisync/Plugins/update.sh diff --git a/bindings/swift/OuisyncLib/Sources/Client.swift b/bindings/swift/Ouisync/Sources/Client.swift similarity index 99% rename from bindings/swift/OuisyncLib/Sources/Client.swift rename to bindings/swift/Ouisync/Sources/Client.swift index 62d2604bc..fbab798ae 100644 --- a/bindings/swift/OuisyncLib/Sources/Client.swift +++ b/bindings/swift/Ouisync/Sources/Client.swift @@ -21,7 +21,7 @@ import Network sock.start(queue: .main) // generate and send client challenge; 256 bytes is a bit large, but we'll probably... - var clientChallenge = Data.secureRandom(256) // ... reserve portions for non-random headers + let clientChallenge = try Data.secureRandom(256) // ... reserve portions for non-random headers try await send(clientChallenge) // receive server challenge and send proof diff --git a/bindings/swift/OuisyncLib/Sources/Directory.swift b/bindings/swift/Ouisync/Sources/Directory.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/Directory.swift rename to bindings/swift/Ouisync/Sources/Directory.swift diff --git a/bindings/swift/OuisyncLib/Sources/File.swift b/bindings/swift/Ouisync/Sources/File.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/File.swift rename to bindings/swift/Ouisync/Sources/File.swift diff --git a/bindings/swift/OuisyncLib/Sources/Ouisync.swift b/bindings/swift/Ouisync/Sources/Ouisync.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/Ouisync.swift rename to bindings/swift/Ouisync/Sources/Ouisync.swift diff --git a/bindings/swift/OuisyncLib/Sources/Repository.swift b/bindings/swift/Ouisync/Sources/Repository.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/Repository.swift rename to bindings/swift/Ouisync/Sources/Repository.swift diff --git a/bindings/swift/OuisyncLib/Sources/Secret.swift b/bindings/swift/Ouisync/Sources/Secret.swift similarity index 99% rename from bindings/swift/OuisyncLib/Sources/Secret.swift rename to bindings/swift/Ouisync/Sources/Secret.swift index 3c9a7d090..8f7aa550e 100644 --- a/bindings/swift/OuisyncLib/Sources/Secret.swift +++ b/bindings/swift/Ouisync/Sources/Secret.swift @@ -45,7 +45,6 @@ public struct Salt: Secret { public init(_ bytes: Data) { value = .binary(bytes) } /// Generates a random 128-bit nonce as recommended by the Argon2 KDF used by Ouisync. public static var random: Self { get throws { try Self(.secureRandom(16)) } } - } public struct SaltedSecretKey: Secret { diff --git a/bindings/swift/OuisyncLib/Sources/Server.swift b/bindings/swift/Ouisync/Sources/Server.swift similarity index 99% rename from bindings/swift/OuisyncLib/Sources/Server.swift rename to bindings/swift/Ouisync/Sources/Server.swift index d805713c2..a02303701 100644 --- a/bindings/swift/OuisyncLib/Sources/Server.swift +++ b/bindings/swift/Ouisync/Sources/Server.swift @@ -1,6 +1,6 @@ import CryptoKit import Foundation -import OuisyncLibFFI +import OuisyncService extension ErrorCode: Error {} // @retroactive doesn't work in Ventura, which I still use public typealias OuisyncError = ErrorCode diff --git a/bindings/swift/OuisyncLib/Sources/ShareToken.swift b/bindings/swift/Ouisync/Sources/ShareToken.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/ShareToken.swift rename to bindings/swift/Ouisync/Sources/ShareToken.swift diff --git a/bindings/swift/Ouisync/Sources/StateMonitor.swift b/bindings/swift/Ouisync/Sources/StateMonitor.swift new file mode 100644 index 000000000..95328f3be --- /dev/null +++ b/bindings/swift/Ouisync/Sources/StateMonitor.swift @@ -0,0 +1,59 @@ +import Foundation +import MessagePack + + +public extension Client { + var root: StateMonitor { StateMonitor(self, []) } +} + + +public class StateMonitor { + public struct Id: Equatable, Comparable, LosslessStringConvertible { + let name: String + let disambiguator: UInt64 + public var description: String { "\(name):\(disambiguator)" } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name ? lhs.disambiguator < lhs.disambiguator : lhs.name < rhs.name + } + + public init?(_ str: String) { + guard let match = str.lastIndex(of: ":") else { return nil } + name = String(str[.. { + client.subscribe(to: "state_monitor", + with: .array(path.map { .string($0.description) })).map { _ in () } + } + + public func load() async throws { + let res = try await client.invoke("state_monitor_get", + with: .array(path.map { .string($0.description) })) + guard case .array(let arr) = res, arr.count == 2 else { throw OuisyncError.InvalidData } + + values = try .init(uniqueKeysWithValues: arr[0].dictionaryValue.orThrow.map { + try ($0.key.stringValue.orThrow, $0.value.stringValue.orThrow) + }) + children = try arr[1].arrayValue.orThrow.lazy.map { + try StateMonitor(client, path + [Id($0.stringValue.orThrow).orThrow]) + } + } +} diff --git a/bindings/swift/OuisyncLib/Sources/_Structs.swift b/bindings/swift/Ouisync/Sources/_Structs.swift similarity index 100% rename from bindings/swift/OuisyncLib/Sources/_Structs.swift rename to bindings/swift/Ouisync/Sources/_Structs.swift diff --git a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift b/bindings/swift/Ouisync/Tests/OuisyncLibTests.swift similarity index 100% rename from bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift rename to bindings/swift/Ouisync/Tests/OuisyncLibTests.swift diff --git a/bindings/swift/Ouisync/init.sh b/bindings/swift/Ouisync/init.sh new file mode 100755 index 000000000..c85f93ce2 --- /dev/null +++ b/bindings/swift/Ouisync/init.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env zsh +# This tool is used to prepare the swift build environment; it is necessary due +# to an unfortunate combination of known limitations in the swift package +# manager and git's refusal to permit comitted but gitignored "template files" +# This script must be called before attempting the first build + +# Make sure we have Xcode command line tools installed +xcode-select -p || xcode-select --install + +# Swift expects some sort of actual framework in the current folder which we +# mock as an empty library with no headers or data that will be replaced before +# it is actually needed via the prebuild tool called during `swift build` +cd $(dirname "$0") +SRC=".build/plugins/outputs/ouisync/Ouisync/destination/CargoBuild/OuisyncService.xcframework" +mkdir -p $SRC +rm -f "OuisyncService.xcframework" +ln -s $SRC "OuisyncService.xcframework" +cat < "OuisyncService.xcframework/Info.plist" + + + + + AvailableLibraries + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + +EOF + +# Even when done incrementally, rust compilation can take considerable time, +# which is amplified by the number of platforms we have to support. There's no +# way around this in the general case, but it's worthwhile to allow developers +# to focus on a single platform in some cases (e.g. when debugging); obviously +# we would like to gitignore this file, but it must exist, so we create it now. +cat < "config.sh" +DEBUG=0 # set to 1 if you want to run rust assertions (much slower) +TARGETS=( # if you're focused on a single target, feel free to disable others + aarch64-apple-darwin # mac on apple silicon +# x86_64-apple-darwin # mac on intel +# aarch64-apple-ios # all supported devices (ios 11+ are 64 bit only) +# aarch64-apple-ios-sim # simulators when running on M chips +# x86_64-apple-ios # simulator running on intel chips +) +EOF + +# Install rust and pull all dependencies neessary for `swift build` +swift package plugin cargo-fetch --allow-network-connections all From eee0b1d80aa9177f9be228238e5a826615746066 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Wed, 22 Jan 2025 00:31:30 +0200 Subject: [PATCH 13/24] Add trivial tests --- bindings/swift/Ouisync/.gitignore | 3 +- bindings/swift/Ouisync/Package.swift | 6 ++-- bindings/swift/Ouisync/Sources/Server.swift | 28 ++++++++++++++-- .../swift/Ouisync/Tests/OuisyncLibTests.swift | 12 ------- .../swift/Ouisync/Tests/ServerTests.swift | 33 +++++++++++++++++++ bindings/swift/Ouisync/Tests/_Utils.swift | 21 ++++++++++++ 6 files changed, 84 insertions(+), 19 deletions(-) delete mode 100644 bindings/swift/Ouisync/Tests/OuisyncLibTests.swift create mode 100644 bindings/swift/Ouisync/Tests/ServerTests.swift create mode 100644 bindings/swift/Ouisync/Tests/_Utils.swift diff --git a/bindings/swift/Ouisync/.gitignore b/bindings/swift/Ouisync/.gitignore index ffcc782cd..7bf98420e 100644 --- a/bindings/swift/Ouisync/.gitignore +++ b/bindings/swift/Ouisync/.gitignore @@ -5,6 +5,5 @@ /Packages xcuserdata/ DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm .netrc diff --git a/bindings/swift/Ouisync/Package.swift b/bindings/swift/Ouisync/Package.swift index b2309d708..2f65add79 100644 --- a/bindings/swift/Ouisync/Package.swift +++ b/bindings/swift/Ouisync/Package.swift @@ -18,10 +18,12 @@ let package = Package( package: "MessagePack.swift"), "CargoBuild", "OuisyncService"], - path: "Sources"), + path: "Sources", + linkerSettings: [.linkedFramework("SystemConfiguration")]), .testTarget(name: "OuisyncTests", dependencies: ["Ouisync"], - path: "Tests"), + path: "Tests", + linkerSettings: [.linkedFramework("SystemConfiguration")]), .binaryTarget(name: "OuisyncService", path: "OuisyncService.xcframework"), .plugin(name: "CargoBuild", diff --git a/bindings/swift/Ouisync/Sources/Server.swift b/bindings/swift/Ouisync/Sources/Server.swift index a02303701..689dfa14b 100644 --- a/bindings/swift/Ouisync/Sources/Server.swift +++ b/bindings/swift/Ouisync/Sources/Server.swift @@ -5,6 +5,27 @@ import OuisyncService extension ErrorCode: Error {} // @retroactive doesn't work in Ventura, which I still use public typealias OuisyncError = ErrorCode +// FIXME: updating this at runtime is unsafe and should be cast to atomic +public var ouisyncLogHandler: ((LogLevel, String) -> Void)? + +// log_init is not safe to call repeatedly, should only be called once before the first server is +// started and provides awkward memory semantics to assist dart, though we may eventually end up +// using them as well if ever we end up making logging async +private func directLogHandler(_ level: LogLevel, _ message: UnsafePointer?) { + if let message { + // defer { log_free(message) } + ouisyncLogHandler?(level, String(cString: message)) + } + ouisyncLogHandler?(level, "") +} +@MainActor private var loggingConfigured = false +@MainActor private func setupLogging() async throws { + if loggingConfigured { return } + loggingConfigured = true + log_init(nil, directLogHandler, "ouisync") +} + + public class Server { /** Starts a Ouisync server in a new thread and binds it to the port set in `configDir`. * @@ -15,11 +36,12 @@ public class Server { * stop the server once all references are dropped, however this is strongly discouraged since * in this case it's not possible to determine whether the shutdown was successful or not. */ public init(configDir: String, debugLabel: String) async throws { + try await setupLogging() self.configDir = URL(fileURLWithPath: configDir) self.debugLabel = debugLabel - handle = try await withUnsafeThrowingContinuation { - service_start(configDir, debugLabel, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) - } + try await withUnsafeThrowingContinuation { + handle = service_start(configDir, debugLabel, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) + } as Void } /// the configDir passed to the constructor when the server was started public let configDir: URL diff --git a/bindings/swift/Ouisync/Tests/OuisyncLibTests.swift b/bindings/swift/Ouisync/Tests/OuisyncLibTests.swift deleted file mode 100644 index c6b9d946a..000000000 --- a/bindings/swift/Ouisync/Tests/OuisyncLibTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import XCTest -@testable import OuisyncLib - -final class OuisyncLibTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} diff --git a/bindings/swift/Ouisync/Tests/ServerTests.swift b/bindings/swift/Ouisync/Tests/ServerTests.swift new file mode 100644 index 000000000..18c7ccfbf --- /dev/null +++ b/bindings/swift/Ouisync/Tests/ServerTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Ouisync + + +final class SessionTests: XCTestCase { + var server: Server! + + override func setUp() async throws { + server = try await startServer(self) + } + + override func tearDown() async throws { + try await server.destroy() + } + + func testThrowsWhenStartedTwice() async throws { + do { + let server2 = try await startServer(self) + XCTFail("Did not throw") + } catch OuisyncError.ServiceAlreadyRunning { + // expected outcome, other errors should propagate and fail + } + } + + func testMultiSession() async throws { + let client0 = try await server.connect() + let client1 = try await server.connect() + + // this is a bit verbose to do concurrently because XCTAssertEqual uses non-async autoclosures + let pair = try await (client0.currentProtocolVersion, client1.currentProtocolVersion) + XCTAssertEqual(pair.0, pair.1) + } +} diff --git a/bindings/swift/Ouisync/Tests/_Utils.swift b/bindings/swift/Ouisync/Tests/_Utils.swift new file mode 100644 index 000000000..9a9c8b7da --- /dev/null +++ b/bindings/swift/Ouisync/Tests/_Utils.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import Ouisync + + +func startServer(_ test: XCTestCase) async throws -> Server { + ouisyncLogHandler = { level, message in print(message) } + let path = test.name.replacingOccurrences(of: "-[", with: "") + .replacingOccurrences(of: "]", with: "") + .replacingOccurrences(of: " ", with: "_") + let config = URL.temporaryDirectory.appending(path: path, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) + return try await Server(configDir: config.absoluteString, debugLabel: "ouisync") +} + +extension Server { + func destroy() async throws { + print("deleting \(configDir.absoluteString)") + try await stop() + try FileManager.default.removeItem(at: configDir) + } +} From 1ec34c3b6bdb70c0ad5639a6b8219996794e451e Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Fri, 24 Jan 2025 16:00:19 +0200 Subject: [PATCH 14/24] Add tests --- bindings/swift/Ouisync/Sources/Client.swift | 62 ++--- .../swift/Ouisync/Sources/Directory.swift | 8 +- bindings/swift/Ouisync/Sources/File.swift | 16 +- bindings/swift/Ouisync/Sources/Ouisync.swift | 2 +- .../swift/Ouisync/Sources/Repository.swift | 107 +++++---- bindings/swift/Ouisync/Sources/Secret.swift | 52 ++-- bindings/swift/Ouisync/Sources/Server.swift | 18 +- .../swift/Ouisync/Sources/ShareToken.swift | 13 +- .../swift/Ouisync/Sources/StateMonitor.swift | 26 +- bindings/swift/Ouisync/Sources/_Structs.swift | 3 +- .../Ouisync/Tests/MultipleNodesTests.swift | 79 ++++++ .../swift/Ouisync/Tests/RepositoryTests.swift | 227 ++++++++++++++++++ .../swift/Ouisync/Tests/SecretTests.swift | 97 ++++++++ .../swift/Ouisync/Tests/ServerTests.swift | 37 ++- bindings/swift/Ouisync/Tests/_Utils.swift | 45 +++- bindings/swift/Ouisync/cov.sh | 9 + 16 files changed, 637 insertions(+), 164 deletions(-) create mode 100644 bindings/swift/Ouisync/Tests/MultipleNodesTests.swift create mode 100644 bindings/swift/Ouisync/Tests/RepositoryTests.swift create mode 100644 bindings/swift/Ouisync/Tests/SecretTests.swift create mode 100755 bindings/swift/Ouisync/cov.sh diff --git a/bindings/swift/Ouisync/Sources/Client.swift b/bindings/swift/Ouisync/Sources/Client.swift index fbab798ae..4a198ed11 100644 --- a/bindings/swift/Ouisync/Sources/Client.swift +++ b/bindings/swift/Ouisync/Sources/Client.swift @@ -1,6 +1,6 @@ import CryptoKit import Foundation -import MessagePack +@preconcurrency import MessagePack import Network @@ -20,19 +20,19 @@ import Network using: .tcp) sock.start(queue: .main) - // generate and send client challenge; 256 bytes is a bit large, but we'll probably... - let clientChallenge = try Data.secureRandom(256) // ... reserve portions for non-random headers + // generate and send client challenge; 256 bytes is a bit large, but we might reserve... + let clientChallenge = try Data.secureRandom(256) // ... some portions for protocol headers try await send(clientChallenge) - // receive server challenge and send proof - let serverChallenge = try await recv(exactly: clientChallenge.count) - try await send(Data(HMAC.authenticationCode(for: serverChallenge, using: key))) - // receive and validate server proof guard HMAC.isValidAuthenticationCode(try await recv(exactly: SHA256.byteCount), authenticating: clientChallenge, using: key) else { throw CryptoKitError.authenticationFailure } // early eof or key mismatch + // receive server challenge and send proof + let serverChallenge = try await recv(exactly: clientChallenge.count) + try await send(Data(HMAC.authenticationCode(for: serverChallenge, using: key))) + read() // this keeps calling itself until the socket is closed // NWConnection predates concurrency, so we need these to ping-pong during this handshake @@ -109,7 +109,8 @@ import Network guard case .ready = sock.state else { throw OuisyncError.ConnectionAborted } return try await withUnsafeThrowingContinuation { let id = `as` ?? Self.next() - let body = pack(arg) +// print("\(id) -> \(method)(\(arg))") + let body = pack([.string(method): arg]) var message = Data(count: 12) message.withUnsafeMutableBytes { $0.storeBytes(of: UInt32(exactly: body.count + 8)!.bigEndian, as: UInt32.self) @@ -131,21 +132,20 @@ import Network * Completes normally if the `cancel()` method is called while the subscription is active. * * The subscription retains the client and until it either goes out of scope. */ - func subscribe(to topic: String, with arg: MessagePackValue = .nil) -> Subscription { - let id = Self.next() - let result = Subscription { - subscriptions[id] = $0 - $0.onTermination = { _ in Task { @MainActor in + nonisolated func subscribe(to topic: String, with arg: MessagePackValue = .nil) -> Subscription { + Subscription { sub in DispatchQueue.main.async { MainActor.assumeIsolated { + let id = Self.next() + self.subscriptions[id] = sub + sub.onTermination = { _ in Task { @MainActor in self.subscriptions.removeValue(forKey: id) do { try await self.invoke("unsubscribe", with: .uint(id)) } catch { print("Unexpected error during unsubscribe: \(error)") } } } - } - Task { - do { try await invoke("\(topic)_subscribe", with: arg, as: id) } - catch { subscriptions.removeValue(forKey: id)?.finish(throwing: error) } - } - return result + Task { + do { try await self.invoke("\(topic)_subscribe", with: arg, as: id) } + catch { self.subscriptions.removeValue(forKey: id)?.finish(throwing: error) } + } + } } } } public typealias Subscription = AsyncThrowingStream @@ -154,8 +154,9 @@ import Network * Implemented using callbacks here because while continuations are _cheap_, they are not * _free_ and non-main actors are still a bit too thread-hoppy with regards to performance */ private func read() { - sock.receive(minimumIncompleteLength: 12, maximumLength: 12) { header, _ , _, err in - MainActor.assumeIsolated { + sock.receive(minimumIncompleteLength: 12, maximumLength: 12) { + [weak self] header, _ , _, err in MainActor.assumeIsolated { + guard let self else { return } guard err == nil else { return self.abort("Unexpected IO error while reading header: \(err!)") } @@ -166,18 +167,20 @@ import Network var size = Int(0) var id = UInt64(0) header.withUnsafeBytes { - size = Int(UInt32(bigEndian: $0.load(as: UInt32.self))) - id = $0.load(fromByteOffset: 4, as: UInt64.self) + size = Int(UInt32(bigEndian: $0.loadUnaligned(as: UInt32.self))) + id = $0.loadUnaligned(fromByteOffset: 4, as: UInt64.self) } - guard size <= self.limit else { - return self.abort("Received \(size) byte packet which exceeds \(self.limit)") + guard (9...self.limit).contains(size) else { + return self.abort("Received \(size) byte packet (must be in 9...\(self.limit))") } - self.sock.receive(minimumIncompleteLength: size, maximumLength: size) { body, _, _, err in - MainActor.assumeIsolated { + size -= 8 // messageid was already read + self.sock.receive(minimumIncompleteLength: size, maximumLength: size) { + [weak self] body, _, _, err in MainActor.assumeIsolated { + guard let self else { return } guard err == nil else { return self.abort("Unexpected IO error while reading body: \(err!)") } - guard let body, header.count == size else { + guard let body, body.count == size else { return self.abort("Unexpected EOF while reading body") } guard let (message, rest) = try? unpack(body) else { @@ -208,12 +211,13 @@ import Network result = .failure(err) } else { return self.abort("Received unercognized message: \(payload)") } +// print("\(id)<-\(result)") if let callback = self.invocations.removeValue(forKey: id) { callback.resume(with: result) } else if let subscription = self.subscriptions[id] { subscription.yield(with: result) } else { - print("Ignoring unexpected message with \(id)") + print("Ignoring unexpected message with id \(id)") } DispatchQueue.main.async{ self.read() } } diff --git a/bindings/swift/Ouisync/Sources/Directory.swift b/bindings/swift/Ouisync/Sources/Directory.swift index a8df13a79..0f032a4da 100644 --- a/bindings/swift/Ouisync/Sources/Directory.swift +++ b/bindings/swift/Ouisync/Sources/Directory.swift @@ -19,16 +19,16 @@ public extension Repository { /** Creates a new empty directory at `path`. * * Throws `OuisyncError` if `path` already exists of if the parent folder doesn't exist. */ - func createDirectory(at Path: String) async throws -> File { - try await File(self, client.invoke("directory_create", with: ["repository": handle, - "path": .string(path)])) + func createDirectory(at path: String) async throws { + try await client.invoke("directory_create", with: ["repository": handle, + "path": .string(path)]) } /** Remove a directory from `path`. * * If `recursive` is `false` (which is the default), the directory must be empty otherwise an * exception is thrown. Otherwise, the contents of the directory are also removed. */ - func remove(at path: String, recursive: Bool = false) async throws { + func removeDirectory(at path: String, recursive: Bool = false) async throws { try await client.invoke("directory_remove", with: ["repository": handle, "path": .string(path), "recursive": .bool(recursive)]) diff --git a/bindings/swift/Ouisync/Sources/File.swift b/bindings/swift/Ouisync/Sources/File.swift index 17fe83a09..9933b0f0e 100644 --- a/bindings/swift/Ouisync/Sources/File.swift +++ b/bindings/swift/Ouisync/Sources/File.swift @@ -22,7 +22,7 @@ public extension Repository { /** Creates a new file at `path`. * * Throws `OuisyncError` if `path` already exists of if the parent folder doesn't exist. */ - func createFile(at Path: String) async throws -> File { + func createFile(at path: String) async throws -> File { try await File(self, client.invoke("file_create", with: ["repository": handle, "path": .string(path)])) } @@ -61,15 +61,15 @@ public extension File { try await repository.client.invoke("file_read", with: ["file": handle, "offset": .uint(offset), - "size": .uint(size)]).dataValue.orThrow + "len": .uint(size)]).dataValue.orThrow } /// Writes `data` to this file, starting at `offset` - func write(_ data: Data, toOffset offset: UInt64) async throws -> Data { + func write(_ data: Data, toOffset offset: UInt64) async throws { try await repository.client.invoke("file_write", with: ["file": handle, "offset": .uint(offset), - "data": .binary(data)]).dataValue.orThrow + "data": .binary(data)]) } /// Flushes any pending writes to persistent storage. @@ -77,13 +77,13 @@ public extension File { try await repository.client.invoke("file_flush", with: handle) } - /// Truncates the file to `len` bytes. - func truncate(to len: UInt64 = 0) async throws { + /// Truncates the file to `length` bytes. + func truncate(to length: UInt64 = 0) async throws { try await repository.client.invoke("file_truncate", with: ["file": handle, - "len": .uint(len)]) + "len": .uint(length)]) } - var len: UInt64 { get async throws { + var length: UInt64 { get async throws { try await repository.client.invoke("file_len", with: handle).uint64Value.orThrow } } diff --git a/bindings/swift/Ouisync/Sources/Ouisync.swift b/bindings/swift/Ouisync/Sources/Ouisync.swift index 61685861d..f0b88622a 100644 --- a/bindings/swift/Ouisync/Sources/Ouisync.swift +++ b/bindings/swift/Ouisync/Sources/Ouisync.swift @@ -59,7 +59,7 @@ public extension Client { try await invoke("network_set_local_discovery_enabled", with: .bool(enabled)) } - var networkEvents: AsyncThrowingMapSequence { + nonisolated var networkEvents: AsyncThrowingMapSequence { subscribe(to: "network").map { try NetworkEvent(rawValue: $0.uint8Value.orThrow).orThrow } } diff --git a/bindings/swift/Ouisync/Sources/Repository.swift b/bindings/swift/Ouisync/Sources/Repository.swift index a2ec80d4d..5586a3fac 100644 --- a/bindings/swift/Ouisync/Sources/Repository.swift +++ b/bindings/swift/Ouisync/Sources/Repository.swift @@ -8,6 +8,9 @@ public extension Client { * If a `token` is provided, the operation will be an `import`. Otherwise, a new, empty, * fully writable repository is created at `path`. * + * If `path` is not absolute, it is interpreted as relative to `storeDir`. If it does not end + * with `".ouisyncdb"`, it will be added to the resulting `Repository.path` + * * The optional `readSecret` and `writeSecret` are intended to function as a second * authentication factor and are used to encrypt the repository's true access keys. Secrets * are ignored when the underlying `token` doesn't contain the corresponding key (e.g. for @@ -17,35 +20,48 @@ public extension Client { * when a `readSecret` is set. You may reuse the same secret for both values. */ func createRepository(at path: String, importingFrom token: ShareToken? = nil, - readSecret: Secret? = nil, - writeSecret: Secret? = nil) async throws { + readSecret: CreateSecret? = nil, + writeSecret: CreateSecret? = nil) async throws -> Repository { // FIXME: the backend does buggy things here, so we bail out; see also `unsafeSetSecrets` - if readSecret != nil && writeSecret == nil { throw OuisyncError.Unsupported } - try await invoke("repository_create", with: ["path": .string(path), - "read_secret": readSecret?.value ?? .nil, - "write_secret": writeSecret?.value ?? .nil, - "token": token?.value ?? .nil, - "sync_enabled": false, - "dht_enabled": false, - "pex_enabled": false]) + if readSecret != nil && writeSecret == nil { throw OuisyncError.InvalidInput } + return try await Repository(self, invoke("repository_create", + with: ["path": .string(path), + "read_secret": readSecret?.value ?? .nil, + "write_secret": writeSecret?.value ?? .nil, + "token": token?.value ?? .nil, + "sync_enabled": false, + "dht_enabled": false, + "pex_enabled": false])) } - /** Opens an existing repository from a `path`, optionally using a known `secret`. + // FIXME: bring this back if we decide on different open / close semantics + /* /** Opens an existing repository from a `path`, optionally using a known `secret`. * * If the same repository is opened again, a new handle pointing to the same underlying * repository is returned. Closed automatically when all references go out of scope. */ - func openRepository(at path: String, using secret: Secret? = nil) async throws -> Repository { + func openRepository(at path: String, using secret: OpenSecret? = nil) async throws -> Repository { try await Repository(self, invoke("repository_open", with: ["path": .string(path), - "secret": secret?.value ?? .nil, - "sync_enabled": false])) - } + "secret": secret?.value ?? .nil])) + } */ /// All currently open repositories. var repositories: [Repository] { get async throws { - try await invoke("repository_list").arrayValue.orThrow.map { try Repository(self, $0) } + try await invoke("repository_list").dictionaryValue.orThrow.values.map { try Repository(self, $0) } } } } +/// A typed remotely stored dictionary that supports multi-value atomic updates +public protocol Metadata where Key: Hashable { + associatedtype Key + associatedtype Value + + // Returns the remote value for `key` or nil if it doesn't exist + subscript(_ key: Key) -> Value? { get async throws } + + /// Performs an atomic CAS on `edits`, returning `true` if all updates were successful + func update(_ edits: [Key: (from: Value?, to: Value?)]) async throws -> Bool +} + public class Repository { let client: Client @@ -56,11 +72,12 @@ public class Repository { self.handle = handle } - deinit { + // FIXME: bring this back if we decide on different open / close semantics + /* deinit { // we're going out of scope so we need to copy the state that the async closure will capture let client = client, handle = handle Task { try await client.invoke("repository_close", with: handle) } - } + } */ } @@ -113,10 +130,10 @@ public extension Repository { try await AccessMode(rawValue: client.invoke("repository_get_access_mode", with: handle).uint8Value.orThrow).orThrow } } - func setAccessMode(to mode: AccessMode, using secret: Secret? = nil) async throws { + func setAccessMode(to mode: AccessMode, using secret: OpenSecret? = nil) async throws { try await client.invoke("repository_set_access_mode", with: ["repository": handle, - "access_mode": .uint(UInt64(mode.rawValue)), + "mode": .uint(UInt64(mode.rawValue)), "secret": secret?.value ?? .nil]) } @@ -140,8 +157,8 @@ public extension Repository { } /// This is a lot of overhead for a glorified event handler - @MainActor var events: AsyncThrowingMapSequence { - client.subscribe(to: "repository").map { _ in () } + var events: AsyncThrowingMapSequence { + client.subscribe(to: "repository", with: handle).map { _ in () } } var dht: Bool { get async throws { @@ -161,7 +178,7 @@ public extension Repository { } /// Create a share token providing access to this repository with the given mode. - func share(for mode: AccessMode, using secret: Secret? = nil) async throws -> ShareToken { + func share(for mode: AccessMode, using secret: OpenSecret? = nil) async throws -> ShareToken { try await ShareToken(client, client.invoke("repository_share", with: ["repository": handle, "secret": secret?.value ?? .nil, @@ -195,21 +212,29 @@ public extension Repository { "host": .string(host)]) } - func metadata(for key: String) async throws -> String? { - let res = try await client.invoke("repository_get_metadata", with: ["repository": handle, - "key": .string(key)]) - if case .nil = res { return nil } - return try res.stringValue.orThrow - } - - /// Performs an (presumably atomic) CAS on `edits`, returning `true` if they were updated - func updateMetadata(with edits: [String:(from: String, to: String)]) async throws -> Bool { - try await client.invoke("repository_set_metadata", - with: ["repository": handle, - "edits": .array(edits.map {["key": .string($0.key), - "old": .string($0.value.from), - "new": .string($0.value.to)]})] - ).boolValue.orThrow + var metadata: any Metadata { get { Meta(repository: self) } } + private struct Meta: Metadata { + @usableFromInline let repository: Repository + public subscript(_ key: String) -> String? { get async throws { + let res = try await repository.client.invoke("repository_get_metadata", + with: ["repository": repository.handle, + "key": .string(key)]) + if case .nil = res { return nil } + return try res.stringValue.orThrow + } } + + @usableFromInline static func cast(e: (String, (String?, String?))) -> MessagePackValue {[ + "key": .string(e.0), + "old": e.1.0 == nil ? .nil : .string(e.1.0!), + "new": e.1.1 == nil ? .nil : .string(e.1.1!) + ]} + + public func update(_ edits: [String: (from: String?, to: String?)]) async throws -> Bool { + try await repository.client.invoke("repository_set_metadata", + with: ["repository": repository.handle, + "edits": .array(edits.map(Self.cast))] + ).boolValue.orThrow + } } /// Mount the repository if supported by the platform. @@ -244,8 +269,8 @@ public extension Repository { * `Known issue`: keeping an existing `read password` while removing the `write password` can * result in a `read-only` repository. If you break it, you get to keep all the pieces! */ - func unsafeSetSecrets(readSecret: Secret? = KEEP_EXISTING, - writeSecret: Secret? = KEEP_EXISTING) async throws { + func unsafeSetSecrets(readSecret: CreateSecret? = KEEP_EXISTING, + writeSecret: CreateSecret? = KEEP_EXISTING) async throws { // FIXME: the implementation requires a distinction between "no key" and "remove key"... /** ...which is currently implemented in terms of a `well known` default value * @@ -257,7 +282,7 @@ public extension Repository { * * If we agree that a secret of length `0` means `no password`, we can then use `nil` here * as a default argument for `keep existing secret` on both sides of the ffi */ - func encode(_ arg: Secret?) -> MessagePackValue { + func encode(_ arg: CreateSecret?) -> MessagePackValue { guard let arg else { return .string("disable") } return arg.value == KEEP_EXISTING.value ? .nil : ["enable": readSecret!.value] } diff --git a/bindings/swift/Ouisync/Sources/Secret.swift b/bindings/swift/Ouisync/Sources/Secret.swift index 8f7aa550e..0f66cc70d 100644 --- a/bindings/swift/Ouisync/Sources/Secret.swift +++ b/bindings/swift/Ouisync/Sources/Secret.swift @@ -19,38 +19,49 @@ import MessagePack * each repository database individually. * * Since secrets should not be logged by default, we require (but provide a default implementation - * for) `CustomDebugStringConvertible` conformance + * for) `CustomDebugStringConvertible` conformance. */ -public protocol Secret: CustomDebugStringConvertible { - var value: MessagePackValue { get } -} +public protocol Secret: CustomDebugStringConvertible {} public extension Secret { var debugDescription: String { "\(Self.self)(***)" } } +/// A secret that can be passed to `createRepository()` & friends +public protocol CreateSecret: Secret { + var value: MessagePackValue { get } +} +/// A secret that can be passed to `openRepository()` & friends +public protocol OpenSecret: Secret { + var value: MessagePackValue { get } +} -public struct Password: Secret { - public let value: MessagePackValue - public init(_ string: String) { value = ["password": .string(string)] } +public struct Password: Secret, CreateSecret, OpenSecret { + let string: String + public var value: MessagePackValue { ["password": .string(string)] } + public init(_ value: String) { string = value } } -public struct SecretKey: Secret { - public let value: MessagePackValue - public init(_ bytes: Data) { value = ["secret_key": .binary(bytes)] } +public struct SecretKey: Secret, OpenSecret { + let bytes: Data + public var value: MessagePackValue { ["secret_key": .binary(bytes)] } + public init(_ value: Data) { bytes = value } /// Generates a random 256-bit key as required by the ChaCha20 implementation Ouisync is using. public static var random: Self { get throws { try Self(.secureRandom(32)) } } } public struct Salt: Secret { - public let value: MessagePackValue - public init(_ bytes: Data) { value = .binary(bytes) } + let bytes: Data + public var value: MessagePackValue { .binary(bytes) } + public init(_ value: Data) {bytes = value } /// Generates a random 128-bit nonce as recommended by the Argon2 KDF used by Ouisync. public static var random: Self { get throws { try Self(.secureRandom(16)) } } } -public struct SaltedSecretKey: Secret { - public let value: MessagePackValue - public init(_ key: SecretKey, _ salt: Salt) { value = ["key_and_salt": ["key": key.value, - "salt": salt.value]] } +public struct SaltedSecretKey: Secret, CreateSecret { + public let key: SecretKey + public let salt: Salt + public var value: MessagePackValue { ["key_and_salt": ["key": .binary(key.bytes), + "salt": .binary(salt.bytes)]] } + public init(_ key: SecretKey, _ salt: Salt) { self.key = key; self.salt = salt } /// Generates a random 256-bit key and a random 128-bit salt public static var random: Self { get throws { try Self(.random, .random) } } @@ -81,9 +92,10 @@ public extension Client { } /// Remotely derive a `SecretKey` from `password` and `salt` using a secure KDF - func deriveSecretKey(from password: Password, with salt: Salt) async throws -> SecretKey { - try await SecretKey(invoke("password_derive_secret_key", - with: ["password": password.value, - "salt": salt.value]).dataValue.orThrow) + func deriveSecretKey(from password: Password, with salt: Salt) async throws -> SaltedSecretKey { + let key = try await SecretKey(invoke("password_derive_secret_key", + with: ["password": .string(password.string), + "salt": salt.value]).dataValue.orThrow) + return SaltedSecretKey(key, salt) } } diff --git a/bindings/swift/Ouisync/Sources/Server.swift b/bindings/swift/Ouisync/Sources/Server.swift index 9e779c248..9904a468d 100644 --- a/bindings/swift/Ouisync/Sources/Server.swift +++ b/bindings/swift/Ouisync/Sources/Server.swift @@ -35,32 +35,32 @@ public class Server { * in this case it's not possible to determine whether the shutdown was successful or not. */ public init(configDir: String, debugLabel: String) async throws { try await setupLogging() - self.configDir = URL(fileURLWithPath: configDir) + self.configDir = configDir self.debugLabel = debugLabel try await withUnsafeThrowingContinuation { handle = start_service(configDir, debugLabel, Resume, unsafeBitCast($0, to: UnsafeRawPointer.self)) } as Void } - /// the configDir passed to the constructor when the server was started - public let configDir: URL - /// the debugLabel passed to the constructor when the server was started + /// the `configDir` passed to the constructor when the server was started + public let configDir: String + /// the `debugLabel` passed to the constructor when the server was started public let debugLabel: String - /// The localhost port that can be used to interact with the server + /// The localhost `port` that can be used to interact with the server (IPv4) public var port: UInt16 { get async throws { try JSONDecoder().decode(UInt16.self, from: - Data(contentsOf: configDir.appending(component: "local_control_port.conf"))) + Data(contentsOf: URL(fileURLWithPath: configDir.appending("/local_control_port.conf")))) } } /// The HMAC key required to authenticate to the server listening on `port` public var authKey: SymmetricKey { get async throws { - let file = configDir.appending(component: "local_control_auth_key.conf") + let file = URL(fileURLWithPath: configDir.appending("/local_control_auth_key.conf")) let str = try JSONDecoder().decode(String.self, from: Data(contentsOf: file)) guard str.count&1 == 0 else { throw CryptoKitError.incorrectParameterSize } // unfortunately, swift doesn't provide an (easy) way to do hex decoding var curr = str.startIndex return try SymmetricKey(data: (0..>1).map { _ in - let next = str.index(after: curr) + let next = str.index(curr, offsetBy: 2) defer { curr = next } - if let res = UInt8(str[curr...next], radix: 16) { return res } + if let res = UInt8(str[curr.. { +public extension StateMonitor { + var changes: AsyncThrowingMapSequence { client.subscribe(to: "state_monitor", with: .array(path.map { .string($0.description) })).map { _ in () } } - public func load() async throws { + func load() async throws -> Bool { let res = try await client.invoke("state_monitor_get", - with: .array(path.map { .string($0.description) })) + with: .array(path.map { .string($0.description) })) + if case .nil = res { return false } guard case .array(let arr) = res, arr.count == 2 else { throw OuisyncError.InvalidData } values = try .init(uniqueKeysWithValues: arr[0].dictionaryValue.orThrow.map { try ($0.key.stringValue.orThrow, $0.value.stringValue.orThrow) }) children = try arr[1].arrayValue.orThrow.lazy.map { - try StateMonitor(client, path + [Id($0.stringValue.orThrow).orThrow]) + try StateMonitor(client, path + [Id($0.stringValue.orThrow)]) } + return true } } diff --git a/bindings/swift/Ouisync/Sources/_Structs.swift b/bindings/swift/Ouisync/Sources/_Structs.swift index 3cde846b2..7775fde34 100644 --- a/bindings/swift/Ouisync/Sources/_Structs.swift +++ b/bindings/swift/Ouisync/Sources/_Structs.swift @@ -40,9 +40,10 @@ public struct PeerInfo { if let kind = arr[2].uint8Value { state = try PeerStateKind(rawValue: kind).orThrow runtimeId = nil - } else if let arr = arr[2].arrayValue, arr.count == 2 { + } else if let arr = arr[2].arrayValue, arr.count >= 2 { state = try PeerStateKind(rawValue: arr[0].uint8Value.orThrow).orThrow runtimeId = try arr[1].dataValue.orThrow.map({ String(format: "%02hhx", $0) }).joined() + // FIXME: arr[3] seems to be an undocumented timestamp in milliseconds } else { throw OuisyncError.InvalidData } diff --git a/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift b/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift new file mode 100644 index 000000000..0b5829799 --- /dev/null +++ b/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift @@ -0,0 +1,79 @@ +import XCTest +import Ouisync + + +final class MultiplenodesTests: XCTestCase { + var server1: Server!, client1: Client!, temp1: String! + var server2: Server!, client2: Client!, temp2: String! + var repo1, repo2: Repository! + override func setUp() async throws { + (server1, client1, temp1) = try await startServer(self, suffix: "-1") + repo1 = try await client1.createRepository(at: "repo1") + try await repo1.setSyncing(enabled: true) + let token = try await repo1.share(for: .Write) + try await client1.bindNetwork(to: ["quic/127.0.0.1:0"]) + + (server2, client2, temp2) = try await startServer(self, suffix: "-2") + repo2 = try await client2.createRepository(at: "repo2", importingFrom: token) + try await repo2.setSyncing(enabled: true) + try await client2.bindNetwork(to: ["quic/127.0.0.1:0"]) + } + override func tearDown() async throws { + var err: (any Error)! + do { try await cleanupServer(server1, temp1) } catch { err = error } + do { try await cleanupServer(server2, temp2) } catch { err = error } + if err != nil { throw err } + } + + func testNotificationOnSync() async throws { + // expect one event for each block created (one for the root directory and one for the file) + let stream = Task { + var count = 0 + for try await _ in repo2.events { + count += 1 + if count == 2 { break } + } + } + try await client2.addUserProvidedPeers(from: client1.listenerAddrs) + _ = try await repo1.createFile(at: "file.txt") + try await stream.value + } + + func testNotificationOnPeersChange() async throws { + let addr = try await client1.listenerAddrs[0] + let stream = Task { + for try await _ in client2.networkEvents { + for peer in try await client2.peers { + if peer.addr == addr, + case .UserProvided = peer.source, + case .Active = peer.state, + let _ = peer.runtimeId { return } + } + } + } + try await client2.addUserProvidedPeers(from: [addr]) + try await stream.value + } + + func testNetworkStats() async throws { + let addr = try await client1.listenerAddrs[0] + try await client2.addUserProvidedPeers(from: [addr]) + try await repo1.createFile(at: "file.txt").flush() + + // wait for the file to get synced + for try await _ in repo2.events { + do { + _ = try await repo2.openFile(at: "file.txt") + break + } catch OuisyncError.NotFound, OuisyncError.StoreError { + // FIXME: why does this also throw StoreError? + continue + } + } + + let stats = try await client2.networkStats + XCTAssertGreaterThan(stats.bytesTx, 0) + XCTAssertGreaterThan(stats.bytesRx, 65536) // at least two blocks received + } +} + diff --git a/bindings/swift/Ouisync/Tests/RepositoryTests.swift b/bindings/swift/Ouisync/Tests/RepositoryTests.swift new file mode 100644 index 000000000..6d2bcf928 --- /dev/null +++ b/bindings/swift/Ouisync/Tests/RepositoryTests.swift @@ -0,0 +1,227 @@ +import XCTest +import Ouisync + + +final class RepositoryTests: XCTestCase { + var server: Server!, client: Client!, temp: String! + override func setUp() async throws { (server, client, temp) = try await startServer(self) } + override func tearDown() async throws { try await cleanupServer(server, temp) } + + func testList() async throws { + var repos = try await client.repositories + XCTAssertEqual(repos.count, 0) + let repo = try await client.createRepository(at: "foo") + repos = try await client.repositories + XCTAssertEqual(repos.count, 1) + let id1 = try await repos[0].infoHash + let id2 = try await repo.infoHash + XCTAssertEqual(id1, id2) + } + + func testFileIO() async throws { + let repo = try await client.createRepository(at: "bar") + let f1 = try await repo.createFile(at: "/test.txt") + let send = "hello world".data(using: .utf8)! + try await f1.write(send, toOffset: 0) + try await f1.flush() + let f2 = try await repo.openFile(at: "/test.txt") + let recv = try await f2.read(f2.length, fromOffset: 0) + XCTAssertEqual(send, recv) + } + + func testDirectoryCreateAndRemove() async throws { + let repo = try await client.createRepository(at: "baz") + var stat = try await repo.entryType(at: "dir") + XCTAssertEqual(stat, .none) + var entries = try await repo.listDirectory(at: "/") + XCTAssertEqual(entries.count, 0) + + try await repo.createDirectory(at: "dir") + stat = try await repo.entryType(at: "dir") + XCTAssertEqual(stat, .Directory) + entries = try await repo.listDirectory(at: "/") + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].name, "dir") + + try await repo.removeDirectory(at: "dir") + stat = try await repo.entryType(at: "dir") + XCTAssertEqual(stat, .none) + entries = try await repo.listDirectory(at: "/") + XCTAssertEqual(entries.count, 0) + } + + func testExternalRename() async throws { + var repo = try await client.createRepository(at: "old") + var files = try await repo.listDirectory(at: "/") + XCTAssertEqual(files.count, 0) + var file = try await repo.createFile(at: "file.txt") + let send = "hello world".data(using: .utf8)! + try await file.write(send, toOffset: 0) + try await file.flush() + files = try await repo.listDirectory(at: "/") + XCTAssertEqual(files.count, 1) + try await server.stop() + + // manually move the repo database + let src = temp + "/store/old.ouisyncdb" + let dst = temp + "/store/new.ouisyncdb" + let fm = FileManager.default + try fm.moveItem(atPath: src, toPath: dst) + // optionally also move the wal and shmem logs if enabled + for tail in ["-wal", "-shm"] { try? fm.moveItem(atPath: src + tail, toPath: dst + tail) } + + // restart server and ensure repo is opened from the new location + (server, client, _) = try await startServer(self) + let repos = try await client.repositories + XCTAssertEqual(repos.count, 1) + repo = repos[0] + let path = try await repo.path + XCTAssert(path.hasSuffix("new.ouisyncdb")) + + // make sure the file was moved as well + files = try await repo.listDirectory(at: "/") + XCTAssertEqual(files.count, 1) + file = try await repo.openFile(at: "file.txt") + let recv = try await file.read(file.length, fromOffset: 0) + XCTAssertEqual(send, recv) + } + + func testDropsCredentialsOnRestart() async throws { + // create a new password-protected repository + let pass = Password("foo") + var repo = try await client.createRepository(at: "bip", readSecret: pass, writeSecret: pass) + var mode = try await repo.accessMode + XCTAssertEqual(mode, .Write) + + // test that credentials are not persisted across server restarts + try await server.stop() + (server, client, _) = try await startServer(self) + repo = try await client.repositories[0] + mode = try await repo.accessMode + XCTAssertEqual(mode, .Blind) + + // check that the credentials are however stored to disk + try await repo.setAccessMode(to: .Write, using: pass) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Write) + } + + func testMetadata() async throws { + let repo = try await client.createRepository(at: "bop") + var val = try await repo.metadata["test.foo"] + XCTAssertNil(val) + val = try await repo.metadata["test.bar"] + XCTAssertNil(val) + var changed = try await repo.metadata.update(["test.foo": (from: nil, to: "foo 1"), + "test.bar": (from: nil, to: "bar 1")]) + XCTAssert(changed) + val = try await repo.metadata["test.foo"] + XCTAssertEqual(val, "foo 1") + val = try await repo.metadata["test.bar"] + XCTAssertEqual(val, "bar 1") + + // `from` and `to` are optional but recommended for readability (as seen below): + changed = try await repo.metadata.update(["test.foo": ("foo 1", to: "foo 2"), + "test.bar": (from: "bar 1", nil)]) + XCTAssert(changed) + val = try await repo.metadata["test.foo"] + XCTAssertEqual(val, "foo 2") + val = try await repo.metadata["test.bar"] + XCTAssertNil(val) + + // old value mismatch + changed = try await repo.metadata.update(["test.foo": (from: "foo 1", to: "foo 3")]) + XCTAssert(!changed) + val = try await repo.metadata["test.foo"] + XCTAssertEqual(val, "foo 2") + + // multi-value updates are rolled back atomically + changed = try await repo.metadata.update(["test.foo": (from: "foo 1", to: "foo 4"), + "test.bar": (from: nil, to: "bar 4")]) + XCTAssert(!changed) + val = try await repo.metadata["test.bar"] + XCTAssertNil(val) + } + + func testShareTokens() async throws { + let repo = try await client.createRepository(at: "pop") + var validToken: String! + + // test sharing returns a corresponding token + for src in [AccessMode.Blind, AccessMode.Read, AccessMode.Write] { + let token = try await repo.share(for: src) + let dst = try await token.accessMode + validToken = token.string // keep a ref to a valid token + XCTAssertEqual(src, dst) + } + + // ensure that valid tokens are parsed correctly + let tok = try await client.shareToken(fromString: validToken) + let mode = try await tok.accessMode + XCTAssertEqual(mode, .Write) + let name = try await tok.suggestedName + XCTAssertEqual(name, "pop") + + // ensure that the returned infohash appears valid + let hash = try await tok.infoHash + XCTAssertNotNil(hash.wholeMatch(of: try! Regex("[0-9a-fA-F]{40}"))) + + // ensure that invalid tokens throw an error + // FIXME: should throw .InvalidInput instead + try await XCTAssertThrows(try await client.shareToken(fromString: "broken!@#%"), + OuisyncError.InvalidData) + } + + func testUserProvidedPeers() async throws { + var peers = try await client.userProvidedPeers + XCTAssertEqual(peers.count, 0) + + try await client.addUserProvidedPeers(from: ["quic/127.0.0.1:12345", + "quic/127.0.0.2:54321"]) + peers = try await client.userProvidedPeers + XCTAssertEqual(peers.count, 2) + + try await client.removeUserProvidedPeers(from: ["quic/127.0.0.2:54321", + "quic/127.0.0.2:13337"]) + peers = try await client.userProvidedPeers + XCTAssertEqual(peers.count, 1) + XCTAssertEqual(peers[0], "quic/127.0.0.1:12345") + + try await client.removeUserProvidedPeers(from: ["quic/127.0.0.1:12345"]) + peers = try await client.userProvidedPeers + XCTAssertEqual(peers.count, 0) + } + + func testPadCoverage() async throws { + // these are ripped from `ouisync_test.dart` and don't really test much other than ensuring + // that their underlying remote procedure calls can work under the right circumstances + let repo = try await client.createRepository(at: "mop") + + // sync progress + let progress = try await repo.syncProgress + XCTAssertEqual(progress.value, 0) + XCTAssertEqual(progress.total, 0) + + // state monitor + let root = await client.root + XCTAssertEqual(root.children.count, 0) + let exists = try await root.load() + XCTAssert(exists) + XCTAssertEqual(root.children.count, 3) + } + + func testNetwork() async throws { + try XCTSkipUnless(envFlag("INCLUDE_SLOW")) + + try await client.bindNetwork(to: ["quic/0.0.0.0:0", "quic/[::]:0"]) + async let v4 = client.externalAddressV4 + async let v6 = client.externalAddressV4 + async let pnat = client.natBehavior + let (ipv4, ipv6, nat) = try await (v4, v6, pnat) + XCTAssertFalse(ipv4.isEmpty) + XCTAssertFalse(ipv6.isEmpty) + XCTAssert(["endpoint independent", + "address dependent", + "address and port dependent"].contains(nat)) + } +} diff --git a/bindings/swift/Ouisync/Tests/SecretTests.swift b/bindings/swift/Ouisync/Tests/SecretTests.swift new file mode 100644 index 000000000..239a2faa9 --- /dev/null +++ b/bindings/swift/Ouisync/Tests/SecretTests.swift @@ -0,0 +1,97 @@ +import XCTest +import Ouisync + + +fileprivate extension Repository { + func dropCredentials() async throws { + try await setAccessMode(to: .Blind) + let mode = try await accessMode + XCTAssertEqual(mode, .Blind) + } +} + + +final class SecretTests: XCTestCase { + var server: Server!, client: Client!, temp: String! + override func setUp() async throws { (server, client, temp) = try await startServer(self) } + override func tearDown() async throws { try await cleanupServer(server, temp) } + + func testOpeningRepoUsingKeys() async throws { + let readSecret = try SaltedSecretKey.random + let writeSecret = try SaltedSecretKey.random + let repo = try await client.createRepository(at: "repo1", + readSecret: readSecret, + writeSecret: writeSecret) + // opened for write by default + var mode = try await repo.accessMode + XCTAssertEqual(mode, .Write) + try await repo.dropCredentials() + + // reopen for reading using a read key + try await repo.setAccessMode(to: .Read, using: readSecret.key) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Read) + try await repo.dropCredentials() + + // reopen for reading using a write key + try await repo.setAccessMode(to: .Read, using: writeSecret.key) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Read) + try await repo.dropCredentials() + + // attempt reopen for writing using a read key (fails but defaults to read) + try await repo.setAccessMode(to: .Write, using: readSecret.key) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Read) + try await repo.dropCredentials() + + // attempt reopen for writing using a write key + try await repo.setAccessMode(to: .Write, using: writeSecret.key) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Write) + try await repo.dropCredentials() + } + + func testCreateUsingKeyOpenWithPassword() async throws { + let readPassword = Password("foo") + let writePassword = Password("bar") + + let readSalt = try await client.generateSalt() + let writeSalt = try Salt.random + + let readKey = try await client.deriveSecretKey(from: readPassword, with: readSalt) + let writeKey = try await client.deriveSecretKey(from: writePassword, with: writeSalt) + + let repo = try await client.createRepository(at: "repo2", readSecret: readKey, + writeSecret: writeKey) + + // opened for write by default + var mode = try await repo.accessMode + XCTAssertEqual(mode, .Write) + try await repo.dropCredentials() + + // reopen for reading using the read password + try await repo.setAccessMode(to: .Read, using: readPassword) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Read) + try await repo.dropCredentials() + + // reopen for reading using the write password + try await repo.setAccessMode(to: .Read, using: writePassword) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Read) + try await repo.dropCredentials() + + // attempt reopen for writing using the read password (fails but defaults to read) + try await repo.setAccessMode(to: .Write, using: readPassword) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Read) + try await repo.dropCredentials() + + // attempt reopen for writing using the write password + try await repo.setAccessMode(to: .Write, using: writePassword) + mode = try await repo.accessMode + XCTAssertEqual(mode, .Write) + try await repo.dropCredentials() + } +} diff --git a/bindings/swift/Ouisync/Tests/ServerTests.swift b/bindings/swift/Ouisync/Tests/ServerTests.swift index 18c7ccfbf..b0a1e5886 100644 --- a/bindings/swift/Ouisync/Tests/ServerTests.swift +++ b/bindings/swift/Ouisync/Tests/ServerTests.swift @@ -1,33 +1,28 @@ import XCTest -@testable import Ouisync +import Ouisync final class SessionTests: XCTestCase { - var server: Server! - - override func setUp() async throws { - server = try await startServer(self) - } - - override func tearDown() async throws { - try await server.destroy() - } + var server: Server!, client: Client!, temp: String! + override func setUp() async throws { (server, client, temp) = try await startServer(self) } + override func tearDown() async throws { try await cleanupServer(server, temp) } func testThrowsWhenStartedTwice() async throws { - do { - let server2 = try await startServer(self) - XCTFail("Did not throw") - } catch OuisyncError.ServiceAlreadyRunning { - // expected outcome, other errors should propagate and fail - } + try await XCTAssertThrows(try await startServer(self), OuisyncError.ServiceAlreadyRunning) } - func testMultiSession() async throws { - let client0 = try await server.connect() - let client1 = try await server.connect() + func testMultiClient() async throws { + let other = try await server.connect() - // this is a bit verbose to do concurrently because XCTAssertEqual uses non-async autoclosures - let pair = try await (client0.currentProtocolVersion, client1.currentProtocolVersion) + // this is a bit verbose to do concurrently because XCTAssert* uses non-async autoclosures + async let future0 = client.currentProtocolVersion + async let future1 = other.currentProtocolVersion + let pair = try await (future0, future1) XCTAssertEqual(pair.0, pair.1) } + + func testUseAfterClose() async throws { + try await server.stop() + try await XCTAssertThrows(try await client.runtimeId, OuisyncError.ConnectionAborted) + } } diff --git a/bindings/swift/Ouisync/Tests/_Utils.swift b/bindings/swift/Ouisync/Tests/_Utils.swift index 9a9c8b7da..5fa77a807 100644 --- a/bindings/swift/Ouisync/Tests/_Utils.swift +++ b/bindings/swift/Ouisync/Tests/_Utils.swift @@ -1,21 +1,40 @@ +import Ouisync import XCTest -@testable import Ouisync -func startServer(_ test: XCTestCase) async throws -> Server { - ouisyncLogHandler = { level, message in print(message) } +func startServer(_ test: XCTestCase, suffix: String = "") async throws -> (Server, Client, String) { + //ouisyncLogHandler = { level, message in print(message) } let path = test.name.replacingOccurrences(of: "-[", with: "") .replacingOccurrences(of: "]", with: "") - .replacingOccurrences(of: " ", with: "_") - let config = URL.temporaryDirectory.appending(path: path, directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) - return try await Server(configDir: config.absoluteString, debugLabel: "ouisync") + .replacingOccurrences(of: " ", with: "_") + suffix + let temp = URL.temporaryDirectory.path(percentEncoded: true).appending(path) + try FileManager.default.createDirectory(atPath: temp, withIntermediateDirectories: true) + let server = try await Server(configDir: temp.appending("/config"), debugLabel: "ouisync") + let client = try await server.connect() + try await client.setStoreDir(to: temp.appending("/store")) + return (server, client, temp) } -extension Server { - func destroy() async throws { - print("deleting \(configDir.absoluteString)") - try await stop() - try FileManager.default.removeItem(at: configDir) - } +func cleanupServer(_ server: Server!, _ path: String) async throws { + defer { try? FileManager.default.removeItem(atPath: path) } + try await server?.stop() +} + + +// TODO: support non-equatable errors via a filter function +func XCTAssertThrows(_ expression: @autoclosure () async throws -> T, + _ filter: E, + file: StaticString = #filePath, + line: UInt = #line) async throws where E: Error, E: Equatable { + do { + _ = try await expression() + XCTFail("Did not throw", file: file, line: line) + } catch let error as E where error == filter {} +} + + +// true iff env[key] is set to a non-empty string that is different from "0" +func envFlag(_ key: String) -> Bool { + guard let val = ProcessInfo.processInfo.environment[key], val.count > 0 else { return false } + return Int(val) != 0 } diff --git a/bindings/swift/Ouisync/cov.sh b/bindings/swift/Ouisync/cov.sh new file mode 100755 index 000000000..43ed317c3 --- /dev/null +++ b/bindings/swift/Ouisync/cov.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env zsh +INCLUDE_SLOW=1 swift test --enable-code-coverage +if [ "$1" = "report" ]; then + xcrun llvm-cov report .build/debug/OuisyncPackageTests.xctest/Contents/MacOS/OuisyncPackageTests -instr-profile .build/debug/codecov/default.profdata --sources Sources/ +else + xcrun llvm-cov show .build/debug/OuisyncPackageTests.xctest/Contents/MacOS/OuisyncPackageTests -instr-profile .build/debug/codecov/default.profdata --sources Sources/ --format html > .build/codecov.html + test "$1" != "open" || open .build/codecov.html +fi + From 3becae2a7ed3ad03454655083427cf2aadf4134e Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Fri, 24 Jan 2025 17:59:56 +0200 Subject: [PATCH 15/24] Finish tests --- bindings/swift/Ouisync/Sources/Server.swift | 6 +- .../swift/Ouisync/Tests/MoveEntryTests.swift | 61 +++++++++++++++++++ .../{ServerTests.swift => SessionTests.swift} | 0 bindings/swift/Ouisync/Tests/_Utils.swift | 2 +- bindings/swift/Ouisync/cov.sh | 3 +- service/src/ffi.rs | 7 ++- 6 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 bindings/swift/Ouisync/Tests/MoveEntryTests.swift rename bindings/swift/Ouisync/Tests/{ServerTests.swift => SessionTests.swift} (100%) diff --git a/bindings/swift/Ouisync/Sources/Server.swift b/bindings/swift/Ouisync/Sources/Server.swift index 9904a468d..b6a7adbe7 100644 --- a/bindings/swift/Ouisync/Sources/Server.swift +++ b/bindings/swift/Ouisync/Sources/Server.swift @@ -11,9 +11,9 @@ public var ouisyncLogHandler: ((LogLevel, String) -> Void)? // init_log is not safe to call repeatedly, should only be called before the first server is // started and provides awkward memory semantics to assist dart, though we may eventually end up // using them here as well if ever we end up making logging async -private func directLogHandler(_ message: LogMessage) { - defer { release_log_message(message) } - ouisyncLogHandler?(message.level, String(cString: message.ptr)) +private func directLogHandler(_ level: LogLevel, _ ptr: UnsafePointer?, _ len: UInt, _ cap: UInt) { + defer { release_log_message(ptr, len, cap) } + if let ptr { ouisyncLogHandler?(level, String(cString: ptr)) } } @MainActor private var loggingConfigured = false @MainActor private func setupLogging() async throws { diff --git a/bindings/swift/Ouisync/Tests/MoveEntryTests.swift b/bindings/swift/Ouisync/Tests/MoveEntryTests.swift new file mode 100644 index 000000000..acbb33870 --- /dev/null +++ b/bindings/swift/Ouisync/Tests/MoveEntryTests.swift @@ -0,0 +1,61 @@ +import XCTest +import Ouisync + + +final class MoveEntryTests: XCTestCase { + var server: Server!, client: Client!, temp: String! + override func setUp() async throws { (server, client, temp) = try await startServer(self) } + override func tearDown() async throws { try await cleanupServer(server, temp) } + + func testMoveEmptyFolder() async throws { + // prep + let repo = try await client.createRepository(at: "foo") + try await repo.createDirectory(at: "/folder1"); + try await repo.createDirectory(at: "/folder1/folder2"); + + // initial assertions + var list = try await repo.listDirectory(at: "/") + XCTAssertEqual(list.count, 1) // root only contains one entry (folder1) + list = try await repo.listDirectory(at: "/folder1/folder2") + XCTAssertEqual(list.count, 0) // folder2 is empty + + try await repo.moveEntry(from: "/folder1/folder2", to: "/folder2") // move folder2 to root + + // final assertions + list = try await repo.listDirectory(at: "/folder1") + XCTAssertEqual(list.count, 0) // folder1 is now empty + list = try await repo.listDirectory(at: "/") + XCTAssertEqual(list.count, 2) // root now contains folder1 AND folder2 + } + + func testMoveNonEmptyFolder() async throws { + // prep + let repo = try await client.createRepository(at: "bar") + try await repo.createDirectory(at: "/folder1"); + try await repo.createDirectory(at: "/folder1/folder2"); + var file: File! = try await repo.createFile(at: "/folder1/folder2/file1.txt") + let send = "hello world".data(using: .utf8)! + try await file.write(send, toOffset: 0) + try await file.flush() + file = nil + + // initial assertions + var list = try await repo.listDirectory(at: "/") + XCTAssertEqual(list.count, 1) // root only contains one entry (folder1) + list = try await repo.listDirectory(at: "/folder1/folder2") + XCTAssertEqual(list.count, 1) // folder2 only contains one entry (file1) + + try await repo.moveEntry(from: "/folder1/folder2", to: "/folder2") // move folder2 to root + + // final assertions + list = try await repo.listDirectory(at: "/folder1") + XCTAssertEqual(list.count, 0) // folder1 is now empty + list = try await repo.listDirectory(at: "/") + XCTAssertEqual(list.count, 2) // root now contains folder1 AND folder2 + list = try await repo.listDirectory(at: "/folder2") + XCTAssertEqual(list.count, 1) // folder2 still contains one entry (file1) + file = try await repo.openFile(at: "/folder2/file1.txt") + let recv = try await file.read(file.length, fromOffset: 0) + XCTAssertEqual(send, recv) // file1 contains the same data it used to + } +} diff --git a/bindings/swift/Ouisync/Tests/ServerTests.swift b/bindings/swift/Ouisync/Tests/SessionTests.swift similarity index 100% rename from bindings/swift/Ouisync/Tests/ServerTests.swift rename to bindings/swift/Ouisync/Tests/SessionTests.swift diff --git a/bindings/swift/Ouisync/Tests/_Utils.swift b/bindings/swift/Ouisync/Tests/_Utils.swift index 5fa77a807..cc84c235e 100644 --- a/bindings/swift/Ouisync/Tests/_Utils.swift +++ b/bindings/swift/Ouisync/Tests/_Utils.swift @@ -3,7 +3,7 @@ import XCTest func startServer(_ test: XCTestCase, suffix: String = "") async throws -> (Server, Client, String) { - //ouisyncLogHandler = { level, message in print(message) } + if envFlag("ENABLE_LOGGING") { ouisyncLogHandler = { level, message in print(message) } } let path = test.name.replacingOccurrences(of: "-[", with: "") .replacingOccurrences(of: "]", with: "") .replacingOccurrences(of: " ", with: "_") + suffix diff --git a/bindings/swift/Ouisync/cov.sh b/bindings/swift/Ouisync/cov.sh index 43ed317c3..7d77d1f34 100755 --- a/bindings/swift/Ouisync/cov.sh +++ b/bindings/swift/Ouisync/cov.sh @@ -1,5 +1,6 @@ #!/usr/bin/env zsh -INCLUDE_SLOW=1 swift test --enable-code-coverage +ENABLE_LOGGING=1 INCLUDE_SLOW=1 swift test --enable-code-coverage || (echo "One or more tests failed" && exit 1) + if [ "$1" = "report" ]; then xcrun llvm-cov report .build/debug/OuisyncPackageTests.xctest/Contents/MacOS/OuisyncPackageTests -instr-profile .build/debug/codecov/default.profdata --sources Sources/ else diff --git a/service/src/ffi.rs b/service/src/ffi.rs index 09b690181..0127115c1 100644 --- a/service/src/ffi.rs +++ b/service/src/ffi.rs @@ -166,7 +166,8 @@ fn init( Ok((runtime, service, span)) } -pub type LogCallback = extern "C" fn(LogLevel, *const c_uchar, c_ulong, c_ulong); +// hoist Option here as a workaround for https://github.com/mozilla/cbindgen/issues/326 +pub type OptionLogCallback = Option ()>; /// Initialize logging. Should be called before `service_start`. /// @@ -180,7 +181,7 @@ pub type LogCallback = extern "C" fn(LogLevel, *const c_uchar, c_ulong, c_ulong) /// /// `file` must be either null or it must be safe to pass to [std::ffi::CStr::from_ptr]. #[no_mangle] -pub unsafe extern "C" fn init_log(file: *const c_char, callback: Option) -> ErrorCode { +pub unsafe extern "C" fn init_log(file: *const c_char, callback: OptionLogCallback) -> ErrorCode { try_init_log(file, callback).to_error_code() } @@ -206,7 +207,7 @@ struct LoggerWrapper { static LOGGER: OnceLock = OnceLock::new(); -unsafe fn try_init_log(file: *const c_char, callback: Option) -> Result<(), Error> { +unsafe fn try_init_log(file: *const c_char, callback: OptionLogCallback) -> Result<(), Error> { let builder = Logger::builder(); let builder = if !file.is_null() { builder.file(Path::new(CStr::from_ptr(file).to_str()?)) From ee38525df74436c4c1b580ff62d97fe992dcce0e Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Tue, 28 Jan 2025 20:56:49 +0200 Subject: [PATCH 16/24] Remove unsafe swift code --- bindings/swift/Ouisync/Sources/Client.swift | 25 ++++++++++++------- .../swift/Ouisync/Sources/Repository.swift | 21 ++++++++-------- bindings/swift/Ouisync/Sources/Server.swift | 7 ++++-- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/bindings/swift/Ouisync/Sources/Client.swift b/bindings/swift/Ouisync/Sources/Client.swift index 4a198ed11..4563506aa 100644 --- a/bindings/swift/Ouisync/Sources/Client.swift +++ b/bindings/swift/Ouisync/Sources/Client.swift @@ -108,12 +108,17 @@ import Network as: UInt64? = nil) async throws -> MessagePackValue { guard case .ready = sock.state else { throw OuisyncError.ConnectionAborted } return try await withUnsafeThrowingContinuation { - let id = `as` ?? Self.next() -// print("\(id) -> \(method)(\(arg))") + // serialize and ensure outgoing message size is below `limit` let body = pack([.string(method): arg]) + guard let size = UInt32(exactly: body.count), size < limit + else { return $0.resume(throwing: OuisyncError.InvalidInput) } + + // allocate id and create length-prefixed payload + let id = `as` ?? Self.next() + // print("\(id) -> \(method)(\(arg))") var message = Data(count: 12) message.withUnsafeMutableBytes { - $0.storeBytes(of: UInt32(exactly: body.count + 8)!.bigEndian, as: UInt32.self) + $0.storeBytes(of: (size + 8).bigEndian, as: UInt32.self) $0.storeBytes(of: id, toByteOffset: 4, as: UInt64.self) } message.append(body) @@ -157,8 +162,8 @@ import Network sock.receive(minimumIncompleteLength: 12, maximumLength: 12) { [weak self] header, _ , _, err in MainActor.assumeIsolated { guard let self else { return } - guard err == nil else { - return self.abort("Unexpected IO error while reading header: \(err!)") + if let err { + return self.abort("Unexpected IO error while reading header: \(err)") } guard let header, header.count == 12 else { return self.abort("Unexpected EOF while reading header") @@ -177,8 +182,8 @@ import Network self.sock.receive(minimumIncompleteLength: size, maximumLength: size) { [weak self] body, _, _, err in MainActor.assumeIsolated { guard let self else { return } - guard err == nil else { - return self.abort("Unexpected IO error while reading body: \(err!)") + if let err { + return self.abort("Unexpected IO error while reading body: \(err)") } guard let body, body.count == size else { return self.abort("Unexpected EOF while reading body") @@ -198,8 +203,10 @@ import Network if let success = payload["success"] { if success.stringValue != nil { result = .success(.nil) - } else if let sub = success.dictionaryValue, sub.count == 1 { - result = .success(sub.values.first!) + } else if let sub = success.dictionaryValue, + sub.count == 1, + let val = sub.values.first { + result = .success(val) } else { return self.abort("Received unrecognized result: \(success)") } diff --git a/bindings/swift/Ouisync/Sources/Repository.swift b/bindings/swift/Ouisync/Sources/Repository.swift index 5586a3fac..364272428 100644 --- a/bindings/swift/Ouisync/Sources/Repository.swift +++ b/bindings/swift/Ouisync/Sources/Repository.swift @@ -223,17 +223,16 @@ public extension Repository { return try res.stringValue.orThrow } } - @usableFromInline static func cast(e: (String, (String?, String?))) -> MessagePackValue {[ - "key": .string(e.0), - "old": e.1.0 == nil ? .nil : .string(e.1.0!), - "new": e.1.1 == nil ? .nil : .string(e.1.1!) - ]} - public func update(_ edits: [String: (from: String?, to: String?)]) async throws -> Bool { - try await repository.client.invoke("repository_set_metadata", - with: ["repository": repository.handle, - "edits": .array(edits.map(Self.cast))] - ).boolValue.orThrow + func toString(_ val: String?) -> MessagePackValue { + guard let val else { return .nil } + return .string(val) + } + return try await repository.client.invoke("repository_set_metadata", with: [ + "repository": repository.handle, + "edits": .array(edits.map {["key": .string($0.key), + "old": toString($0.value.from), + "new": toString($0.value.to)]})]).boolValue.orThrow } } @@ -284,7 +283,7 @@ public extension Repository { * as a default argument for `keep existing secret` on both sides of the ffi */ func encode(_ arg: CreateSecret?) -> MessagePackValue { guard let arg else { return .string("disable") } - return arg.value == KEEP_EXISTING.value ? .nil : ["enable": readSecret!.value] + return arg.value == KEEP_EXISTING.value ? .nil : ["enable": arg.value] } try await client.invoke("repository_set_access", with: ["repository": handle, "read": encode(readSecret), diff --git a/bindings/swift/Ouisync/Sources/Server.swift b/bindings/swift/Ouisync/Sources/Server.swift index b6a7adbe7..e661739e4 100644 --- a/bindings/swift/Ouisync/Sources/Server.swift +++ b/bindings/swift/Ouisync/Sources/Server.swift @@ -2,7 +2,10 @@ import CryptoKit import Foundation import OuisyncService -extension ErrorCode: Error {} // @retroactive doesn't work in Ventura, which I still use +// @retroactive doesn't work in Ventura, which I still use +extension ErrorCode: Error, CustomDebugStringConvertible { + public var debugDescription: String { "OuisyncError(code=\(rawValue))" } +} public typealias OuisyncError = ErrorCode // FIXME: updating this at runtime is unsafe and should be cast to atomic @@ -20,7 +23,7 @@ private func directLogHandler(_ level: LogLevel, _ ptr: UnsafePointer?, _ if loggingConfigured { return } loggingConfigured = true let err = init_log(nil, directLogHandler) - if err != .Ok { throw err } + guard case .Ok = err else { throw err } } From ba0d3fafd38593a1a2a5fe0e646ed0870aee5b5f Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Tue, 28 Jan 2025 20:58:23 +0200 Subject: [PATCH 17/24] Include version hash in exported EntryType (now called StatEntry) --- bindings/dart/lib/bindings.dart | 25 +++++++++++ bindings/dart/lib/bindings.g.dart | 22 ---------- bindings/dart/lib/ouisync.dart | 8 ++-- bindings/dart/test/ouisync_test.dart | 2 +- .../main/kotlin/org/equalitie/ouisync/Ui.kt | 8 ++-- .../org/equalitie/ouisync/lib/Directory.kt | 2 +- .../org/equalitie/ouisync/lib/Response.kt | 32 +++++++++++++- .../org/equalitie/ouisync/RepositoryTest.kt | 10 ++--- .../swift/Ouisync/Sources/Directory.swift | 3 +- .../swift/Ouisync/Sources/Repository.swift | 10 ++--- bindings/swift/Ouisync/Sources/Server.swift | 2 + bindings/swift/Ouisync/Sources/_Structs.swift | 30 ++++++++++--- .../swift/Ouisync/Tests/RepositoryTests.swift | 10 +++-- lib/src/repository/mod.rs | 21 +++++---- service/src/protocol.rs | 2 +- service/src/protocol/response.rs | 14 ++++-- service/src/state.rs | 43 +++++++++++-------- utils/bindgen/src/main.rs | 1 - 18 files changed, 156 insertions(+), 89 deletions(-) diff --git a/bindings/dart/lib/bindings.dart b/bindings/dart/lib/bindings.dart index 96e16498a..87cbc09d8 100644 --- a/bindings/dart/lib/bindings.dart +++ b/bindings/dart/lib/bindings.dart @@ -2,10 +2,35 @@ import 'dart:ffi'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:ouisync/exception.dart'; import 'package:path/path.dart'; export 'bindings.g.dart'; +sealed class EntryType { + static EntryType decode(Object foo) { + if (foo is! Map || foo.length != 1) { throw InvalidData("Not a one entry map"); } + return switch (foo.entries.first.key) { + "File" => EntryType_File(foo.entries.first.value), + "Directory" => EntryType_Directory(foo.entries.first.value), + final key => throw InvalidData("Unknown EntryType: $key") + }; + } +} + +// ignore: camel_case_types +class EntryType_File extends EntryType { + final Uint8List version; + EntryType_File(this.version); +} + +// ignore: camel_case_types +class EntryType_Directory extends EntryType { + final Uint8List version; + EntryType_Directory(this.version); +} + + /// Callback for `start_service` and `stop_service`. typedef StatusCallback = Void Function(Pointer, Uint16); diff --git a/bindings/dart/lib/bindings.g.dart b/bindings/dart/lib/bindings.g.dart index de75a0ae6..816e5d6df 100644 --- a/bindings/dart/lib/bindings.g.dart +++ b/bindings/dart/lib/bindings.g.dart @@ -23,28 +23,6 @@ enum AccessMode { } -enum EntryType { - file, - directory, - ; - - static EntryType decode(int n) { - switch (n) { - case 1: return EntryType.file; - case 2: return EntryType.directory; - default: throw ArgumentError('invalid value: $n'); - } - } - - int encode() { - switch (this) { - case EntryType.file: return 1; - case EntryType.directory: return 2; - } - } - -} - enum ErrorCode { ok, permissionDenied, diff --git a/bindings/dart/lib/ouisync.dart b/bindings/dart/lib/ouisync.dart index 427394dbb..a826abe7d 100644 --- a/bindings/dart/lib/ouisync.dart +++ b/bindings/dart/lib/ouisync.dart @@ -16,6 +16,8 @@ export 'bindings.dart' show AccessMode, EntryType, + EntryType_File, + EntryType_Directory, ErrorCode, LogLevel, NetworkEvent, @@ -408,7 +410,7 @@ class Repository { /// Returns the type (file, directory, ..) of the entry at [path]. Returns `null` if the entry /// doesn't exists. Future entryType(String path) async { - final raw = await _client.invoke('repository_entry_type', { + final raw = await _client.invoke('repository_entry_type', { 'repository': _handle, 'path': path, }); @@ -634,13 +636,13 @@ class DirEntry { static DirEntry decode(Object? raw) { final map = raw as List; final name = map[0] as String; - final type = map[1] as int; + final type = map[1] as Object; return DirEntry(name, EntryType.decode(type)); } @override - String toString() => '$name (${entryType.name})'; + String toString() => '$name ($entryType)'; } /// A reference to a directory (folder) in a [Repository]. diff --git a/bindings/dart/test/ouisync_test.dart b/bindings/dart/test/ouisync_test.dart index e7c94894e..0c81e1e54 100644 --- a/bindings/dart/test/ouisync_test.dart +++ b/bindings/dart/test/ouisync_test.dart @@ -97,7 +97,7 @@ void main() { expect(await repo.entryType('dir'), isNull); await Directory.create(repo, 'dir'); - expect(await repo.entryType('dir'), equals(EntryType.directory)); + expect(await repo.entryType('dir'), isA()); await Directory.remove(repo, 'dir'); expect(await repo.entryType('dir'), isNull); diff --git a/bindings/kotlin/example/src/main/kotlin/org/equalitie/ouisync/Ui.kt b/bindings/kotlin/example/src/main/kotlin/org/equalitie/ouisync/Ui.kt index baf81407f..33333b91f 100644 --- a/bindings/kotlin/example/src/main/kotlin/org/equalitie/ouisync/Ui.kt +++ b/bindings/kotlin/example/src/main/kotlin/org/equalitie/ouisync/Ui.kt @@ -277,10 +277,10 @@ fun FolderScreen( path = path, onEntryClicked = { entry -> when (entry.entryType) { - EntryType.FILE -> { + is EntryType.File -> { navController.navigate(FileRoute(repositoryName, "$path/${entry.name}")) } - EntryType.DIRECTORY -> { + is EntryType.Directory -> { navController.navigate(FolderRoute(repositoryName, "$path/${entry.name}")) } } @@ -324,8 +324,8 @@ fun FolderDetail( modifier = Modifier.padding(PADDING).fillMaxWidth(), ) { when (entry.entryType) { - EntryType.FILE -> Icon(Icons.Default.Description, "File") - EntryType.DIRECTORY -> Icon(Icons.Default.Folder, "Folder") + is EntryType.File -> Icon(Icons.Default.Description, "File") + is EntryType.Directory -> Icon(Icons.Default.Folder, "Folder") } Text( diff --git a/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Directory.kt b/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Directory.kt index 5153445cd..9ed0adfb3 100644 --- a/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Directory.kt +++ b/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Directory.kt @@ -4,7 +4,7 @@ package org.equalitie.ouisync.lib * A directory entry * * @property name name of the entry. - * @property entryType type of the entry (i.e., file or directory). + * @property entryType type of the entry (i.e., file or directory) and version. */ data class DirectoryEntry(val name: String, val entryType: EntryType) diff --git a/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Response.kt b/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Response.kt index b4fcb74ad..46d68ef2c 100644 --- a/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Response.kt +++ b/bindings/kotlin/lib/src/main/kotlin/org/equalitie/ouisync/lib/Response.kt @@ -25,6 +25,11 @@ sealed class PeerState { class Active(val runtimeId: String) : PeerState() } +sealed class EntryType { + class File(var version: ByteArray) : EntryType() + class Directory(var version: ByteArray) : EntryType() +} + data class Progress(val value: Long, val total: Long) internal sealed interface Response { @@ -73,7 +78,7 @@ internal sealed interface Response { "bytes" -> unpacker.unpackByteArray() "directory" -> unpacker.unpackDirectory() // Duration(Duration), - "entry_type" -> EntryType.decode(unpacker.unpackByte()) + "entry_type" -> unpacker.unpackEntryType() "file", "repository", "u64" -> unpacker.unpackLong() "network_event" -> NetworkEvent.decode(unpacker.unpackByte()) // NetworkStats(Stats), @@ -217,13 +222,36 @@ private fun MessageUnpacker.unpackDirectory(): Directory { return Directory(entries) } +internal fun MessageUnpacker.unpackEntryType(): EntryType { + val type = getNextFormat().getValueType() + return when (type) { + ValueType.NIL -> { + unpackNil() + throw IllegalArgumentException() // this is awkward but that's how bindgen does it too + } + ValueType.MAP -> { + val size = unpackMapHeader() + if (size != 1) { + throw Error.InvalidData("invalid EntryType payload: expected map of size 1, was $size") + } + val key = unpackString() + when (key) { + "File" -> EntryType.File(unpackByteArray()) + "Directory" -> EntryType.Directory(unpackByteArray()) + else -> throw Error.InvalidData("invalid EntryType case: '$key'") + } + } + else -> throw Error.InvalidData("invalid EntryType payload: expected NIL or MAP, was $type") + } +} + internal fun MessageUnpacker.unpackDirectoryEntry(): DirectoryEntry { if (unpackArrayHeader() < 2) { throw Error.InvalidData("invalid DirectoryEntry: too few elements") } val name = unpackString() - val entryType = EntryType.decode(unpackByte()) + val entryType = unpackEntryType() return DirectoryEntry(name, entryType) } diff --git a/bindings/kotlin/lib/src/test/kotlin/org/equalitie/ouisync/RepositoryTest.kt b/bindings/kotlin/lib/src/test/kotlin/org/equalitie/ouisync/RepositoryTest.kt index b6bea25e1..4d0c2a896 100644 --- a/bindings/kotlin/lib/src/test/kotlin/org/equalitie/ouisync/RepositoryTest.kt +++ b/bindings/kotlin/lib/src/test/kotlin/org/equalitie/ouisync/RepositoryTest.kt @@ -160,7 +160,7 @@ class RepositoryTest { @Test fun entryType() = runTest { withRepo { - assertEquals(EntryType.DIRECTORY, it.entryType("/")) + assertTrue(it.entryType("/") is EntryType.Directory) assertNull(it.entryType("missing.txt")) } } @@ -171,7 +171,7 @@ class RepositoryTest { File.create(repo, "foo.txt").close() repo.moveEntry("foo.txt", "bar.txt") - assertEquals(EntryType.FILE, repo.entryType("bar.txt")) + assertTrue(repo.entryType("bar.txt") is EntryType.File) assertNull(repo.entryType("foo.txt")) } } @@ -204,7 +204,7 @@ class RepositoryTest { val file = File.create(repo, name) file.close() - assertEquals(EntryType.FILE, repo.entryType(name)) + assertTrue(repo.entryType(name) is EntryType.File) File.remove(repo, name) assertNull(repo.entryType(name)) @@ -259,7 +259,7 @@ class RepositoryTest { assertNull(repo.entryType(dirName)) Directory.create(repo, dirName) - assertEquals(EntryType.DIRECTORY, repo.entryType(dirName)) + assertTrue(repo.entryType(dirName) is EntryType.Directory) val dir0 = Directory.read(repo, dirName) assertEquals(0, dir0.size) @@ -269,7 +269,7 @@ class RepositoryTest { val dir1 = Directory.read(repo, dirName) assertEquals(1, dir1.size) assertEquals(fileName, dir1.elementAt(0).name) - assertEquals(EntryType.FILE, dir1.elementAt(0).entryType) + assertTrue(dir1.elementAt(0).entryType is EntryType.File) Directory.remove(repo, dirName, recursive = true) assertNull(repo.entryType(dirName)) diff --git a/bindings/swift/Ouisync/Sources/Directory.swift b/bindings/swift/Ouisync/Sources/Directory.swift index 0f032a4da..62d37a177 100644 --- a/bindings/swift/Ouisync/Sources/Directory.swift +++ b/bindings/swift/Ouisync/Sources/Directory.swift @@ -11,8 +11,7 @@ public extension Repository { try await client.invoke("directory_read", with: ["repository": handle, "path": .string(path)]).arrayValue.orThrow.map { guard let arr = $0.arrayValue, arr.count == 2 else { throw OuisyncError.InvalidData } - return try (name: arr[0].stringValue.orThrow, - type: EntryType(rawValue: arr[1].uint8Value.orThrow).orThrow) + return try (name: arr[0].stringValue.orThrow, type: .init(arr[1]).orThrow) } } diff --git a/bindings/swift/Ouisync/Sources/Repository.swift b/bindings/swift/Ouisync/Sources/Repository.swift index 364272428..4ca6af163 100644 --- a/bindings/swift/Ouisync/Sources/Repository.swift +++ b/bindings/swift/Ouisync/Sources/Repository.swift @@ -139,10 +139,8 @@ public extension Repository { /// Returns the `EntryType` at `path`, or `nil` if there's nothing that location func entryType(at path: String) async throws -> EntryType? { - let res = try await client.invoke("repository_entry_type", with: ["repository": handle, - "path": .string(path)]) - if case .nil = res { return nil } // FIXME: this should just be an enum type - return try EntryType(rawValue: res.uint8Value.orThrow).orThrow + try await .init(client.invoke("repository_entry_type", with: ["repository": handle, + "path": .string(path)])) } /// Returns whether the entry (file or directory) at `path` exists. @@ -185,8 +183,8 @@ public extension Repository { "mode": .uint(UInt64(mode.rawValue))])) } - var syncProgress: Progress { get async throws { - try await Progress(client.invoke("repository_sync_progress", with: handle)) + var syncProgress: SyncProgress { get async throws { + try await .init(client.invoke("repository_sync_progress", with: handle)) } } var infoHash: String { get async throws { diff --git a/bindings/swift/Ouisync/Sources/Server.swift b/bindings/swift/Ouisync/Sources/Server.swift index e661739e4..128d3ac23 100644 --- a/bindings/swift/Ouisync/Sources/Server.swift +++ b/bindings/swift/Ouisync/Sources/Server.swift @@ -2,12 +2,14 @@ import CryptoKit import Foundation import OuisyncService + // @retroactive doesn't work in Ventura, which I still use extension ErrorCode: Error, CustomDebugStringConvertible { public var debugDescription: String { "OuisyncError(code=\(rawValue))" } } public typealias OuisyncError = ErrorCode + // FIXME: updating this at runtime is unsafe and should be cast to atomic public var ouisyncLogHandler: ((LogLevel, String) -> Void)? diff --git a/bindings/swift/Ouisync/Sources/_Structs.swift b/bindings/swift/Ouisync/Sources/_Structs.swift index 7775fde34..9161d0d1a 100644 --- a/bindings/swift/Ouisync/Sources/_Structs.swift +++ b/bindings/swift/Ouisync/Sources/_Structs.swift @@ -1,4 +1,5 @@ import MessagePack +import Foundation extension Optional { @@ -52,7 +53,29 @@ public struct PeerInfo { } -public struct Progress { +public enum EntryType: Equatable { + case File(_ version: Data) + case Directory(_ version: Data) + + init?(_ value: MessagePackValue) throws { + switch value { + case .nil: + return nil + case .map(let fields): + guard fields.count == 1, let pair = fields.first, let version = pair.value.dataValue, + version.count == 32 else { throw OuisyncError.InvalidData } + switch try pair.key.stringValue.orThrow { + case "File": self = .File(version) + case "Directory": self = .Directory(version) + default: throw OuisyncError.InvalidData + } + default: throw OuisyncError.InvalidData + } + } +} + + +public struct SyncProgress { public let value: UInt64 public let total: UInt64 @@ -74,11 +97,6 @@ public enum AccessMode: UInt8 { case Write = 2 } -public enum EntryType: UInt8 { - case File = 1 - case Directory = 2 -} - 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. diff --git a/bindings/swift/Ouisync/Tests/RepositoryTests.swift b/bindings/swift/Ouisync/Tests/RepositoryTests.swift index 6d2bcf928..337039f21 100644 --- a/bindings/swift/Ouisync/Tests/RepositoryTests.swift +++ b/bindings/swift/Ouisync/Tests/RepositoryTests.swift @@ -32,20 +32,22 @@ final class RepositoryTests: XCTestCase { func testDirectoryCreateAndRemove() async throws { let repo = try await client.createRepository(at: "baz") var stat = try await repo.entryType(at: "dir") - XCTAssertEqual(stat, .none) + XCTAssertNil(stat) var entries = try await repo.listDirectory(at: "/") XCTAssertEqual(entries.count, 0) try await repo.createDirectory(at: "dir") - stat = try await repo.entryType(at: "dir") - XCTAssertEqual(stat, .Directory) + switch try await repo.entryType(at: "dir") { + case .Directory: break + default: XCTFail("Not a folder") + } entries = try await repo.listDirectory(at: "/") XCTAssertEqual(entries.count, 1) XCTAssertEqual(entries[0].name, "dir") try await repo.removeDirectory(at: "dir") stat = try await repo.entryType(at: "dir") - XCTAssertEqual(stat, .none) + XCTAssertNil(stat) entries = try await repo.listDirectory(at: "/") XCTAssertEqual(entries.count, 0) } diff --git a/lib/src/repository/mod.rs b/lib/src/repository/mod.rs index 905ec2a84..7c225407c 100644 --- a/lib/src/repository/mod.rs +++ b/lib/src/repository/mod.rs @@ -21,7 +21,7 @@ use crate::{ access_control::{Access, AccessChange, AccessKeys, AccessMode, AccessSecrets, LocalSecret}, block_tracker::BlockRequestMode, branch::{Branch, BranchShared}, - crypto::{sign::PublicKey, PasswordSalt}, + crypto::{Hash, Hashable, PasswordSalt, sign::PublicKey}, db::{self, DatabaseId}, debug::DebugPrinter, directory::{Directory, DirectoryFallback, DirectoryLocking, EntryRef, EntryType}, @@ -585,14 +585,19 @@ impl Repository { } /// Looks up an entry by its path. The path must be relative to the repository root. - /// If the entry exists, returns its `EntryType`, otherwise returns `EntryNotFound`. - pub async fn lookup_type>(&self, path: P) -> Result { + /// If the entry exists, returns its `EntryType`, as well as it's version hash, + /// otherwise fails with `EntryNotFound`. + pub async fn lookup_type>(&self, path: P) -> Result<(EntryType, Hash)> { match path::decompose(path.as_ref()) { - Some((parent, name)) => { - let parent = self.open_directory(parent).await?; - Ok(parent.lookup_unique(name)?.entry_type()) - } - None => Ok(EntryType::Directory), + None => Ok((EntryType::Directory, self.get_merged_version_vector().await?.hash())), + Some((parent, name)) => self + .open_directory(parent) + .await? + .lookup_unique(name) + .map(|child| match child.entry_type() { + EntryType::File => (EntryType::File, child.version_vector().hash()), + EntryType::Directory => (EntryType::Directory, child.version_vector().hash()), + }), } } diff --git a/service/src/protocol.rs b/service/src/protocol.rs index af79c41aa..c4ed3e544 100644 --- a/service/src/protocol.rs +++ b/service/src/protocol.rs @@ -14,6 +14,6 @@ pub use log::LogLevel; pub use message::{DecodeError, EncodeError, Message, MessageId}; pub use metadata::MetadataEdit; pub use request::{NetworkDefaults, Request}; -pub use response::{DirectoryEntry, QuotaInfo, Response, ResponseResult, UnexpectedResponse}; +pub use response::{DirectoryEntry, QuotaInfo, Response, ResponseResult, StatEntry, UnexpectedResponse}; pub(crate) use error_code::ToErrorCode; diff --git a/service/src/protocol/response.rs b/service/src/protocol/response.rs index d410c30b7..c660d40ca 100644 --- a/service/src/protocol/response.rs +++ b/service/src/protocol/response.rs @@ -1,6 +1,6 @@ use crate::{file::FileHandle, repository::RepositoryHandle}; use ouisync::{ - AccessMode, EntryType, NatBehavior, NetworkEvent, PeerAddr, PeerInfo, Progress, ShareToken, + AccessMode, NatBehavior, NetworkEvent, PeerAddr, PeerInfo, Progress, ShareToken, Stats, StorageSize, }; use serde::{Deserialize, Serialize}; @@ -53,7 +53,7 @@ pub enum Response { Bytes(Bytes), Directory(Vec), Duration(Duration), - EntryType(EntryType), + EntryType(StatEntry), File(FileHandle), NetworkEvent(NetworkEvent), NetworkStats(Stats), @@ -177,7 +177,7 @@ impl_response_conversion!(AccessMode(AccessMode)); impl_response_conversion!(Bool(bool)); impl_response_conversion!(Directory(Vec)); impl_response_conversion!(Duration(Duration)); -impl_response_conversion!(EntryType(EntryType)); +impl_response_conversion!(EntryType(StatEntry)); impl_response_conversion!(File(FileHandle)); impl_response_conversion!(NetworkEvent(NetworkEvent)); impl_response_conversion!(NetworkStats(Stats)); @@ -200,10 +200,16 @@ impl_response_conversion!(U64(u64)); #[error("unexpected response")] pub struct UnexpectedResponse; +#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize)] +pub enum StatEntry { + File(#[serde(with = "serde_bytes")] [u8; 32]), + Directory(#[serde(with = "serde_bytes")] [u8; 32]), +} + #[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize)] pub struct DirectoryEntry { pub name: String, - pub entry_type: EntryType, + pub entry_type: StatEntry, } #[derive(Eq, PartialEq, Debug, Serialize, Deserialize)] diff --git a/service/src/state.rs b/service/src/state.rs index 00a718d4e..3bf593d2c 100644 --- a/service/src/state.rs +++ b/service/src/state.rs @@ -13,16 +13,17 @@ use crate::{ error::Error, file::{FileHandle, FileHolder, FileSet}, network::{self, PexConfig}, - protocol::{DirectoryEntry, MetadataEdit, NetworkDefaults, QuotaInfo}, + protocol::{DirectoryEntry, MetadataEdit, NetworkDefaults, QuotaInfo, StatEntry}, repository::{FindError, RepositoryHandle, RepositoryHolder, RepositorySet}, tls, transport::remote::RemoteClient, utils, }; use ouisync::{ - Access, AccessChange, AccessMode, AccessSecrets, Credentials, EntryType, Event, LocalSecret, - Network, PeerAddr, Progress, Repository, RepositoryParams, SetLocalSecret, ShareToken, Stats, - StorageSize, + crypto::Hashable, + Access, AccessChange, AccessMode, AccessSecrets, Credentials, JointEntryRef, + EntryType, Event, LocalSecret, Network, PeerAddr, Progress, Repository, + RepositoryParams, SetLocalSecret, ShareToken, Stats, StorageSize }; use ouisync_vfs::{MultiRepoMount, MultiRepoVFS}; use state_monitor::{MonitorId, StateMonitor}; @@ -1054,13 +1055,13 @@ impl State { Ok(true) } - /// Returns the type of repository entry (file, directory, ...) or `None` if the entry doesn't - /// exist. + /// Returns the type of repository entry (file, directory, ...) as well as its version hash + /// or `None` if the entry doesn't exist. pub async fn repository_entry_type( &self, handle: RepositoryHandle, path: String, - ) -> Result, Error> { + ) -> Result, Error> { match self .repos .get(handle) @@ -1069,7 +1070,10 @@ impl State { .lookup_type(path) .await { - Ok(entry_type) => Ok(Some(entry_type)), + Ok((entry_type, hash)) => Ok(Some(match entry_type { + EntryType::File => StatEntry::File(hash.into()), + EntryType::Directory => StatEntry::Directory(hash.into()) + })), Err(ouisync::Error::EntryNotFound) => Ok(None), Err(error) => Err(error.into()), } @@ -1111,22 +1115,23 @@ impl State { repo: RepositoryHandle, path: String, ) -> Result, Error> { - let repo = self + Ok(self .repos .get(repo) .ok_or(Error::InvalidArgument)? - .repository(); - - let dir = repo.open_directory(path).await?; - let entries = dir + .repository() + .open_directory(path).await? .entries() .map(|entry| DirectoryEntry { name: entry.unique_name().into_owned(), - entry_type: entry.entry_type(), + entry_type: match entry { + JointEntryRef::File(item) => + StatEntry::File(item.version_vector().hash().into()), + JointEntryRef::Directory(item) => + StatEntry::Directory(item.version_vector().hash().into()) + } }) - .collect(); - - Ok(entries) + .collect()) } /// Removes the directory at the given path from the repository. If `recursive` is true it removes @@ -1246,8 +1251,8 @@ impl State { .repository(); match repo.lookup_type(&path).await { - Ok(EntryType::File) => Ok(true), - Ok(EntryType::Directory) => Ok(false), + Ok((EntryType::File, _)) => Ok(true), + Ok((EntryType::Directory, _)) => Ok(false), Err(ouisync::Error::EntryNotFound) => Ok(false), Err(ouisync::Error::AmbiguousEntry) => Ok(false), Err(error) => Err(error.into()), diff --git a/utils/bindgen/src/main.rs b/utils/bindgen/src/main.rs index 781bd0758..52894bd6c 100644 --- a/utils/bindgen/src/main.rs +++ b/utils/bindgen/src/main.rs @@ -12,7 +12,6 @@ fn main() -> Result<(), Box> { let source_files = [ "service/src/protocol.rs", "lib/src/access_control/access_mode.rs", - "lib/src/directory/entry_type.rs", "lib/src/network/event.rs", "lib/src/network/peer_source.rs", "lib/src/network/peer_state.rs", From e0dca71d172874f0ee160c28e58a67eb37b5a861 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Thu, 30 Jan 2025 23:01:23 +0200 Subject: [PATCH 18/24] Add `root/target` to shared rust artifact pool on darwin --- .../Ouisync/Plugins/Builder/builder.swift | 2 +- .../Ouisync/Plugins/Updater/updater.swift | 21 +++++++++------- bindings/swift/Ouisync/init.sh | 25 +++++++++++++++---- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/bindings/swift/Ouisync/Plugins/Builder/builder.swift b/bindings/swift/Ouisync/Plugins/Builder/builder.swift index d12df9e14..307902e04 100644 --- a/bindings/swift/Ouisync/Plugins/Builder/builder.swift +++ b/bindings/swift/Ouisync/Plugins/Builder/builder.swift @@ -1,4 +1,4 @@ -/* Swift package manager build plugin: currently invokes `build.sh` before every build. +/* Swift package manager build plugin: invokes `build.sh` before every build. Ideally, a `.buildTool()`[1] plugin[2][3][4] is expected to provide makefile-like rules mapping supplied files to their requirements, which are then used by the build system to only compile the diff --git a/bindings/swift/Ouisync/Plugins/Updater/updater.swift b/bindings/swift/Ouisync/Plugins/Updater/updater.swift index af74e20e6..0d9e5eafe 100644 --- a/bindings/swift/Ouisync/Plugins/Updater/updater.swift +++ b/bindings/swift/Ouisync/Plugins/Updater/updater.swift @@ -1,12 +1,15 @@ -/* Swift package manager command plugin: Used to download and compile any rust dependencies - - Due to apple's policies regarding plugin access, this must be run manually manually and granted - permission to bypass the sandbox restrictions. Can be run by right clicking OuisyncLib from Xcode - or directly from the command line via: `swift package cargo-fetch`. - - For automated tasks, the permissions can be automatically granted on invocation via the - `--allow-network-connections` and `--allow-writing-to-package-directory` flags respectively or - the sandbox can be disabled altogether via `--disable-sandbox` though the latter is untested. */ +/* Swift package manager command plugin: invokes `update.sh` to download and compile rust deps. + * + * Because the companion build plugin cannot access the network, this plugin must be run every time + * either `Cargo.toml` or `Cargo.lock` is updated, or the next build will fail. + * + * Can be run from Xcode by right clicking on the "Ouisync" package and picking + * "Update rust dependencies" or directly via the command line: + * `swift package plugin cargo-fetch --allow-network-connections all`. + * + * After a fresh `git clone` (or `git clean` or `flutter clean` or after using the + * `Product > Clear Build Folder` menu action in Xcode, the `init` shell script from the swift + * package root MUST be run before attempting a new build (it will run this script as well) */ import Foundation import PackagePlugin diff --git a/bindings/swift/Ouisync/init.sh b/bindings/swift/Ouisync/init.sh index c85f93ce2..45f2353b0 100755 --- a/bindings/swift/Ouisync/init.sh +++ b/bindings/swift/Ouisync/init.sh @@ -2,19 +2,34 @@ # This tool is used to prepare the swift build environment; it is necessary due # to an unfortunate combination of known limitations in the swift package # manager and git's refusal to permit comitted but gitignored "template files" -# This script must be called before attempting the first build +# This script must be called before attempting the first `swift build` # Make sure we have Xcode command line tools installed xcode-select -p || xcode-select --install +# for build artifacts, swift uses `.build` relative to the swift package and +# `cargo` uses `target` relative to the cargo package (../../../) so we link +# them together to share the build cache and speed builds up whenever possible +cd $(dirname "$0") +BASE="$(realpath .)/.build/plugins/outputs/ouisync/Ouisync/destination" +mkdir -p "$BASE" +CARGO="$(realpath "../../..")/target" +SWIFT="$BASE/CargoBuild" +if [ -d "$CARGO" ]; then + rm -Rf "$SWIFT" + mv "$CARGO" "$SWIFT" +else + rm -f "$CARGO" + mkdir -p "$SWIFT" +fi +ln -s "$SWIFT" "$CARGO" + # Swift expects some sort of actual framework in the current folder which we # mock as an empty library with no headers or data that will be replaced before # it is actually needed via the prebuild tool called during `swift build` -cd $(dirname "$0") -SRC=".build/plugins/outputs/ouisync/Ouisync/destination/CargoBuild/OuisyncService.xcframework" -mkdir -p $SRC +mkdir -p "$SWIFT/OuisyncService.xcframework" rm -f "OuisyncService.xcframework" -ln -s $SRC "OuisyncService.xcframework" +ln -s "$SWIFT/OuisyncService.xcframework" "OuisyncService.xcframework" cat < "OuisyncService.xcframework/Info.plist" From b4bddb67f54d2a63a1840749682eb3087ad7b09e Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Thu, 30 Jan 2025 23:02:14 +0200 Subject: [PATCH 19/24] Remove superflous logInit call --- bindings/dart/lib/ouisync.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/bindings/dart/lib/ouisync.dart b/bindings/dart/lib/ouisync.dart index a826abe7d..911bb91af 100644 --- a/bindings/dart/lib/ouisync.dart +++ b/bindings/dart/lib/ouisync.dart @@ -52,7 +52,6 @@ class Session { // that one instead. If we do spawn, we are responsible for logging if (startServer) { try { - logInit(callback: logger, tag: 'Server'); server = await Server.start( configPath: configPath, debugLabel: debugLabel, From c1580d54c15bf049a68a79ee6e5deb5a7853b30f Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Thu, 30 Jan 2025 23:02:59 +0200 Subject: [PATCH 20/24] fix `cargo test` on darwin Add libc dependency on macos and ios (osx is no longer valid in rust) Bump libc to 0.2.129 globally Disable `vfs` and `cli` tests on darwin because they depend on fuse --- cli/Cargo.toml | 4 ++-- cli/tests/cli.rs | 5 ++++- cli/tests/utils.rs | 2 +- service/src/logger/stdout.rs | 2 +- utils/swarm/Cargo.toml | 4 ++-- utils/swarm/src/main.rs | 4 ++-- vfs/Cargo.toml | 1 + vfs/src/lib.rs | 1 + 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 7cc9954ae..f8350dd84 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -57,5 +57,5 @@ tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } -[target.'cfg(any(target_os = "linux", target_os = "osx"))'.dev-dependencies] -libc = "0.2.126" +[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "ios"))'.dev-dependencies] +libc = "0.2.129" diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index ab90f57e2..a61e1c040 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -1,4 +1,5 @@ -mod utils; +#[cfg(not(any(target_os = "macos", target_os = "ios")))] +mod utils { use self::utils::{check_eq, eventually, Bin, CountWrite, RngRead}; use anyhow::{format_err, Result}; @@ -399,3 +400,5 @@ fn setup() -> (Bin, Bin) { (a, b) } + +} diff --git a/cli/tests/utils.rs b/cli/tests/utils.rs index 9c9593010..d5d0997e8 100644 --- a/cli/tests/utils.rs +++ b/cli/tests/utils.rs @@ -397,7 +397,7 @@ where // Gracefully terminate the process, unlike `Child::kill` which sends `SIGKILL` and thus doesn't // allow destructors to run. -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "ios"))] fn terminate(process: &Child) { // SAFETY: we are just sending a `SIGTERM` signal to the process, there should be no reason for // undefined behaviour here. diff --git a/service/src/logger/stdout.rs b/service/src/logger/stdout.rs index 80ee59277..0835dd81a 100644 --- a/service/src/logger/stdout.rs +++ b/service/src/logger/stdout.rs @@ -30,7 +30,7 @@ where // // TODO: consider using `ansi_term::enable_ansi_support()` // (see https://github.com/ogham/rust-ansi-term#basic-usage for more info) - !cfg!(any(target_os = "windows", target_os = "macos")) && io::stdout().is_terminal() + !cfg!(any(target_os = "windows", target_os = "macos", target_os = "ios")) && io::stdout().is_terminal() } }; diff --git a/utils/swarm/Cargo.toml b/utils/swarm/Cargo.toml index 3ccf433be..e9b7af693 100644 --- a/utils/swarm/Cargo.toml +++ b/utils/swarm/Cargo.toml @@ -19,5 +19,5 @@ clap = { workspace = true } ctrlc = { version = "3.4.5", features = ["termination"] } os_pipe = "1.1.4" -[target.'cfg(any(target_os = "linux", target_os = "osx"))'.dependencies] -libc = "0.2.126" +[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "ios"))'.dependencies] +libc = "0.2.129" diff --git a/utils/swarm/src/main.rs b/utils/swarm/src/main.rs index 711dc25ad..a2d22648b 100644 --- a/utils/swarm/src/main.rs +++ b/utils/swarm/src/main.rs @@ -307,7 +307,7 @@ enum Mode { // Gracefully terminate the process, unlike `Child::kill` which sends `SIGKILL` and thus doesn't // allow destructors to run. -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "ios"))] fn terminate(process: &Child) { // SAFETY: we are just sending a `SIGTERM` signal to the process, there should be no reason for // undefined behaviour here. @@ -316,7 +316,7 @@ fn terminate(process: &Child) { } } -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "ios")))] fn terminate(_process: &Child) { todo!() } diff --git a/vfs/Cargo.toml b/vfs/Cargo.toml index 0dc4cf972..e5a9bfce4 100644 --- a/vfs/Cargo.toml +++ b/vfs/Cargo.toml @@ -28,6 +28,7 @@ bitflags = "2.6.0" [target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] xpc-connection = "0.2.0" +libc = "0.2.139" [target.'cfg(target_os = "windows")'.dependencies] deadlock = { path = "../deadlock" } diff --git a/vfs/src/lib.rs b/vfs/src/lib.rs index 769b8ca68..67b410095 100644 --- a/vfs/src/lib.rs +++ b/vfs/src/lib.rs @@ -25,6 +25,7 @@ pub use dummy::{mount, MountGuard, MultiRepoVFS}; // --------------------------------------------------------------------------------- #[cfg(test)] +#[cfg(not(any(target_os = "macos", target_os = "ios")))] mod tests; use ouisync_lib::Repository; From 69d332e8525f66fa5b293e66cadcdca13a92bd07 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Fri, 31 Jan 2025 12:31:18 +0200 Subject: [PATCH 21/24] Readability --- bindings/swift/Ouisync/Sources/Client.swift | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/bindings/swift/Ouisync/Sources/Client.swift b/bindings/swift/Ouisync/Sources/Client.swift index 4563506aa..1d47e74e8 100644 --- a/bindings/swift/Ouisync/Sources/Client.swift +++ b/bindings/swift/Ouisync/Sources/Client.swift @@ -6,14 +6,14 @@ import Network @MainActor public class Client { let sock: NWConnection - let limit: Int + let limit: UInt32 private(set) var invocations = [UInt64: UnsafeContinuation]() private(set) var subscriptions = [UInt64: Subscription.Continuation]() /** Connects to `127.0.0.1:port` and attempts to authenticate the peer using `key`. * * Throws on connection error or if the peer could not be authenticated. */ - public init(_ port: UInt16, _ key: SymmetricKey, maxMessageSize: Int = 1<<18) async throws { + public init(_ port: UInt16, _ key: SymmetricKey, maxMessageSize: UInt32 = 1<<18) async throws { limit = maxMessageSize sock = NWConnection(to: .hostPort(host: .ipv4(.loopback), port: .init(rawValue: port)!), @@ -83,10 +83,11 @@ import Network } /** Deinitializers are currently synchronous so we can't gracefully unsubscribe, but we can - * schedule a `RST` packet in order to allow the server to clean up after this connection */ + * schedule a `RST` packet in order to allow the server to clean up after this connection */ deinit { sock.cancel() } // MARK: end of public API + /** This internal function prints `reason` to the standard log, then closes the socket and fails * all outstanding requests with `OuisyncError.ConnectionAborted`; intended for use as a generic * __panic handler__ whenever a non-recoverable protocol error occurs. */ @@ -115,7 +116,7 @@ import Network // allocate id and create length-prefixed payload let id = `as` ?? Self.next() - // print("\(id) -> \(method)(\(arg))") +// print("\(id) -> \(method)(\(arg))") var message = Data(count: 12) message.withUnsafeMutableBytes { $0.storeBytes(of: (size + 8).bigEndian, as: UInt32.self) @@ -165,21 +166,22 @@ import Network if let err { return self.abort("Unexpected IO error while reading header: \(err)") } + guard let header, header.count == 12 else { return self.abort("Unexpected EOF while reading header") } - - var size = Int(0) - var id = UInt64(0) + var size = UInt32(0), id = UInt64(0) header.withUnsafeBytes { - size = Int(UInt32(bigEndian: $0.loadUnaligned(as: UInt32.self))) + size = UInt32(bigEndian: $0.loadUnaligned(as: UInt32.self)) id = $0.loadUnaligned(fromByteOffset: 4, as: UInt64.self) } + guard (9...self.limit).contains(size) else { return self.abort("Received \(size) byte packet (must be in 9...\(self.limit))") } - size -= 8 // messageid was already read - self.sock.receive(minimumIncompleteLength: size, maximumLength: size) { + size -= 8 // messageId was already read so it's not part of the remaining count + + self.sock.receive(minimumIncompleteLength: Int(size), maximumLength: Int(size)) { [weak self] body, _, _, err in MainActor.assumeIsolated { guard let self else { return } if let err { From 7fe9fc060e119817d7aac39b4e7a50de4074eed9 Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Sat, 1 Feb 2025 12:12:46 +0200 Subject: [PATCH 22/24] Update gradle --- .../kotlin/gradle/wrapper/gradle-wrapper.jar | Bin 63721 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 2 +- bindings/kotlin/gradlew | 21 ++++++++++------- bindings/kotlin/gradlew.bat | 22 ++++++++++-------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/bindings/kotlin/gradle/wrapper/gradle-wrapper.jar b/bindings/kotlin/gradle/wrapper/gradle-wrapper.jar index 7f93135c49b765f8051ef9d0a6055ff8e46073d8..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc diff --git a/bindings/kotlin/gradle/wrapper/gradle-wrapper.properties b/bindings/kotlin/gradle/wrapper/gradle-wrapper.properties index 9355b4155..e18bc253b 100644 --- a/bindings/kotlin/gradle/wrapper/gradle-wrapper.properties +++ b/bindings/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/bindings/kotlin/gradlew b/bindings/kotlin/gradlew index 0adc8e1a5..f5feea6d6 100755 --- a/bindings/kotlin/gradlew +++ b/bindings/kotlin/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -145,7 +148,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -153,7 +156,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -202,11 +205,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/bindings/kotlin/gradlew.bat b/bindings/kotlin/gradlew.bat index 93e3f59f1..9d21a2183 100644 --- a/bindings/kotlin/gradlew.bat +++ b/bindings/kotlin/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail From 32448a57dc4fc9e2eccb9781315877c43b9a057b Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Sun, 2 Feb 2025 16:06:16 +0200 Subject: [PATCH 23/24] Rename `listenerAddrs` property to mirror upstream --- bindings/swift/Ouisync/Sources/Ouisync.swift | 4 ++-- bindings/swift/Ouisync/Tests/MultipleNodesTests.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bindings/swift/Ouisync/Sources/Ouisync.swift b/bindings/swift/Ouisync/Sources/Ouisync.swift index f0b88622a..e691556ef 100644 --- a/bindings/swift/Ouisync/Sources/Ouisync.swift +++ b/bindings/swift/Ouisync/Sources/Ouisync.swift @@ -31,8 +31,8 @@ public extension Client { "local_discovery_enabled": .bool(localDiscovery)]) } - var listenerAddrs: [String] { get async throws { - try await invoke("network_get_listener_addrs").arrayValue.orThrow.map { + var localListenerAddrs: [String] { get async throws { + try await invoke("network_get_local_listener_addrs").arrayValue.orThrow.map { try $0.stringValue.orThrow } } } diff --git a/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift b/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift index 0b5829799..89e6e006f 100644 --- a/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift +++ b/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift @@ -2,7 +2,7 @@ import XCTest import Ouisync -final class MultiplenodesTests: XCTestCase { +final class MultipleNodesTests: XCTestCase { var server1: Server!, client1: Client!, temp1: String! var server2: Server!, client2: Client!, temp2: String! var repo1, repo2: Repository! @@ -34,13 +34,13 @@ final class MultiplenodesTests: XCTestCase { if count == 2 { break } } } - try await client2.addUserProvidedPeers(from: client1.listenerAddrs) + try await client2.addUserProvidedPeers(from: client1.localListenerAddrs) _ = try await repo1.createFile(at: "file.txt") try await stream.value } func testNotificationOnPeersChange() async throws { - let addr = try await client1.listenerAddrs[0] + let addr = try await client1.localListenerAddrs[0] let stream = Task { for try await _ in client2.networkEvents { for peer in try await client2.peers { @@ -56,7 +56,7 @@ final class MultiplenodesTests: XCTestCase { } func testNetworkStats() async throws { - let addr = try await client1.listenerAddrs[0] + let addr = try await client1.localListenerAddrs[0] try await client2.addUserProvidedPeers(from: [addr]) try await repo1.createFile(at: "file.txt").flush() From ea65398a228606b71fb46d246785468c1783eafb Mon Sep 17 00:00:00 2001 From: Radu Dan Date: Sun, 2 Feb 2025 21:16:16 +0200 Subject: [PATCH 24/24] Disable cli tests on macos without breaking other platforms --- cli/tests/cli.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index a61e1c040..37cdf5384 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -1,5 +1,4 @@ -#[cfg(not(any(target_os = "macos", target_os = "ios")))] -mod utils { +mod utils; use self::utils::{check_eq, eventually, Bin, CountWrite, RngRead}; use anyhow::{format_err, Result}; @@ -13,6 +12,7 @@ use std::{ }; #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn transfer_single_small_file() { let (a, b) = setup(); @@ -28,6 +28,7 @@ fn transfer_single_small_file() { } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn transfer_single_large_file() { let (a, b) = setup(); @@ -52,6 +53,7 @@ fn transfer_single_large_file() { } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn sequential_write_to_the_same_file() { let (a, b) = setup(); @@ -79,6 +81,7 @@ fn sequential_write_to_the_same_file() { } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn fast_sequential_writes() { // There used to be a deadlock which would manifest whenever one of the connected replicas // perfomed more than one write operation (mkdir, echo foo > bar,...) quickly one after another @@ -98,11 +101,13 @@ fn fast_sequential_writes() { } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn concurrent_read_and_write_small_file() { concurrent_read_and_write_file(32); } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn concurrent_read_and_write_large_file() { concurrent_read_and_write_file(1024 * 1024); } @@ -141,6 +146,7 @@ fn concurrent_read_and_write_file(size: usize) { // large enough so that the number of blocks it consists of is greater than the capacity of the // notification channel. #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn concurrent_read_and_delete_file() { let (a, b) = setup(); @@ -183,6 +189,7 @@ fn concurrent_read_and_delete_file() { } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn relay() { // Create three nodes: A, B and R where A and B are connected only to R but not to each other. // Then create a file by A and let it be received by B which requires the file to pass through @@ -227,6 +234,7 @@ fn relay() { } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn concurrent_update() { let (a, b) = setup(); @@ -344,6 +352,7 @@ fn check_concurrent_versions(file_path: &Path, expected_contents: &[&[u8]]) -> R // This test is similar to the `relay` test but using a "cache server" for the relay node instead // of a regular peer. #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn mirror() { // the cache server let r = Bin::start(); @@ -400,5 +409,3 @@ fn setup() -> (Bin, Bin) { (a, b) } - -}