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 cf5b9a1f9..ae648b3c6 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, @@ -415,7 +417,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, }); @@ -641,13 +643,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/gradle/wrapper/gradle-wrapper.jar b/bindings/kotlin/gradle/wrapper/gradle-wrapper.jar index 7f93135c4..a4b76b953 100644 Binary files a/bindings/kotlin/gradle/wrapper/gradle-wrapper.jar and b/bindings/kotlin/gradle/wrapper/gradle-wrapper.jar differ 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 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/.gitignore b/bindings/swift/Ouisync/.gitignore new file mode 100644 index 000000000..7bf98420e --- /dev/null +++ b/bindings/swift/Ouisync/.gitignore @@ -0,0 +1,9 @@ +/OuisyncService.xcframework +/config.sh +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm +.netrc 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 56% rename from bindings/swift/OuisyncLib/Package.swift rename to bindings/swift/Ouisync/Package.swift index 592ff50cf..2f65add79 100644 --- a/bindings/swift/OuisyncLib/Package.swift +++ b/bindings/swift/Ouisync/Package.swift @@ -1,32 +1,32 @@ // swift-tools-version: 5.9 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"], - path: "Sources"), - .testTarget(name: "OuisyncLibTests", - dependencies: ["OuisyncLib"], - path: "Tests"), - // FIXME: move this to a separate package / framework - .binaryTarget(name: "OuisyncLibFFI", - path: "output/OuisyncLibFFI.xcframework"), - .plugin(name: "FFIBuilder", + "CargoBuild", + "OuisyncService"], + path: "Sources", + linkerSettings: [.linkedFramework("SystemConfiguration")]), + .testTarget(name: "OuisyncTests", + dependencies: ["Ouisync"], + path: "Tests", + linkerSettings: [.linkedFramework("SystemConfiguration")]), + .binaryTarget(name: "OuisyncService", + path: "OuisyncService.xcframework"), + .plugin(name: "CargoBuild", capability: .buildTool(), path: "Plugins/Builder"), .plugin(name: "Update rust dependencies", @@ -34,8 +34,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/Builder/builder.swift b/bindings/swift/Ouisync/Plugins/Builder/builder.swift similarity index 58% rename from bindings/swift/OuisyncLib/Plugins/Builder/builder.swift rename to bindings/swift/Ouisync/Plugins/Builder/builder.swift index 99d8b8f22..307902e04 100644 --- a/bindings/swift/OuisyncLib/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 @@ -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/Ouisync/Plugins/Updater/updater.swift b/bindings/swift/Ouisync/Plugins/Updater/updater.swift new file mode 100644 index 000000000..0d9e5eafe --- /dev/null +++ b/bindings/swift/Ouisync/Plugins/Updater/updater.swift @@ -0,0 +1,36 @@ +/* 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 + +@main struct Updater: CommandPlugin { + func panic(_ msg: String) -> Never { + Diagnostics.error(msg) + fatalError("Unable to update rust dependencies") + } + + func performCommand(context: PackagePlugin.PluginContext, + arguments: [String] = []) async throws { + let task = Process() + let exe = context.package.directory.appending(["Plugins", "update.sh"]).string + task.standardInput = nil + task.executableURL = URL(fileURLWithPath: exe) + task.arguments = [context.pluginWorkDirectory.string] + do { try task.run() } catch { panic("Unable to start \(exe): \(error)") } + task.waitUntilExit() + + guard task.terminationReason ~= .exit else { panic("\(exe) killed by \(task.terminationStatus)") } + guard task.terminationStatus == 0 else { panic("\(exe) returned \(task.terminationStatus)") } + Diagnostics.remark("Dependencies up to date!") + } +} diff --git a/bindings/swift/Ouisync/Plugins/build.sh b/bindings/swift/Ouisync/Plugins/build.sh new file mode 100755 index 000000000..f1dba2a93 --- /dev/null +++ b/bindings/swift/Ouisync/Plugins/build.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env zsh +# 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. 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 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" + +# 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/Ouisync/config.sh" || fatal 1 "Unable to find config file" +if [ $SKIP ] && [ $SKIP -gt 0 ]; then + exit 0 +fi +if [ $DEBUG ] && [ $DEBUG -gt 0 ]; then + CONFIGURATION="debug" + FLAGS="" +else + CONFIGURATION="release" + FLAGS="--release" +fi + +# convert targets to dictionary +LIST=($TARGETS[@]) +declare -A TARGETS +for TARGET in $LIST[@]; do TARGETS[$TARGET]="" done + +# build configured targets +cd "$PROJECT_HOME" +for TARGET in ${(k)TARGETS}; do + cross build \ + --frozen \ + --package ouisync-service \ + --target $TARGET \ + --target-dir "$BUILD_OUTPUT" \ + $FLAGS || fatal 3 "Unable to compile for $TARGET" +done + +# generate include files +INCLUDE="$BUILD_OUTPUT/include" +mkdir -p "$INCLUDE" +echo "module OuisyncService { + header \"bindings.h\" + export * +}" > "$INCLUDE/module.modulemap" +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" + +# xcodebuild refuses multiple architectures per platform, instead expecting fat libraries when the +# destination operating system supports multiple architectures; apple also explicitly rejects any +# submissions that link to mixed-platform libraries so `lipo` usage is reduced to an if and only if +# scenario; since our input is a list of llvm triples which do not follow rigid naming conventions, +# we first have to statically define the platform->arch tree and then run some annoying diffs on it +PARAMS=() +declare -A TREE +TREE=( + macos "aarch64-apple-darwin x86_64-apple-darwin" + ios "aarch64-apple-ios" + simulator "aarch64-apple-ios-sim x86_64-apple-ios" +) +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_service.a" + fi + done + if [ $#MATCHED -eq 0 ]; then # platform not enabled + continue + 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_service.a" + mkdir -p "$(dirname "$LIBRARY")" + 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/OuisyncService.xcframework" -mindepth 1 -delete +xcodebuild \ + -create-xcframework ${PARAMS[@]} \ + -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/OuisyncService.xcframework/$FILE" +done diff --git a/bindings/swift/Ouisync/Plugins/update.sh b/bindings/swift/Ouisync/Plugins/update.sh new file mode 100755 index 000000000..445ea331c --- /dev/null +++ b/bindings/swift/Ouisync/Plugins/update.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env zsh +# Command line tool which pulls all dependencies needed to build the rust core library. +fatal() { echo "Error $@" >&2 && exit $1 } +PROJECT_HOME=$(realpath "$(dirname "$0")/../../../../") +export CARGO_HOME=$(realpath "$1") +export PATH="$CARGO_HOME/bin:$PATH" +export RUSTUP_HOME="$CARGO_HOME/.rustup" + +# 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 + +# 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" + +# 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/Ouisync/Sources/Client.swift b/bindings/swift/Ouisync/Sources/Client.swift new file mode 100644 index 000000000..1d47e74e8 --- /dev/null +++ b/bindings/swift/Ouisync/Sources/Client.swift @@ -0,0 +1,246 @@ +import CryptoKit +import Foundation +@preconcurrency import MessagePack +import Network + + +@MainActor public class Client { + let sock: NWConnection + 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: UInt32 = 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 might reserve... + let clientChallenge = try Data.secureRandom(256) // ... some portions for protocol headers + try await send(clientChallenge) + + // 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 + 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 { + // 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: (size + 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. */ + 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 self.invoke("\(topic)_subscribe", with: arg, as: id) } + catch { self.subscriptions.removeValue(forKey: id)?.finish(throwing: error) } + } + } } } + } + 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) { + [weak self] header, _ , _, err in MainActor.assumeIsolated { + guard let self else { return } + 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 = UInt32(0), id = UInt64(0) + header.withUnsafeBytes { + 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 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 { + 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") + } + 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, + let val = sub.values.first { + result = .success(val) + } 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)") } + +// 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 \(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/Ouisync/Sources/Directory.swift b/bindings/swift/Ouisync/Sources/Directory.swift new file mode 100644 index 000000000..62d37a177 --- /dev/null +++ b/bindings/swift/Ouisync/Sources/Directory.swift @@ -0,0 +1,35 @@ +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: .init(arr[1]).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 { + 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 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 new file mode 100644 index 000000000..9933b0f0e --- /dev/null +++ b/bindings/swift/Ouisync/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), + "len": .uint(size)]).dataValue.orThrow + } + + /// Writes `data` to this file, starting at `offset` + 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)]) + } + + /// Flushes any pending writes to persistent storage. + func flush() async throws { + try await repository.client.invoke("file_flush", with: handle) + } + + /// 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(length)]) + } + + var length: 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/Ouisync/Sources/Ouisync.swift b/bindings/swift/Ouisync/Sources/Ouisync.swift new file mode 100644 index 000000000..e691556ef --- /dev/null +++ b/bindings/swift/Ouisync/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 localListenerAddrs: [String] { get async throws { + try await invoke("network_get_local_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)) + } + + nonisolated 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/Ouisync/Sources/Repository.swift b/bindings/swift/Ouisync/Sources/Repository.swift new file mode 100644 index 000000000..4ca6af163 --- /dev/null +++ b/bindings/swift/Ouisync/Sources/Repository.swift @@ -0,0 +1,293 @@ +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`. + * + * 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 + * "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: 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.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])) + } + + // 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: OpenSecret? = nil) async throws -> Repository { + try await Repository(self, invoke("repository_open", with: ["path": .string(path), + "secret": secret?.value ?? .nil])) + } */ + + /// All currently open repositories. + var repositories: [Repository] { get async throws { + 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 + let handle: MessagePackValue + init(_ client: Client, _ handle: MessagePackValue) throws { + _ = try handle.uintValue.orThrow + self.client = client + self.handle = handle + } + + // 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) } + } */ +} + + +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: OpenSecret? = nil) async throws { + try await client.invoke("repository_set_access_mode", + with: ["repository": handle, + "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? { + try await .init(client.invoke("repository_entry_type", with: ["repository": handle, + "path": .string(path)])) + } + + /// 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 + var events: AsyncThrowingMapSequence { + client.subscribe(to: "repository", with: handle).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: OpenSecret? = 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: SyncProgress { get async throws { + try await .init(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)]) + } + + 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 + } } + + public func update(_ edits: [String: (from: String?, to: String?)]) async throws -> Bool { + 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 + } + } + + /// 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: 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 + * + * 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: CreateSecret?) -> MessagePackValue { + guard let arg else { return .string("disable") } + return arg.value == KEEP_EXISTING.value ? .nil : ["enable": arg.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/Ouisync/Sources/Secret.swift b/bindings/swift/Ouisync/Sources/Secret.swift new file mode 100644 index 000000000..0f66cc70d --- /dev/null +++ b/bindings/swift/Ouisync/Sources/Secret.swift @@ -0,0 +1,101 @@ +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 {} +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, CreateSecret, OpenSecret { + let string: String + public var value: MessagePackValue { ["password": .string(string)] } + public init(_ value: String) { string = value } +} + +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 { + 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, 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) } } +} + + +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 -> 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 new file mode 100644 index 000000000..128d3ac23 --- /dev/null +++ b/bindings/swift/Ouisync/Sources/Server.swift @@ -0,0 +1,105 @@ +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)? + +// 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(_ 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 { + if loggingConfigured { return } + loggingConfigured = true + let err = init_log(nil, directLogHandler) + guard case .Ok = err else { throw err } +} + + +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 { + try await setupLogging() + 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: 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 (IPv4) + public var port: UInt16 { get async throws { try JSONDecoder().decode(UInt16.self, from: + 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 = 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(curr, offsetBy: 2) + defer { curr = next } + if let res = UInt8(str[curr.. Client { try await .init(port, authKey) } +} + +/// 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/Ouisync/Sources/ShareToken.swift b/bindings/swift/Ouisync/Sources/ShareToken.swift new file mode 100644 index 000000000..3339fa0d1 --- /dev/null +++ b/bindings/swift/Ouisync/Sources/ShareToken.swift @@ -0,0 +1,47 @@ +import MessagePack + + +public extension Client { + func shareToken(fromString value: String) async throws -> ShareToken { + try await ShareToken(self, invoke("share_token_normalize", with: .string(value))) + } +} + + +public struct ShareToken: Secret { + // FIXME: should this retain the Client unless cast to string? + let client: Client + /// The (nominally secret) token value that should be stored or shared somewhere safe + public let string: String + public var value: MessagePackValue { .string(string) } + + init(_ client: Client, _ value: MessagePackValue) throws { + self.client = client + string = try value.stringValue.orThrow + } +} + + +public extension ShareToken { + /// 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/Ouisync/Sources/StateMonitor.swift b/bindings/swift/Ouisync/Sources/StateMonitor.swift new file mode 100644 index 000000000..4a1f03b55 --- /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 { + 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 + } + + init(_ str: String) throws { + guard let match = str.lastIndex(of: ":") else { throw OuisyncError.InvalidData } + name = String(str[.. { + client.subscribe(to: "state_monitor", + with: .array(path.map { .string($0.description) })).map { _ in () } + } + + func load() async throws -> Bool { + let res = try await client.invoke("state_monitor_get", + 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)]) + } + return true + } +} diff --git a/bindings/swift/Ouisync/Sources/_Structs.swift b/bindings/swift/Ouisync/Sources/_Structs.swift new file mode 100644 index 000000000..9161d0d1a --- /dev/null +++ b/bindings/swift/Ouisync/Sources/_Structs.swift @@ -0,0 +1,131 @@ +import MessagePack +import Foundation + + +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() + // FIXME: arr[3] seems to be an undocumented timestamp in milliseconds + } else { + throw OuisyncError.InvalidData + } + stats = try NetworkStats(arr[3]) + } +} + + +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 + + 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 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/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/MultipleNodesTests.swift b/bindings/swift/Ouisync/Tests/MultipleNodesTests.swift new file mode 100644 index 000000000..89e6e006f --- /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.localListenerAddrs) + _ = try await repo1.createFile(at: "file.txt") + try await stream.value + } + + func testNotificationOnPeersChange() async throws { + let addr = try await client1.localListenerAddrs[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.localListenerAddrs[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..337039f21 --- /dev/null +++ b/bindings/swift/Ouisync/Tests/RepositoryTests.swift @@ -0,0 +1,229 @@ +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") + XCTAssertNil(stat) + var entries = try await repo.listDirectory(at: "/") + XCTAssertEqual(entries.count, 0) + + try await repo.createDirectory(at: "dir") + 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") + XCTAssertNil(stat) + 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/SessionTests.swift b/bindings/swift/Ouisync/Tests/SessionTests.swift new file mode 100644 index 000000000..b0a1e5886 --- /dev/null +++ b/bindings/swift/Ouisync/Tests/SessionTests.swift @@ -0,0 +1,28 @@ +import XCTest +import Ouisync + + +final class SessionTests: 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 testThrowsWhenStartedTwice() async throws { + try await XCTAssertThrows(try await startServer(self), OuisyncError.ServiceAlreadyRunning) + } + + func testMultiClient() async throws { + let other = try await server.connect() + + // 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 new file mode 100644 index 000000000..cc84c235e --- /dev/null +++ b/bindings/swift/Ouisync/Tests/_Utils.swift @@ -0,0 +1,40 @@ +import Ouisync +import XCTest + + +func startServer(_ test: XCTestCase, suffix: String = "") async throws -> (Server, Client, String) { + if envFlag("ENABLE_LOGGING") { ouisyncLogHandler = { level, message in print(message) } } + let path = test.name.replacingOccurrences(of: "-[", with: "") + .replacingOccurrences(of: "]", with: "") + .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) +} + +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..7d77d1f34 --- /dev/null +++ b/bindings/swift/Ouisync/cov.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env zsh +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 + 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 + diff --git a/bindings/swift/Ouisync/init.sh b/bindings/swift/Ouisync/init.sh new file mode 100755 index 000000000..45f2353b0 --- /dev/null +++ b/bindings/swift/Ouisync/init.sh @@ -0,0 +1,66 @@ +#!/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 `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` +mkdir -p "$SWIFT/OuisyncService.xcframework" +rm -f "OuisyncService.xcframework" +ln -s "$SWIFT/OuisyncService.xcframework" "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 diff --git a/bindings/swift/OuisyncLib/.gitignore b/bindings/swift/OuisyncLib/.gitignore deleted file mode 100644 index 72917e5fd..000000000 --- a/bindings/swift/OuisyncLib/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -/output -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift b/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift deleted file mode 100644 index a32fb6d60..000000000 --- a/bindings/swift/OuisyncLib/Plugins/Updater/updater.swift +++ /dev/null @@ -1,40 +0,0 @@ -/* 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. */ -import Foundation -import PackagePlugin - -@main struct Updater: CommandPlugin { - func panic(_ msg: String) -> Never { - Diagnostics.error(msg) - fatalError("Unable to update rust dependencies") - } - - 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] - do { try task.run() } catch { panic("Unable to start \(exe): \(error)") } - task.waitUntilExit() - - guard task.terminationReason ~= .exit else { panic("\(exe) killed by \(task.terminationStatus)") } - guard task.terminationStatus == 0 else { panic("\(exe) returned \(task.terminationStatus)") } - Diagnostics.remark("Dependencies up to date!") - } -} diff --git a/bindings/swift/OuisyncLib/Plugins/build.sh b/bindings/swift/OuisyncLib/Plugins/build.sh deleted file mode 100755 index a08e07b3c..000000000 --- a/bindings/swift/OuisyncLib/Plugins/build.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/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: -# -# [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 -PROJECT_HOME=$(realpath "$(dirname "$0")/../../../../") -PACKAGE_HOME=$(realpath "$PROJECT_HOME/bindings/swift/OuisyncLib") -export CARGO_HOME="$1" -export RUSTUP_HOME="$CARGO_HOME/.rustup" -BUILD_OUTPUT="$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 -mkdir -p "$BUILD_OUTPUT/dummy" - -# read config and prepare to build -source "$PACKAGE_HOME/config.sh" -if [ $SKIP ] && [ $SKIP -gt 0 ]; then - exit 0 -fi -if [ $DEBUG ] && [ $DEBUG -gt 0 ]; then - CONFIGURATION="debug" - FLAGS="" -else - CONFIGURATION="release" - FLAGS="--release" -fi - -# convert targets to dictionary -LIST=($TARGETS[@]) -declare -A TARGETS -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 \ - --frozen \ - --package ouisync-ffi \ - --target $TARGET \ - --target-dir "$BUILD_OUTPUT" \ - $FLAGS || exit 1 -done - -# generate include files -INCLUDE="$BUILD_OUTPUT/include" -mkdir -p "$INCLUDE" -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 - -# xcodebuild refuses multiple architectures per platform, instead expecting fat libraries when the -# destination operating system supports multiple architectures; apple also explicitly rejects any -# submissions that link to mixed-platform libraries so `lipo` usage is reduced to an if and only if -# scenario; since our input is a list of llvm triples which do not follow rigid naming conventions, -# we first have to statically define the platform->arch tree and then run some annoying diffs on it -PARAMS=() -declare -A TREE -TREE=( - macos "aarch64-apple-darwin x86_64-apple-darwin" - ios "aarch64-apple-ios" - simulator "aarch64-apple-ios-sim x86_64-apple-ios" -) -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" - fi - done - if [ $#MATCHED -eq 0 ]; then # platform not enabled - continue - 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" - mkdir -p "$(dirname "$LIBRARY")" - lipo -create $MATCHED[@] -output $LIBRARY || exit 3 - fi - PARAMS+=("-library" "$LIBRARY" "-headers" "$INCLUDE") -done -echo ${PARAMS[@]} -xcodebuild \ - -create-xcframework ${PARAMS[@]} \ - -output "$BUILD_OUTPUT/output/OuisyncLibFFI.xcframework" || exit 4 diff --git a/bindings/swift/OuisyncLib/Plugins/update.sh b/bindings/swift/OuisyncLib/Plugins/update.sh deleted file mode 100755 index 570fe5859..000000000 --- a/bindings/swift/OuisyncLib/Plugins/update.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/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) -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 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" -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" - -# unfortunately, we can't trigger a build from here because `build.sh` runs in a different sandbox diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift b/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift deleted file mode 100644 index f7bf5a4e8..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncClient.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// File.swift -// -// -// Created by Peter Jankuliak on 23/07/2024. -// - -import Foundation -import OuisyncLibFFI - -public class OuisyncClient { - var clientHandle: SessionHandle - let ffi: OuisyncFFI - public var onReceiveFromBackend: OuisyncOnReceiveFromBackend? = nil - - public static func create(_ configPath: String, _ logPath: String, _ ffi: OuisyncFFI) throws -> OuisyncClient { - // Init with an invalid sessionHandle because we need the OuisyncSession instance to - // create the callback, which is in turn needed to create the proper sessionHandle. - let client = OuisyncClient(0, ffi) - - let logTag = "ouisync-backend" - let result = ffi.ffiSessionCreate(ffi.sessionKindShared, configPath, logPath, logTag, - .init(mutating: OuisyncFFI.toUnretainedPtr(obj: client))) { - context, dataPointer, size in - let client: OuisyncClient = OuisyncFFI.fromUnretainedPtr(ptr: context!) - guard let onReceive = client.onReceiveFromBackend else { - fatalError("OuisyncClient has no onReceive handler set") - } - onReceive(Array(UnsafeBufferPointer(start: dataPointer, count: Int(exactly: size)!))) - } - - if result.error_code != 0 { - throw SessionCreateError("Failed to create session, code:\(result.error_code), message:\(result.error_message!)") - } - - client.clientHandle = result.session - return client - } - - fileprivate init(_ clientHandle: SessionHandle, _ ffi: OuisyncFFI) { - self.clientHandle = clientHandle - self.ffi = ffi - } - - public func sendToBackend(_ data: [UInt8]) { - let count = data.count; - data.withUnsafeBufferPointer({ maybePointer in - if let pointer = maybePointer.baseAddress { - ffi.ffiSessionChannelSend(clientHandle, .init(mutating: pointer), UInt64(count)) - } - }) - } - - public func close() async { - typealias Continuation = CheckedContinuation - - class Context { - let clientHandle: SessionHandle - let continuation: Continuation - init(_ clientHandle: SessionHandle, _ continuation: Continuation) { - self.clientHandle = clientHandle - self.continuation = continuation - } - } - - await withCheckedContinuation(function: "FFI.closeSession", { continuation in - let context = OuisyncFFI.toRetainedPtr(obj: Context(clientHandle, continuation)) - let callback: FFICallback = { context, dataPointer, size in - let context: Context = OuisyncFFI.fromRetainedPtr(ptr: context!) - context.continuation.resume() - } - ffi.ffiSessionClose(clientHandle, .init(mutating: context), callback) - }) - } -} - -public typealias OuisyncOnReceiveFromBackend = ([UInt8]) -> Void diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift b/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift deleted file mode 100644 index 1bbd29827..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncEntry.swift +++ /dev/null @@ -1,166 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System - -public enum OuisyncEntryType { - case file - case directory -} - -public enum OuisyncEntry { - case file(OuisyncFileEntry) - case directory(OuisyncDirectoryEntry) - - public func name() -> String { - switch self { - case .file(let e): return e.name() - case .directory(let e): return e.name() - } - } - - public func type() -> OuisyncEntryType { - switch self { - case .file: return .file - case .directory: return .directory - } - } - - public func parent() -> OuisyncEntry? { - switch self { - case .file(let file): return .directory(file.parent()) - case .directory(let directory): - guard let parent = directory.parent() else { - return nil - } - return .directory(parent) - } - } -} - -public class OuisyncFileEntry { - public let path: FilePath - public let repository: OuisyncRepository - - public init(_ path: FilePath, _ repository: OuisyncRepository) { - self.path = path - self.repository = repository - } - - public func parent() -> OuisyncDirectoryEntry { - return OuisyncDirectoryEntry(Self.parent(path), repository) - } - - public func name() -> String { - return Self.name(path) - } - - public static func name(_ path: FilePath) -> String { - return path.lastComponent!.string - } - - public func exists() async throws -> Bool { - return try await repository.session.sendRequest(.fileExists(repository.handle, path)).toBool() - } - - public func delete() async throws { - try await repository.deleteFile(path) - } - - public func getVersionHash() async throws -> Data { - try await repository.getEntryVersionHash(path) - } - - public static func parent(_ path: FilePath) -> FilePath { - var parentPath = path - parentPath.components.removeLast() - return parentPath - } - - public func open() async throws -> OuisyncFile { - try await repository.openFile(path) - } - - public func create() async throws -> OuisyncFile { - try await repository.createFile(path) - } -} - -public class OuisyncDirectoryEntry: CustomDebugStringConvertible { - public let repository: OuisyncRepository - public let path: FilePath - - public init(_ path: FilePath, _ repository: OuisyncRepository) { - self.repository = repository - self.path = path - } - - public func name() -> String { - return OuisyncDirectoryEntry.name(path) - } - - public static func name(_ path: FilePath) -> String { - if let c = path.lastComponent { - return c.string - } - return "/" - } - - public func listEntries() async throws -> [OuisyncEntry] { - let response = try await repository.session.sendRequest(OuisyncRequest.listEntries(repository.handle, path)) - let entries = response.value.arrayValue! - return entries.map({entry in - let name: String = entry[0]!.stringValue! - let typeNum = entry[1]!.uint8Value! - - switch typeNum { - case 1: return .file(OuisyncFileEntry(path.appending(name), repository)) - case 2: return .directory(OuisyncDirectoryEntry(path.appending(name), repository)) - default: - fatalError("Invalid EntryType returned from OuisyncLib \(typeNum)") - } - }) - } - - public func isRoot() -> Bool { - return path.components.isEmpty - } - - public func parent() -> OuisyncDirectoryEntry? { - guard let parentPath = OuisyncDirectoryEntry.parent(path) else { - return nil - } - return OuisyncDirectoryEntry(parentPath, repository) - } - - public func exists() async throws -> Bool { - let response = try await repository.session.sendRequest(OuisyncRequest.directoryExists(repository.handle, path)) - return response.value.boolValue! - } - - public func delete(recursive: Bool) async throws { - try await repository.deleteDirectory(path, recursive: recursive) - } - - public func getVersionHash() async throws -> Data { - try await repository.getEntryVersionHash(path) - } - - public static func parent(_ path: FilePath) -> FilePath? { - if path.components.isEmpty { - return nil - } else { - var parentPath = path - parentPath.components.removeLast() - return parentPath - } - } - - public var debugDescription: String { - return "OuisyncDirectory(\(path), \(repository))" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncError.swift b/bindings/swift/OuisyncLib/Sources/OuisyncError.swift deleted file mode 100644 index 1061c8182..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncError.swift +++ /dev/null @@ -1,84 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation - -public enum OuisyncErrorCode: UInt16 { - /// Store error - case Store = 1 - /// Insuficient permission to perform the intended operation - case PermissionDenied = 2 - /// Malformed data - case MalformedData = 3 - /// Entry already exists - case EntryExists = 4 - /// Entry doesn't exist - case EntryNotFound = 5 - /// Multiple matching entries found - case AmbiguousEntry = 6 - /// The intended operation requires the directory to be empty but it isn't - case DirectoryNotEmpty = 7 - /// The indended operation is not supported - case OperationNotSupported = 8 - /// Failed to read from or write into the config file - case Config = 10 - /// Argument passed to a function is not valid - case InvalidArgument = 11 - /// Request or response is malformed - case MalformedMessage = 12 - /// Storage format version mismatch - case StorageVersionMismatch = 13 - /// Connection lost - case ConnectionLost = 14 - /// Invalid handle to a resource (e.g., Repository, File, ...) - case InvalidHandle = 15 - /// Entry has been changed and no longer matches the expected value - case EntryChanged = 16 - - // These can't happen and apple devices - // case VfsInvalidMountPoint = 2048 - // case VfsDriverInstall = 2049 - // case VfsBackend = 2050 - - /// Unspecified error - case Other = 65535 -} - -public class OuisyncError : Error, CustomDebugStringConvertible { - public let code: OuisyncErrorCode - public let message: String - - init(_ code: OuisyncErrorCode, _ message: String) { - self.code = code - self.message = message - } - - public var debugDescription: String { - let codeStr: String - - switch code { - case .Store: codeStr = "Store error" - case .PermissionDenied: codeStr = "Insuficient permission to perform the intended operation" - case .MalformedData: codeStr = "Malformed data" - case .EntryExists: codeStr = "Entry already exists" - case .EntryNotFound: codeStr = "Entry doesn't exist" - case .AmbiguousEntry: codeStr = "Multiple matching entries found" - case .DirectoryNotEmpty: codeStr = "The intended operation requires the directory to be empty but it isn't" - case .OperationNotSupported: codeStr = "The indended operation is not supported" - case .Config: codeStr = "Failed to read from or write into the config file" - case .InvalidArgument: codeStr = "Argument passed to a function is not valid" - case .MalformedMessage: codeStr = "Request or response is malformed" - case .StorageVersionMismatch: codeStr = "Storage format version mismatch" - case .ConnectionLost: codeStr = "Connection lost" - case .InvalidHandle: codeStr = "Invalid handle to a resource (e.g., Repository, File, ...)" - case .EntryChanged: codeStr = "Entry has been changed and no longer matches the expected value" - - case .Other: codeStr = "Unspecified error" - } - - return "OuisyncError(code:\(code), codeStr:\"\(codeStr)\", message:\"\(message)\")" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift b/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift deleted file mode 100644 index 36eb00ad0..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncFFI.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// File.swift -// -// -// Created by Peter Jankuliak on 19/07/2024. -// - -import Foundation -import OuisyncLibFFI - - -/* TODO: โฌ‡๏ธ - - Since we're now linking statically and both rust-cbindgen and swift do a reasonable job at guessing - the intended types, I don't expect these types to ever make it to the main branch because this file - will most likely go away. For now they are kept to avoid touching too much code in a single commit. - */ -typealias FFISessionKind = UInt8 // swift gets confused here and imports a UInt32 enum as well as a UInt8 typealias -typealias FFIContext = UnsafeMutableRawPointer // exported as `* mut ()` in rust so this is correct, annoyingly -typealias FFICallback = @convention(c) (FFIContext?, UnsafePointer?, UInt64) -> Void; -typealias FFISessionCreate = @convention(c) (FFISessionKind, UnsafePointer?, UnsafePointer?, UnsafePointer?, FFIContext?, FFICallback?) -> SessionCreateResult; -typealias FFISessionGrab = @convention(c) (FFIContext?, FFICallback?) -> SessionCreateResult; -typealias FFISessionClose = @convention(c) (SessionHandle, FFIContext?, FFICallback?) -> Void; -typealias FFISessionChannelSend = @convention(c) (SessionHandle, UnsafeMutablePointer?, UInt64) -> Void; - -class SessionCreateError : Error, CustomStringConvertible { - let message: String - init(_ message: String) { self.message = message } - var description: String { message } -} - -public class OuisyncFFI { - // let handle: UnsafeMutableRawPointer - let ffiSessionGrab: FFISessionGrab - let ffiSessionCreate: FFISessionCreate - let ffiSessionChannelSend: FFISessionChannelSend - let ffiSessionClose: FFISessionClose - let sessionKindShared: FFISessionKind = 0; - - public init() { - // The .dylib is created using the OuisyncDyLibBuilder package plugin in this Swift package. - // let libraryName = "libouisync_ffi.dylib" - // let resourcePath = Bundle.main.resourcePath! + "/OuisyncLib_OuisyncLibFFI.bundle/Contents/Resources" - // handle = dlopen("\(resourcePath)/\(libraryName)", RTLD_NOW)! - - ffiSessionGrab = session_grab - ffiSessionChannelSend = session_channel_send - ffiSessionClose = session_close - ffiSessionCreate = session_create - - //ffiSessionGrab = unsafeBitCast(dlsym(handle, "session_grab"), to: FFISessionGrab.self) - //ffiSessionChannelSend = unsafeBitCast(dlsym(handle, "session_channel_send"), to: FFISessionChannelSend.self) - //ffiSessionClose = unsafeBitCast(dlsym(handle, "session_close"), to: FFISessionClose.self) - //ffiSessionCreate = unsafeBitCast(dlsym(handle, "session_create"), to: FFISessionCreate.self) - } - - // Blocks until Dart creates a session, then returns it. - func waitForSession(_ context: FFIContext, _ callback: FFICallback) async throws -> SessionHandle { - // TODO: Might be worth change the ffi function to call a callback when the session becomes created instead of bussy sleeping. - var elapsed: UInt64 = 0; - while true { - let result = ffiSessionGrab(context, callback) - if result.error_code == 0 { - NSLog("๐Ÿ˜€ Got Ouisync session"); - return result.session - } - NSLog("๐Ÿคจ Ouisync session not yet ready. Code: \(result.error_code) Message:\(String(cString: result.error_message!))"); - - let millisecond: UInt64 = 1_000_000 - let second: UInt64 = 1000 * millisecond - - var timeout = 200 * millisecond - - if elapsed > 10 * second { - timeout = second - } - - try await Task.sleep(nanoseconds: timeout) - elapsed += timeout; - } - } - - // Retained pointers have their reference counter incremented by 1. - // https://stackoverflow.com/a/33310021/273348 - static func toUnretainedPtr(obj : T) -> UnsafeRawPointer { - return UnsafeRawPointer(Unmanaged.passUnretained(obj).toOpaque()) - } - - static func fromUnretainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeUnretainedValue() - } - - static func toRetainedPtr(obj : T) -> UnsafeRawPointer { - return UnsafeRawPointer(Unmanaged.passRetained(obj).toOpaque()) - } - - static func fromRetainedPtr(ptr : UnsafeRawPointer) -> T { - return Unmanaged.fromOpaque(ptr).takeRetainedValue() - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift b/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift deleted file mode 100644 index 995a40705..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncFile.swift +++ /dev/null @@ -1,42 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System - -public class OuisyncFile { - public let repository: OuisyncRepository - let handle: FileHandle - - init(_ handle: FileHandle, _ repository: OuisyncRepository) { - self.repository = repository - self.handle = handle - } - - public func read(_ offset: UInt64, _ length: UInt64) async throws -> Data { - try await session.sendRequest(.fileRead(handle, offset, length)).toData() - } - - public func write(_ offset: UInt64, _ data: Data) async throws { - let _ = try await session.sendRequest(.fileWrite(handle, offset, data)) - } - - public func size() async throws -> UInt64 { - try await session.sendRequest(.fileLen(handle)).toUInt64() - } - - public func truncate(_ len: UInt64) async throws { - let _ = try await session.sendRequest(.fileTruncate(handle, len)) - } - - public func close() async throws { - let _ = try await session.sendRequest(.fileClose(handle)) - } - - var session: OuisyncSession { - repository.session - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift b/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift deleted file mode 100644 index abed67ff0..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncLib.swift +++ /dev/null @@ -1,11 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation - -public typealias MessageId = UInt64 -public typealias RepositoryHandle = UInt64 -public typealias FileHandle = UInt64 diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift b/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift deleted file mode 100644 index 6aa8f45ef..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncMessage.swift +++ /dev/null @@ -1,399 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import MessagePack -import System - -//-------------------------------------------------------------------- - -public class OuisyncRequest { - let functionName: String - let functionArguments: MessagePackValue - - init(_ functionName: String, _ functionArguments: MessagePackValue) { - self.functionName = functionName - self.functionArguments = functionArguments - } - - public static func listRepositories() -> OuisyncRequest { - return OuisyncRequest("list_repositories", MessagePackValue.nil) - } - - public static func subscribeToRepositoryListChange() -> OuisyncRequest { - return OuisyncRequest("list_repositories_subscribe", MessagePackValue.nil) - } - - public static func subscribeToRepositoryChange(_ handle: RepositoryHandle) -> OuisyncRequest { - return OuisyncRequest("repository_subscribe", MessagePackValue(handle)) - } - - public static func getRepositoryName(_ handle: RepositoryHandle) -> OuisyncRequest { - return OuisyncRequest("repository_name", MessagePackValue(handle)) - } - - public static func repositoryMoveEntry(_ repoHandle: RepositoryHandle, _ srcPath: FilePath, _ dstPath: FilePath) -> OuisyncRequest { - return OuisyncRequest("repository_move_entry", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("src"): MessagePackValue(srcPath.description), - MessagePackValue("dst"): MessagePackValue(dstPath.description), - ])) - } - - public static func listEntries(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_open", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func getEntryVersionHash(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("repository_entry_version_hash", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func directoryExists(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_exists", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func directoryRemove(_ handle: RepositoryHandle, _ path: FilePath, _ recursive: Bool) -> OuisyncRequest { - return OuisyncRequest("directory_remove", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - MessagePackValue("recursive"): MessagePackValue(recursive), - ])) - } - - public static func directoryCreate(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("directory_create", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileOpen(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_open", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileExists(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_exists", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileRemove(_ handle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_remove", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(handle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileClose(_ fileHandle: FileHandle) -> OuisyncRequest { - return OuisyncRequest("file_close", MessagePackValue(fileHandle)) - } - - public static func fileRead(_ fileHandle: FileHandle, _ offset: UInt64, _ len: UInt64) -> OuisyncRequest { - return OuisyncRequest("file_read", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("offset"): MessagePackValue(offset), - MessagePackValue("len"): MessagePackValue(len), - ])) - } - - public static func fileTruncate(_ fileHandle: FileHandle, _ len: UInt64) -> OuisyncRequest { - return OuisyncRequest("file_truncate", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("len"): MessagePackValue(len), - ])) - } - - public static func fileLen(_ fileHandle: FileHandle) -> OuisyncRequest { - return OuisyncRequest("file_len", MessagePackValue(fileHandle)) - } - - public static func fileCreate(_ repoHandle: RepositoryHandle, _ path: FilePath) -> OuisyncRequest { - return OuisyncRequest("file_create", MessagePackValue([ - MessagePackValue("repository"): MessagePackValue(repoHandle), - MessagePackValue("path"): MessagePackValue(path.description), - ])) - } - - public static func fileWrite(_ fileHandle: FileHandle, _ offset: UInt64, _ data: Data) -> OuisyncRequest { - return OuisyncRequest("file_write", MessagePackValue([ - MessagePackValue("file"): MessagePackValue(fileHandle), - MessagePackValue("offset"): MessagePackValue(offset), - MessagePackValue("data"): MessagePackValue(data), - ])) - } -} - -//-------------------------------------------------------------------- - -public class OuisyncRequestMessage { - public let messageId: MessageId - public let request: OuisyncRequest - - init(_ messageId: MessageId, _ request: OuisyncRequest) { - self.messageId = messageId - self.request = request - } - - public func serialize() -> [UInt8] { - var message: [UInt8] = [] - message.append(contentsOf: withUnsafeBytes(of: messageId.bigEndian, Array.init)) - let payload = [MessagePackValue.string(request.functionName): request.functionArguments] - message.append(contentsOf: pack(MessagePackValue.map(payload))) - return message - } - - public static func deserialize(_ data: [UInt8]) -> OuisyncRequestMessage? { - guard let (id, data) = readMessageId(data) else { - return nil - } - - let unpacked = (try? unpack(data))?.0 - - guard case let .map(m) = unpacked else { return nil } - if m.count != 1 { return nil } - guard let e = m.first else { return nil } - guard let functionName = e.key.stringValue else { return nil } - let functionArguments = e.value - - return OuisyncRequestMessage(id, OuisyncRequest(functionName, functionArguments)) - } -} - -public class OuisyncResponseMessage { - public let messageId: MessageId - public let payload: OuisyncResponsePayload - - public init(_ messageId: MessageId, _ payload: OuisyncResponsePayload) { - self.messageId = messageId - self.payload = payload - } - - public func serialize() -> [UInt8] { - var message: [UInt8] = [] - message.append(contentsOf: withUnsafeBytes(of: messageId.bigEndian, Array.init)) - let body: MessagePackValue; - switch payload { - case .response(let response): - body = MessagePackValue.map(["success": Self.responseValue(response.value)]) - case .notification(let notification): - body = MessagePackValue.map(["notification": notification.value]) - case .error(let error): - let code = Int64(exactly: error.code.rawValue)! - body = MessagePackValue.map(["failure": .array([.int(code), .string(error.message)])]) - } - message.append(contentsOf: pack(body)) - return message - } - - static func responseValue(_ value: MessagePackValue) -> MessagePackValue { - switch value { - case .nil: return .string("none") - default: - // The flutter code doesn't read the key which is supposed to be a type, - // would still be nice to have a proper mapping. - return .map(["todo-type": value]) - } - } - - public static func deserialize(_ bytes: [UInt8]) -> OuisyncResponseMessage? { - guard let (id, data) = readMessageId(bytes) else { - return nil - } - - let unpacked = (try? unpack(Data(data)))?.0 - - if case let .map(m) = unpacked { - if let success = m[.string("success")] { - if let value = parseResponse(success) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.response(value)) - } - } else if let error = m[.string("failure")] { - if let response = parseFailure(error) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.error(response)) - } - } else if let notification = m[.string("notification")] { - if let value = parseNotification(notification) { - return OuisyncResponseMessage(id, OuisyncResponsePayload.notification(value)) - } - } - } - - return nil - } -} - -extension OuisyncResponseMessage: CustomStringConvertible { - public var description: String { - return "IncomingMessage(\(messageId), \(payload))" - } -} - -fileprivate func readMessageId(_ data: [UInt8]) -> (MessageId, Data)? { - let idByteCount = (MessageId.bitWidth / UInt8.bitWidth) - - if data.count < idByteCount { - return nil - } - - let bigEndianValue = data.withUnsafeBufferPointer { - ($0.baseAddress!.withMemoryRebound(to: MessageId.self, capacity: 1) { $0 }) - }.pointee - - let id = MessageId(bigEndian: bigEndianValue) - - return (id, Data(data[idByteCount...])) -} -//-------------------------------------------------------------------- - -public enum OuisyncResponsePayload { - case response(Response) - case notification(OuisyncNotification) - case error(OuisyncError) -} - -extension OuisyncResponsePayload: CustomStringConvertible { - public var description: String { - switch self { - case .response(let response): - return "response(\(response))" - case .notification(let notification): - return "notification(\(notification))" - case .error(let error): - return "error(\(error))" - } - } -} - -//-------------------------------------------------------------------- - -public enum IncomingSuccessPayload { - case response(Response) - case notification(OuisyncNotification) -} - -extension IncomingSuccessPayload: CustomStringConvertible { - public var description: String { - switch self { - case .response(let value): - return "response(\(value))" - case .notification(let value): - return "notificateion(\(value))" - } - } -} - -//-------------------------------------------------------------------- - -public class Response { - public let value: MessagePackValue - - // Note about unwraps in these methods. It is expected that the - // caller knows what type the response is. If the expected and - // the actual types differ, then it is likely that there is a - // mismatch between the front end and the backend in the FFI API. - - public init(_ value: MessagePackValue) { - self.value = value - } - - public func toData() -> Data { - return value.dataValue! - } - - public func toUInt64Array() -> [UInt64] { - return value.arrayValue!.map({ $0.uint64Value! }) - } - - public func toUInt64() -> UInt64 { - return value.uint64Value! - } - - public func toBool() -> Bool { - return value.boolValue! - } -} - -extension Response: CustomStringConvertible { - public var description: String { - return "Response(\(value))" - } -} - -//-------------------------------------------------------------------- - -public class OuisyncNotification { - let value: MessagePackValue - init(_ value: MessagePackValue) { - self.value = value - } -} - -extension OuisyncNotification: CustomStringConvertible { - public var description: String { - return "Notification(\(value))" - } -} - -//-------------------------------------------------------------------- - -func parseResponse(_ value: MessagePackValue) -> Response? { - if case let .map(m) = value { - if m.count != 1 { - return nil - } - return Response(m.first!.value) - } else if case let .string(str) = value, str == "none" { - // A function was called which has a `void` return value. - return Response(.nil) - } - return nil -} - -func parseFailure(_ value: MessagePackValue) -> OuisyncError? { - if case let .array(arr) = value { - if arr.count != 2 { - return nil - } - if case let .uint(code) = arr[0] { - if case let .string(message) = arr[1] { - guard let codeU16 = UInt16(exactly: code) else { - fatalError("Error code from backend is out of range") - } - guard let codeEnum = OuisyncErrorCode(rawValue: codeU16) else { - fatalError("Invalid error code from backend") - } - return OuisyncError(codeEnum, message) - } - } - } - return nil -} - -func parseNotification(_ value: MessagePackValue) -> OuisyncNotification? { - if case .string(_) = value { - return OuisyncNotification(MessagePackValue.nil) - } - if case let .map(m) = value { - if m.count != 1 { - return nil - } - return OuisyncNotification(m.first!.value) - } - return nil -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift b/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift deleted file mode 100644 index b2cfd6632..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncRepository.swift +++ /dev/null @@ -1,79 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import System -import MessagePack - -public class OuisyncRepository: Hashable, CustomDebugStringConvertible { - let session: OuisyncSession - public let handle: RepositoryHandle - - public init(_ handle: RepositoryHandle, _ session: OuisyncSession) { - self.handle = handle - self.session = session - } - - public func getName() async throws -> String { - let data = try await session.sendRequest(.getRepositoryName(handle)).toData() - return String(decoding: data, as: UTF8.self) - } - - public func fileEntry(_ path: FilePath) -> OuisyncFileEntry { - OuisyncFileEntry(path, self) - } - - public func getEntryVersionHash(_ path: FilePath) async throws -> Data { - try await session.sendRequest(.getEntryVersionHash(handle, path)).toData() - } - - public func directoryEntry(_ path: FilePath) -> OuisyncDirectoryEntry { - OuisyncDirectoryEntry(path, self) - } - - public func getRootDirectory() -> OuisyncDirectoryEntry { - return OuisyncDirectoryEntry(FilePath("/"), self) - } - - public func createFile(_ path: FilePath) async throws -> OuisyncFile { - let handle = try await session.sendRequest(.fileCreate(handle, path)).toUInt64() - return OuisyncFile(handle, self) - } - - public func openFile(_ path: FilePath) async throws -> OuisyncFile { - let handle = try await session.sendRequest(.fileOpen(handle, path)).toUInt64() - return OuisyncFile(handle, self) - } - - public func deleteFile(_ path: FilePath) async throws { - let _ = try await session.sendRequest(.fileRemove(handle, path)) - } - - public func createDirectory(_ path: FilePath) async throws { - let _ = try await session.sendRequest(.directoryCreate(handle, path)) - } - - public func deleteDirectory(_ path: FilePath, recursive: Bool) async throws { - let _ = try await session.sendRequest(.directoryRemove(handle, path, recursive)) - } - - public func moveEntry(_ sourcePath: FilePath, _ destinationPath: FilePath) async throws { - let _ = try await session.sendRequest(.repositoryMoveEntry(handle, sourcePath, destinationPath)) - } - - public static func == (lhs: OuisyncRepository, rhs: OuisyncRepository) -> Bool { - return lhs.session === rhs.session && lhs.handle == rhs.handle - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(session)) - hasher.combine(handle) - } - - public var debugDescription: String { - return "OuisyncRepository(handle: \(handle))" - } -} diff --git a/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift b/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift deleted file mode 100644 index 9944be5d5..000000000 --- a/bindings/swift/OuisyncLib/Sources/OuisyncSession.swift +++ /dev/null @@ -1,186 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import Foundation -import MessagePack - -public class OuisyncSession { - let configsPath: String - let logsPath: String - - public let client: OuisyncClient - - var nextMessageId: MessageId = 0 - var pendingResponses: [MessageId: CheckedContinuation] = [:] - var notificationSubscriptions: NotificationStream.State = NotificationStream.State() - - public init(_ configsPath: String, _ logsPath: String, _ ffi: OuisyncFFI) throws { - self.configsPath = configsPath - self.logsPath = logsPath - - client = try OuisyncClient.create(configsPath, logsPath, ffi) - client.onReceiveFromBackend = { [weak self] data in - self?.onReceiveDataFromOuisyncLib(data) - } - } - - public func connectNewClient() throws -> OuisyncClient { - return try OuisyncClient.create(configsPath, logsPath, client.ffi) - } - - // Can be called from a separate thread. - public func invoke(_ requestMsg: OuisyncRequestMessage) async -> OuisyncResponseMessage { - let responsePayload: OuisyncResponsePayload - - do { - responsePayload = .response(try await sendRequest(requestMsg.request)) - } catch let e as OuisyncError { - responsePayload = .error(e) - } catch let e { - fatalError("Unhandled exception in OuisyncSession.invoke: \(e)") - } - - return OuisyncResponseMessage(requestMsg.messageId, responsePayload) - } - - public func listRepositories() async throws -> [OuisyncRepository] { - let response = try await sendRequest(OuisyncRequest.listRepositories()); - let handles = response.toUInt64Array() - return handles.map({ OuisyncRepository($0, self) }) - } - - public func subscribeToRepositoryListChange() async throws -> NotificationStream { - let subscriptionId = try await sendRequest(OuisyncRequest.subscribeToRepositoryListChange()).toUInt64(); - return NotificationStream(subscriptionId, notificationSubscriptions) - } - - public func subscribeToRepositoryChange(_ repo: RepositoryHandle) async throws -> NotificationStream { - let subscriptionId = try await sendRequest(OuisyncRequest.subscribeToRepositoryChange(repo)).toUInt64(); - return NotificationStream(subscriptionId, notificationSubscriptions) - } - - // Can be called from a separate thread. - internal func sendRequest(_ request: OuisyncRequest) async throws -> Response { - let messageId = generateMessageId() - - async let onResponse = withCheckedThrowingContinuation { [weak self] continuation in - guard let session = self else { return } - - synchronized(session) { - session.pendingResponses[messageId] = continuation - let data = OuisyncRequestMessage(messageId, request).serialize() - session.client.sendToBackend(data) - } - } - - return try await onResponse - } - - // Can be called from a separate thread. - fileprivate func generateMessageId() -> MessageId { - synchronized(self) { - let messageId = nextMessageId - nextMessageId += 1 - return messageId - } - } - - // Use this function to pass data from the backend. - // It may be called from a separate thread. - public func onReceiveDataFromOuisyncLib(_ data: [UInt8]) { - let maybe_message = OuisyncResponseMessage.deserialize(data) - - guard let message = maybe_message else { - let hex = data.map({String(format:"%02x", $0)}).joined(separator: ",") - // Likely cause is a version mismatch between the backend (Rust) and frontend (Swift) code. - fatalError("Failed to parse incoming message from OuisyncLib [\(hex)]") - } - - switch message.payload { - case .response(let response): - handleResponse(message.messageId, response) - case .notification(let notification): - handleNotification(message.messageId, notification) - case .error(let error): - handleError(message.messageId, error) - } - } - - fileprivate func handleResponse(_ messageId: MessageId, _ response: Response) { - let maybePendingResponse = synchronized(self) { pendingResponses.removeValue(forKey: messageId) } - - guard let pendingResponse = maybePendingResponse else { - fatalError("โ— Failed to match response to a request. messageId:\(messageId), repsponse:\(response) ") - } - - pendingResponse.resume(returning: response) - } - - fileprivate func handleNotification(_ messageId: MessageId, _ response: OuisyncNotification) { - let maybeTx = synchronized(self) { notificationSubscriptions.registrations[messageId] } - - if let tx = maybeTx { - tx.yield(()) - } else { - NSLog("โ— Received unsolicited notification") - } - } - - fileprivate func handleError(_ messageId: MessageId, _ response: OuisyncError) { - let maybePendingResponse = synchronized(self) { pendingResponses.removeValue(forKey: messageId) } - - guard let pendingResponse = maybePendingResponse else { - fatalError("โ— Failed to match error response to a request. messageId:\(messageId), response:\(response)") - } - - pendingResponse.resume(throwing: response) - } - -} - -fileprivate func synchronized(_ lock: AnyObject, _ closure: () throws -> T) rethrows -> T { - objc_sync_enter(lock) - defer { objc_sync_exit(lock) } - return try closure() -} - -public class NotificationStream { - typealias Id = UInt64 - typealias Rx = AsyncStream<()> - typealias RxIter = Rx.AsyncIterator - typealias Tx = Rx.Continuation - - class State { - var registrations: [Id: Tx] = [:] - } - - let subscriptionId: Id - let rx: Rx - var rx_iter: RxIter - var state: State - - init(_ subscriptionId: Id, _ state: State) { - self.subscriptionId = subscriptionId - var tx: Tx! - rx = Rx (bufferingPolicy: Tx.BufferingPolicy.bufferingOldest(1), { tx = $0 }) - self.rx_iter = rx.makeAsyncIterator() - - self.state = state - - state.registrations[subscriptionId] = tx - } - - public func next() async -> ()? { - return await rx_iter.next() - } - - deinit { - // TODO: We should have a `close() async` function where we unsubscripbe - // from the notifications. - state.registrations.removeValue(forKey: subscriptionId) - } -} - diff --git a/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift b/bindings/swift/OuisyncLib/Tests/OuisyncLibTests.swift deleted file mode 100644 index c6b9d946a..000000000 --- a/bindings/swift/OuisyncLib/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/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 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..37cdf5384 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -12,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(); @@ -27,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(); @@ -51,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(); @@ -78,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 @@ -97,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); } @@ -140,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(); @@ -182,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 @@ -226,6 +234,7 @@ fn relay() { } #[test] +#[cfg_attr(any(target_os = "macos", target_os = "ios"), ignore)] fn concurrent_update() { let (a, b) = setup(); @@ -343,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(); 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/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/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()?)) 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/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 27664f1c1..72c7a8c11 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}; @@ -1062,13 +1063,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) @@ -1077,7 +1078,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()), } @@ -1119,22 +1123,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 @@ -1254,8 +1259,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", 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;