This commit is contained in:
Jamie Pine committed 2022-10-06 18:10:08 -07:00
commit 5ff3d5fecb
177 files changed
+9534 -5633

No files matched your search

+5 -2
View File
@@ -155,8 +155,11 @@ jobs:
- name: Generate Prisma client
uses: ./.github/actions/generate-prisma-client
- name: Build everything
run: cargo build --release
- name: Cargo fetch
run: cargo fetch
- name: Check core
run: cargo check -p sd-core --release
- name: Bundle Desktop
run: pnpm desktop tauri build
+1 -2
View File
@@ -27,7 +27,6 @@ apps/*/stats.html
docs/public/*.st
docs/public/*.toml
dev.db
stats.html
!cli/cmd/turbo
cli/npm/turbo-android-arm64/bin
@@ -66,4 +65,4 @@ examples/*/*.lock
/sdserver_data
.spacedrive
dev.db-journal
dev.db-journal
+2
View File
@@ -0,0 +1,2 @@
/target
.build
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
bracketSameLine: false,
semi: true,
quoteProps: 'consistent',
importOrder: ['^@sd/interface/(.*)$', '^@sd/client/(.*)$', '^@sd/ui/(.*)$', '^[./]'],
importOrder: ['^[./]', '^@sd/interface/(.*)$', '^@sd/client/(.*)$', '^@sd/ui/(.*)$'],
importOrderSeparation: true,
importOrderSortSpecifiers: true,
plugins: ['@trivago/prettier-plugin-sort-imports']
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer",
"oscartbeaumont.rspc-vscode"
]
}
Generated
+1062 -174
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -2,6 +2,8 @@
members = [
"core",
"crates/*",
"crates/p2p/tunnel",
"crates/p2p/tunnel/utils",
"crates/sync/example/src-tauri",
"apps/desktop/src-tauri",
"apps/mobile/rust",
@@ -10,19 +12,22 @@ members = [
resolver = "2"
[workspace.dependencies]
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust.git", rev = "762ba6c06b9386102aea37538fdfe0b2aa24fd52", features = [
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust.git", rev = "078919e3724f35cc00c2402c66ba585b61c88d47", features = [
"rspc",
"sqlite-create-many",
"migrations",
"sqlite",
], default-features = false }
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust.git", rev = "762ba6c06b9386102aea37538fdfe0b2aa24fd52", features = [
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust.git", rev = "078919e3724f35cc00c2402c66ba585b61c88d47", features = [
"rspc",
"sqlite-create-many",
"migrations",
"sqlite",
], default-features = false }
rspc = { version = "0.1.2" }
[patch.crates-io]
# We use this patch so we can compile for the IOS simulator on M1
openssl-sys = { git = "https://github.com/spacedriveapp/rust-openssl" }
openssl-sys = { git = "https://github.com/spacedriveapp/rust-openssl", rev = "92c3dec225a9e984884d5b30a517e5d44a24d03b" }
rspc = { git = "https://github.com/oscartbeaumont/rspc", rev = "08c19f5e485dfc6841c7e9877c80058402bf71d7" } # TODO: Move back to crates.io when new jsonrpc executor is release
-1
View File
@@ -109,4 +109,3 @@ This project is using what I'm calling the **"PRRTT"** stack (Prisma, Rust, Reac
- `ios`: A [Swift](https://developer.apple.com/swift/) Native binary (planned).
- `windows`: A [C#](https://docs.microsoft.com/en-us/dotnet/csharp/) Native binary (planned).
- `android`: A [Kotlin](https://kotlinlang.org/) Native binary (planned).
+11 -11
View File
@@ -11,32 +11,32 @@
"build": "tauri build"
},
"dependencies": {
"@rspc/client": "^0.0.6",
"@rspc/tauri": "^0.0.6",
"@rspc/client": "^0.1.2",
"@rspc/tauri": "^0.1.2",
"@sd/client": "workspace:*",
"@sd/interface": "workspace:*",
"@sd/ui": "workspace:*",
"@tanstack/react-query": "^4.0.10",
"@tauri-apps/api": "1.0.2",
"@tanstack/react-query": "^4.10.1",
"@tauri-apps/api": "1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@tauri-apps/cli": "1.0.5",
"@tauri-apps/cli": "1.1.1",
"@tauri-apps/tauricon": "github:tauri-apps/tauricon",
"@types/babel-core": "^6.25.7",
"@types/byte-size": "^8.1.0",
"@types/react": "^18.0.15",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/react-router-dom": "^5.3.3",
"@types/react-window": "^1.8.5",
"@types/tailwindcss": "^3.1.0",
"@vitejs/plugin-react": "^2.0.0",
"concurrently": "^7.3.0",
"@vitejs/plugin-react": "^2.1.0",
"concurrently": "^7.4.0",
"prettier": "^2.7.1",
"sass": "^1.54.0",
"typescript": "^4.7.4",
"vite": "^3.0.3",
"sass": "^1.55.0",
"typescript": "^4.8.4",
"vite": "^3.1.4",
"vite-plugin-filter-replace": "^0.1.9",
"vite-plugin-svgr": "^2.2.1"
}
-1
View File
@@ -1 +0,0 @@
RUST_LOG=spacedrive=debug,sdcore=debug
+7 -7
View File
@@ -10,19 +10,19 @@ edition = "2021"
build = "build.rs"
[dependencies]
tauri = { version = "1.0.4", features = ["api-all", "macos-private-api"] }
rspc = { version = "0.0.5", features = ["tauri"] }
tauri = { version = "1.1.1", features = ["api-all", "macos-private-api"] }
rspc = { workspace = true, features = ["tauri"] }
sd-core = { path = "../../../core", features = ["ffmpeg"] }
tokio = { version = "1.17.0", features = ["sync"] }
window-shadows = "0.1.2"
tracing = "0.1.35"
serde = "1.0.144"
tokio = { version = "1.21.2", features = ["sync"] }
window-shadows = "0.2.0"
tracing = "0.1.36"
serde = "1.0.145"
[target.'cfg(target_os = "macos")'.dependencies]
swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "autorelease" }
[build-dependencies]
tauri-build = { version = "1.0.0", features = [] }
tauri-build = { version = "1.1.1", features = [] }
[target.'cfg(target_os = "macos")'.build-dependencies]
swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "autorelease", features = [
+2 -2
View File
@@ -1,6 +1,6 @@
import { createClient } from '@rspc/client';
import { TauriTransport } from '@rspc/tauri';
import { OperatingSystem, Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
import { OperatingSystem, PlatformProvider, Procedures, queryClient, rspc } from '@sd/client';
import SpacedriveInterface, { Platform } from '@sd/interface';
import { KeybindEvent } from '@sd/interface';
import { dialog, invoke, os, shell } from '@tauri-apps/api';
@@ -10,7 +10,7 @@ import { createRoot } from 'react-dom/client';
import '@sd/ui/style';
const client = createClient<Operations>({
const client = createClient<Procedures>({
transport: new TauriTransport()
});
+1 -1
View File
@@ -7,7 +7,7 @@ apply plugin: 'org.mozilla.rust-android-gradle.rust-android'
cargo {
module = "../../rust"
libname = "sdcore"
libname = "sd_core_mobile"
// profile = 'release',
pythonCommand = 'python3'
targets = ["arm", "arm64", "x86", "x86_64"]
@@ -28,7 +28,7 @@ public class SDCore extends ReactContextBaseJavaModule {
}
static {
System.loadLibrary("sdcore");
System.loadLibrary("sd_core_mobile");
}
// is exposed by Rust and is used to register the subscription
@@ -47,6 +47,11 @@ public class SDCore extends ReactContextBaseJavaModule {
return getCurrentActivity().getFilesDir().toString();
}
public void print(String msg)
{
System.out.println(msg);
}
@ReactMethod
public void addListener(String eventName)
{
+26 -26
View File
@@ -7,13 +7,13 @@ PODS:
- ExpoModulesCore
- EXFileSystem (14.1.0):
- ExpoModulesCore
- EXFont (10.2.0):
- EXFont (10.2.1):
- ExpoModulesCore
- Expo (46.0.10):
- Expo (46.0.15):
- ExpoModulesCore
- ExpoKeepAwake (10.2.0):
- ExpoModulesCore
- ExpoModulesCore (0.11.5):
- ExpoModulesCore (0.11.7):
- React-Core
- ReactCommon/turbomodule/core
- EXSplashScreen (0.16.2):
@@ -31,7 +31,7 @@ PODS:
- glog (0.3.5)
- hermes-engine (0.69.4)
- libevent (2.1.12)
- lottie-ios (3.4.2)
- lottie-ios (3.4.3)
- lottie-react-native (5.1.4):
- lottie-ios (~> 3.4.0)
- React-Core
@@ -333,7 +333,7 @@ PODS:
- React-jsi (= 0.69.4)
- React-logger (= 0.69.4)
- React-perflogger (= 0.69.4)
- RNCAsyncStorage (1.17.7):
- RNCAsyncStorage (1.17.10):
- React-Core
- RNCMaskedView (0.2.7):
- React-Core
@@ -376,14 +376,14 @@ PODS:
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- "EXApplication (from `../node_modules/.pnpm/expo-application@4.2.2_expo@46.0.10/node_modules/expo-application/ios`)"
- "EXConstants (from `../node_modules/.pnpm/expo-constants@13.2.4_expo@46.0.10/node_modules/expo-constants/ios`)"
- "EXFileSystem (from `../node_modules/.pnpm/expo-file-system@14.1.0_expo@46.0.10/node_modules/expo-file-system/ios`)"
- "EXFont (from `../node_modules/.pnpm/expo-font@10.2.0_expo@46.0.10/node_modules/expo-font/ios`)"
- "Expo (from `../node_modules/.pnpm/expo@46.0.10_@babel+core@7.18.10/node_modules/expo`)"
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@10.2.0_expo@46.0.10/node_modules/expo-keep-awake/ios`)"
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@0.11.5/node_modules/expo-modules-core/ios`)"
- "EXSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@0.16.2_expo@46.0.10/node_modules/expo-splash-screen/ios`)"
- "EXApplication (from `../node_modules/.pnpm/expo-application@4.2.2_expo@46.0.15/node_modules/expo-application/ios`)"
- "EXConstants (from `../node_modules/.pnpm/expo-constants@13.2.4_expo@46.0.15/node_modules/expo-constants/ios`)"
- "EXFileSystem (from `../node_modules/.pnpm/expo-file-system@14.1.0_expo@46.0.15/node_modules/expo-file-system/ios`)"
- "EXFont (from `../node_modules/.pnpm/expo-font@10.2.1_expo@46.0.15/node_modules/expo-font/ios`)"
- "Expo (from `../node_modules/.pnpm/expo@46.0.15_@babel+core@7.19.3/node_modules/expo`)"
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@10.2.0_expo@46.0.15/node_modules/expo-keep-awake/ios`)"
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@0.11.7/node_modules/expo-modules-core/ios`)"
- "EXSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@0.16.2_expo@46.0.15/node_modules/expo-splash-screen/ios`)"
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`)
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
@@ -439,21 +439,21 @@ EXTERNAL SOURCES:
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXApplication:
:path: "../node_modules/.pnpm/expo-application@4.2.2_expo@46.0.10/node_modules/expo-application/ios"
:path: "../node_modules/.pnpm/expo-application@4.2.2_expo@46.0.15/node_modules/expo-application/ios"
EXConstants:
:path: "../node_modules/.pnpm/expo-constants@13.2.4_expo@46.0.10/node_modules/expo-constants/ios"
:path: "../node_modules/.pnpm/expo-constants@13.2.4_expo@46.0.15/node_modules/expo-constants/ios"
EXFileSystem:
:path: "../node_modules/.pnpm/expo-file-system@14.1.0_expo@46.0.10/node_modules/expo-file-system/ios"
:path: "../node_modules/.pnpm/expo-file-system@14.1.0_expo@46.0.15/node_modules/expo-file-system/ios"
EXFont:
:path: "../node_modules/.pnpm/expo-font@10.2.0_expo@46.0.10/node_modules/expo-font/ios"
:path: "../node_modules/.pnpm/expo-font@10.2.1_expo@46.0.15/node_modules/expo-font/ios"
Expo:
:path: "../node_modules/.pnpm/expo@46.0.10_@babel+core@7.18.10/node_modules/expo"
:path: "../node_modules/.pnpm/expo@46.0.15_@babel+core@7.19.3/node_modules/expo"
ExpoKeepAwake:
:path: "../node_modules/.pnpm/expo-keep-awake@10.2.0_expo@46.0.10/node_modules/expo-keep-awake/ios"
:path: "../node_modules/.pnpm/expo-keep-awake@10.2.0_expo@46.0.15/node_modules/expo-keep-awake/ios"
ExpoModulesCore:
:path: "../node_modules/.pnpm/expo-modules-core@0.11.5/node_modules/expo-modules-core/ios"
:path: "../node_modules/.pnpm/expo-modules-core@0.11.7/node_modules/expo-modules-core/ios"
EXSplashScreen:
:path: "../node_modules/.pnpm/expo-splash-screen@0.16.2_expo@46.0.10/node_modules/expo-splash-screen/ios"
:path: "../node_modules/.pnpm/expo-splash-screen@0.16.2_expo@46.0.15/node_modules/expo-splash-screen/ios"
FBLazyVector:
:path: "../node_modules/react-native/Libraries/FBLazyVector"
FBReactNativeSpec:
@@ -541,10 +541,10 @@ SPEC CHECKSUMS:
EXApplication: e418d737a036e788510f2c4ad6c10a7d54d18586
EXConstants: 7c44785d41d8e959d527d23d29444277a4d1ee73
EXFileSystem: 927e0a8885aa9c49e50fc38eaba2c2389f2f1019
EXFont: a5d80bd9b3452b2d5abbce2487da89b0150e6487
Expo: fcdb32274e2ca9c7638d3b21b30fb665c6869219
EXFont: 06df627203afcb8a3b3152ec06eb2f11f46f0cff
Expo: 7e821e708a35d2720ef6baa658e224dd91c4821e
ExpoKeepAwake: 0e8f18142e71bbf2c7f6aa66ebed249ba1420320
ExpoModulesCore: 5a973701f4400d70254bc836305228731c829010
ExpoModulesCore: 2d60ec04c49641afb55fee3faac86fb108c68fe0
EXSplashScreen: 799bece80089219b2c989c1082d70f3b00995cda
FBLazyVector: c71b8c429a8af2aff1013934a7152e9d9d0c937d
FBReactNativeSpec: 3cc5cff7d792e74a875be91e56d6242335016f50
@@ -552,7 +552,7 @@ SPEC CHECKSUMS:
glog: 3d02b25ca00c2d456734d0bcff864cbc62f6ae1a
hermes-engine: 761a544537e62df2a37189389b9d2654dc1f75af
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
lottie-ios: 6bbc53eef6957e4744a50321507015fba72d8ca6
lottie-ios: 9ae750cdc7820fecbd3c2f0cfc493038208fcdc4
lottie-react-native: b702fab740cdb952a8e2354713d3beda63ff97b0
RCT-Folly: b9d9fe1fc70114b751c076104e52f3b1b5e5a95a
RCTRequired: bd9d2ab0fda10171fcbcf9ba61a7df4dc15a28f4
@@ -582,7 +582,7 @@ SPEC CHECKSUMS:
React-RCTVibration: 9adb4a3cbb598d1bbd46a05256f445e4b8c70603
React-runtimeexecutor: 61ee22a8cdf8b6bb2a7fb7b4ba2cc763e5285196
ReactCommon: 8f67bd7e0a6afade0f20718f859dc8c2275f2e83
RNCAsyncStorage: d81ee5c3db1060afd49ea7045ad460eff82d2b7d
RNCAsyncStorage: 0c357f3156fcb16c8589ede67cc036330b6698ca
RNCMaskedView: cb9670ea9239998340eaab21df13fa12a1f9de15
RNGestureHandler: bad495418bcbd3ab47017a38d93d290ebd406f50
RNReanimated: 7faa787e8d4493fbc95fab2ad331fa7625828cfa
@@ -366,6 +366,7 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"$(inherited)",
"\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"",
"\"${PODS_CONFIGURATION_BUILD_DIR}/DoubleConversion\"",
@@ -455,6 +456,7 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(SDKROOT)/usr/lib/swift",
"$(inherited)",
"\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"",
"\"${PODS_CONFIGURATION_BUILD_DIR}/DoubleConversion\"",
@@ -579,7 +581,7 @@
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
@@ -633,7 +635,7 @@
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "\"$(inherited)\"";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
@@ -1,122 +1,122 @@
{
"images": [
{
"idiom": "iphone",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "iphone",
"size": "20x20",
"scale": "3x",
"filename": "App-Icon-20x20@3x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "3x",
"filename": "App-Icon-29x29@3x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "3x",
"filename": "App-Icon-40x40@3x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "2x",
"filename": "App-Icon-60x60@2x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "3x",
"filename": "App-Icon-60x60@3x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "1x",
"filename": "App-Icon-20x20@1x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "1x",
"filename": "App-Icon-40x40@1x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "1x",
"filename": "App-Icon-76x76@1x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "2x",
"filename": "App-Icon-76x76@2x.png"
},
{
"idiom": "ipad",
"size": "83.5x83.5",
"scale": "2x",
"filename": "App-Icon-83.5x83.5@2x.png"
},
{
"idiom": "ios-marketing",
"size": "1024x1024",
"scale": "1x",
"filename": "ItunesArtwork@2x.png"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
"images": [
{
"idiom": "iphone",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "iphone",
"size": "20x20",
"scale": "3x",
"filename": "App-Icon-20x20@3x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "iphone",
"size": "29x29",
"scale": "3x",
"filename": "App-Icon-29x29@3x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "iphone",
"size": "40x40",
"scale": "3x",
"filename": "App-Icon-40x40@3x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "2x",
"filename": "App-Icon-60x60@2x.png"
},
{
"idiom": "iphone",
"size": "60x60",
"scale": "3x",
"filename": "App-Icon-60x60@3x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "1x",
"filename": "App-Icon-20x20@1x.png"
},
{
"idiom": "ipad",
"size": "20x20",
"scale": "2x",
"filename": "App-Icon-20x20@2x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "1x",
"filename": "App-Icon-29x29@1x.png"
},
{
"idiom": "ipad",
"size": "29x29",
"scale": "2x",
"filename": "App-Icon-29x29@2x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "1x",
"filename": "App-Icon-40x40@1x.png"
},
{
"idiom": "ipad",
"size": "40x40",
"scale": "2x",
"filename": "App-Icon-40x40@2x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "1x",
"filename": "App-Icon-76x76@1x.png"
},
{
"idiom": "ipad",
"size": "76x76",
"scale": "2x",
"filename": "App-Icon-76x76@2x.png"
},
{
"idiom": "ipad",
"size": "83.5x83.5",
"scale": "2x",
"filename": "App-Icon-83.5x83.5@2x.png"
},
{
"idiom": "ios-marketing",
"size": "1024x1024",
"scale": "1x",
"filename": "ItunesArtwork@2x.png"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
"info": {
"version": 1,
"author": "expo"
}
}
@@ -1,21 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
@@ -1,21 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}
+2 -2
View File
@@ -17,8 +17,8 @@
"@react-navigation/drawer": "^6.4.4",
"@react-navigation/native": "^6.0.12",
"@react-navigation/stack": "^6.2.3",
"@rspc/client": "^0.0.6",
"@rspc/react": "^0.0.6",
"@rspc/client": "^0.1.2",
"@rspc/react": "^0.1.2",
"@sd/assets": "file:../../packages/assets",
"@tanstack/react-query": "^4.2.3",
"byte-size": "^8.1.0",
+1102 -1228
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -8,18 +8,18 @@ rust-version = "1.64.0"
crate-type = ["staticlib", "cdylib"] # staticlib for IOS and cdylib for Android
[dependencies]
once_cell = "1.13.0"
once_cell = "1.15.0"
sd-core = { path = "../../../core", features = [
"mobile",
"p2p",
], default-features = false }
rspc = { version = "0.0.5", features = [] }
serde_json = "1.0.83"
tokio = "1.20.1"
openssl = { version = "0.10.41", features = [
rspc = { workspace = true }
serde_json = "1.0.85"
tokio = "1.21.2"
openssl = { version = "0.10.42", features = [
"vendored",
] } # Override features of transitive dependencies
openssl-sys = { version = "0.9.75", features = [
openssl-sys = { version = "0.9.76", features = [
"vendored",
] } # Override features of transitive dependencies to support IOS Simulator on M1
+124 -80
View File
@@ -1,43 +1,72 @@
use crate::{CLIENT_CONTEXT, EVENT_SENDER, NODE, RUNTIME};
use jni::objects::{JClass, JObject, JString};
use jni::JNIEnv;
use rspc::Request;
use std::panic;
use crate::{EVENT_SENDER, NODE, RUNTIME, SUBSCRIPTIONS};
use jni::objects::{GlobalRef, JClass, JObject, JString};
use jni::{JNIEnv, JavaVM};
use rspc::internal::jsonrpc::{handle_json_rpc, Request, Sender, SubscriptionMap};
use sd_core::Node;
use tokio::sync::mpsc::unbounded_channel;
// fn print(jvm: &JavaVM, class: &GlobalRef, msg: &str) {
// let env = jvm.attach_current_thread().unwrap();
// env.call_method(
// class,
// "print",
// "(Ljava/lang/String;)V",
// &[env
// .new_string(msg)
// .expect("Couldn't create java string!")
// .into()],
// )
// .unwrap()
// .l()
// .unwrap();
// }
#[no_mangle]
pub extern "system" fn Java_com_spacedrive_app_SDCore_registerCoreEventListener(
env: JNIEnv,
class: JClass,
) {
let jvm = env.get_java_vm().unwrap();
let class = env.new_global_ref(class).unwrap();
let (tx, mut rx) = unbounded_channel();
let _ = EVENT_SENDER.set(tx);
let result = panic::catch_unwind(|| {
let jvm = env.get_java_vm().unwrap();
let class = env.new_global_ref(class).unwrap();
let (tx, mut rx) = unbounded_channel();
let _ = EVENT_SENDER.set(tx);
RUNTIME.spawn(async move {
while let Some(event) = rx.recv().await {
let data = match serde_json::to_string(&event) {
Ok(json) => json,
Err(err) => {
println!("Failed to serialize event: {}", err);
continue;
}
};
RUNTIME.spawn(async move {
while let Some(event) = rx.recv().await {
let data = match serde_json::to_string(&event) {
Ok(json) => json,
Err(err) => {
println!("Failed to serialize event: {}", err);
continue;
}
};
let env = jvm.attach_current_thread().unwrap();
env.call_method(
&class,
"sendCoreEvent",
"(Ljava/lang/String;)V",
&[env
.new_string(data)
.expect("Couldn't create java string!")
.into()],
)
.unwrap();
}
let env = jvm.attach_current_thread().unwrap();
env.call_method(
&class,
"sendCoreEvent",
"(Ljava/lang/String;)V",
&[env
.new_string(data)
.expect("Couldn't create java string!")
.into()],
)
.unwrap();
}
});
});
if let Err(err) = result {
// TODO: Send rspc error or something here so we can show this in the UI.
// TODO: Maybe reinitialise the core cause it could be in an invalid state?
println!(
"Error in Java_com_spacedrive_app_SDCore_registerCoreEventListener: {:?}",
err
);
}
}
#[no_mangle]
@@ -47,60 +76,75 @@ pub extern "system" fn Java_com_spacedrive_app_SDCore_handleCoreMsg(
query: JString,
callback: JObject,
) {
let jvm = env.get_java_vm().unwrap();
let query: String = env
.get_string(query)
.expect("Couldn't get java string!")
.into();
let class = env.new_global_ref(class).unwrap();
let callback = env.new_global_ref(callback).unwrap();
let result = panic::catch_unwind(|| {
let jvm = env.get_java_vm().unwrap();
let query: String = env
.get_string(query)
.expect("Couldn't get java string!")
.into();
let class = env.new_global_ref(class).unwrap();
let callback = env.new_global_ref(callback).unwrap();
RUNTIME.spawn(async move {
let request: Request = serde_json::from_str(&query).unwrap();
RUNTIME.spawn(async move {
let request: Request = serde_json::from_str(&query).unwrap();
let node = &mut *NODE.lock().await;
let (node, router) = match node {
Some(node) => node.clone(),
None => {
let data_dir: String = {
let node = &mut *NODE.lock().await;
let (node, router) = match node {
Some(node) => node.clone(),
None => {
let data_dir: String = {
let env = jvm.attach_current_thread().unwrap();
let data_dir = env
.call_method(&class, "getDataDirectory", "()Ljava/lang/String;", &[])
.unwrap()
.l()
.unwrap();
env.get_string(data_dir.into()).unwrap().into()
};
let new_node = Node::new(data_dir).await.unwrap();
node.replace(new_node.clone());
new_node
}
};
let mut channel = EVENT_SENDER.get().unwrap().clone();
let mut resp = Sender::ResponseAndChannel(None, &mut channel);
handle_json_rpc(
node.get_request_context(),
request,
&router,
&mut resp,
&mut SubscriptionMap::Mutex(&SUBSCRIPTIONS),
)
.await;
match resp {
Sender::Response(Some(resp)) => {
let env = jvm.attach_current_thread().unwrap();
let data_dir = env
.call_method(&class, "getDataDirectory", "()Ljava/lang/String;", &[])
.unwrap()
.l()
.unwrap();
env.get_string(data_dir.into()).unwrap().into()
};
let new_node = Node::new(data_dir).await.expect("Unable to create node");
node.replace(new_node.clone());
new_node
env.call_method(
&callback,
"resolve",
"(Ljava/lang/Object;)V",
&[env
.new_string(serde_json::to_string(&resp).unwrap())
.expect("Couldn't create java string!")
.into()],
)
.unwrap();
}
_ => unreachable!(),
}
};
let resp = serde_json::to_string(
&request
.handle(
node.get_request_context(),
&router,
&CLIENT_CONTEXT,
EVENT_SENDER.get(),
)
.await,
)
.unwrap();
let env = jvm.attach_current_thread().unwrap();
env.call_method(
&callback,
"resolve",
"(Ljava/lang/Object;)V",
&[env
.new_string(resp)
.expect("Couldn't create java string!")
.into()],
)
.unwrap();
});
});
if let Err(err) = result {
// TODO: Send rspc error or something here so we can show this in the UI.
// TODO: Maybe reinitialise the core cause it could be in an invalid state?
println!(
"Error in Java_com_spacedrive_app_SDCore_registerCoreEventListener: {:?}",
err
);
}
}
+72 -55
View File
@@ -1,16 +1,16 @@
use crate::{CLIENT_CONTEXT, EVENT_SENDER, NODE, RUNTIME};
use crate::{EVENT_SENDER, NODE, RUNTIME, SUBSCRIPTIONS};
use objc::{msg_send, runtime::Object, sel, sel_impl};
use objc_foundation::{INSString, NSString};
use objc_id::Id;
use rspc::internal::jsonrpc::{handle_json_rpc, Request, Sender, SubscriptionMap};
use sd_core::Node;
use std::{
ffi::{CStr, CString},
os::raw::{c_char, c_void},
panic,
};
use tokio::sync::mpsc::unbounded_channel;
use objc::{class, msg_send, runtime::Object, sel, sel_impl};
use objc_foundation::{INSString, NSString};
use objc_id::Id;
use rspc::Request;
use sd_core::Node;
extern "C" {
fn get_data_directory() -> *const c_char;
fn call_resolve(resolve: *const c_void, result: *const c_char);
@@ -33,64 +33,81 @@ impl RNPromise {
#[no_mangle]
pub unsafe extern "C" fn register_core_event_listener(id: *mut Object) {
let id = Id::<Object>::from_ptr(id);
let result = panic::catch_unwind(|| {
let id = Id::<Object>::from_ptr(id);
let (tx, mut rx) = unbounded_channel();
let _ = EVENT_SENDER.set(tx);
let (tx, mut rx) = unbounded_channel();
let _ = EVENT_SENDER.set(tx);
RUNTIME.spawn(async move {
while let Some(event) = rx.recv().await {
let data = match serde_json::to_string(&event) {
Ok(json) => json,
Err(err) => {
println!("Failed to serialize event: {}", err);
continue;
}
};
let data = NSString::from_str(&data);
let _: () = msg_send![id, sendCoreEvent: data];
}
RUNTIME.spawn(async move {
while let Some(event) = rx.recv().await {
let data = match serde_json::to_string(&event) {
Ok(json) => json,
Err(err) => {
println!("Failed to serialize event: {}", err);
continue;
}
};
let data = NSString::from_str(&data);
let _: () = msg_send![id, sendCoreEvent: data];
}
});
});
if let Err(err) = result {
// TODO: Send rspc error or something here so we can show this in the UI.
// TODO: Maybe reinitialise the core cause it could be in an invalid state?
println!("Error in register_core_event_listener: {:?}", err);
}
}
#[no_mangle]
pub unsafe extern "C" fn sd_core_msg(query: *const c_char, resolve: *const c_void) {
// This string is cloned to the Rust heap. This is important as Objective-C may remove the query once this function completions but prior to the async block finishing.
let query = CStr::from_ptr(query).to_str().unwrap().to_string();
let result = panic::catch_unwind(|| {
// This string is cloned to the Rust heap. This is important as Objective-C may remove the query once this function completions but prior to the async block finishing.
let query = CStr::from_ptr(query).to_str().unwrap().to_string();
let resolve = RNPromise(resolve);
RUNTIME.spawn(async move {
let request: Request = serde_json::from_str(&query).unwrap();
let resolve = RNPromise(resolve);
RUNTIME.spawn(async move {
let request: Request = serde_json::from_str(&query).unwrap();
let node = &mut *NODE.lock().await;
let (node, router) = match node {
Some(node) => node.clone(),
None => {
let doc_dir = CStr::from_ptr(get_data_directory())
.to_str()
.unwrap()
.to_string();
let new_node = Node::new(doc_dir).await.unwrap();
node.replace(new_node.clone());
new_node
}
};
let node = &mut *NODE.lock().await;
let (node, router) = match node {
Some(node) => node.clone(),
None => {
let doc_dir = CStr::from_ptr(get_data_directory())
.to_str()
.unwrap()
.to_string();
let new_node = Node::new(doc_dir).await.unwrap();
node.replace(new_node.clone());
new_node
}
};
resolve.resolve(
CString::new(
serde_json::to_vec(
&request
.handle(
node.get_request_context(),
&router,
&CLIENT_CONTEXT,
EVENT_SENDER.get(),
)
.await,
)
.unwrap(),
let mut channel = EVENT_SENDER.get().unwrap().clone();
let mut resp = Sender::ResponseAndChannel(None, &mut channel);
handle_json_rpc(
node.get_request_context(),
request,
&router,
&mut resp,
&mut SubscriptionMap::Mutex(&SUBSCRIPTIONS),
)
.unwrap(),
)
.await;
match resp {
Sender::ResponseAndChannel(Some(resp), _) => {
resolve.resolve(CString::new(serde_json::to_vec(&resp).unwrap()).unwrap());
}
_ => unreachable!(),
}
});
});
if let Err(err) = result {
// TODO: Send rspc error or something here so we can show this in the UI.
// TODO: Maybe reinitialise the core cause it could be in an invalid state?
println!("Error in sd_core_msg: {:?}", err);
}
}
+9 -9
View File
@@ -1,24 +1,24 @@
use std::sync::Arc;
use std::{collections::HashMap, sync::Arc};
use once_cell::sync::{Lazy, OnceCell};
use rspc::{ClientContext, Response};
use rspc::internal::jsonrpc::{RequestId, Response};
use sd_core::{api::Router, Node};
use tokio::{
runtime::Runtime,
sync::{mpsc::UnboundedSender, Mutex},
sync::{mpsc::UnboundedSender, oneshot, Mutex},
};
#[allow(dead_code)]
pub(crate) static RUNTIME: Lazy<Runtime> = Lazy::new(|| Runtime::new().unwrap());
type LazyNode = Lazy<Mutex<Option<(Arc<Node>, Arc<Router>)>>>;
#[allow(dead_code)]
pub(crate) static NODE: LazyNode = Lazy::new(|| Mutex::new(None));
type NodeType = Lazy<Mutex<Option<(Arc<Node>, Arc<Router>)>>>;
#[allow(dead_code)]
pub(crate) static CLIENT_CONTEXT: Lazy<ClientContext> = Lazy::new(|| ClientContext {
subscriptions: Default::default(),
});
pub(crate) static NODE: NodeType = Lazy::new(|| Mutex::new(None));
#[allow(dead_code)]
pub(crate) static SUBSCRIPTIONS: Lazy<Mutex<HashMap<RequestId, oneshot::Sender<()>>>> =
Lazy::new(Default::default);
#[allow(dead_code)]
pub(crate) static EVENT_SENDER: OnceCell<UnboundedSender<Response>> = OnceCell::new();
+2 -2
View File
@@ -22,9 +22,9 @@ import RootNavigator from './navigation';
import OnboardingNavigator from './navigation/OnboardingNavigator';
import { libraryStore } from './stores/libraryStore';
import { onboardingStore } from './stores/onboardingStore';
import type { Operations } from './types/bindings';
import type { Procedures } from './types/bindings';
const client = createClient<Operations>({
const client = createClient<Procedures>({
transport: new ReactNativeTransport()
});
@@ -1,5 +1,5 @@
import { BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { format } from 'date-fns';
import dayjs from 'dayjs';
import React, { useRef } from 'react';
import { Button, Pressable, Text, View } from 'react-native';
import { ChevronLeftIcon } from 'react-native-heroicons/outline';
@@ -98,12 +98,12 @@ export const FileModal = () => {
<Divider style={tw`my-4`} />
<MetaItem
title="Date Created"
value={format(new Date(data.date_created), 'MMMM Do yyyy, h:mm:ss aaa')}
value={dayjs(data.date_created).format('MMMM Do yyyy, h:mm:ss aaa')}
/>
<Divider style={tw`my-4`} />
<MetaItem
title="Date Indexed"
value={format(new Date(data.date_indexed), 'MMMM Do yyyy, h:mm:ss aaa')}
value={dayjs(data.date_indexed).format('MMMM Do yyyy, h:mm:ss aaa')}
/>
</>
</BottomSheetScrollView>
+66 -101
View File
@@ -1,144 +1,109 @@
import { ClientTransformer, OperationKey, OperationType, RSPCError, Transport } from '@rspc/client';
import { OperationType, ProcedureDef, RSPCError, Transport } from '@rspc/client';
import { createReactQueryHooks } from '@rspc/react';
import {
QueryClient,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
useMutation as _useMutation
} from '@tanstack/react-query';
import { QueryClient } from '@tanstack/react-query';
import { NativeEventEmitter, NativeModules } from 'react-native';
import { useSnapshot } from 'valtio';
import { libraryStore } from '../stores/libraryStore';
import type { LibraryArgs, Operations } from '../types/bindings';
import { getLibraryIdRaw } from '../stores/libraryStore';
import { LibraryArgs, Procedures } from '../types/bindings';
export const queryClient = new QueryClient();
export const rspc = createReactQueryHooks<Operations>();
export const rspc = createReactQueryHooks<Procedures>();
const { SDCore } = NativeModules;
const eventEmitter = new NativeEventEmitter(NativeModules.SDCore);
// TODO(@Oscar): Replace this with a better abstraction when it's released in rspc. This relies on internal details of rspc which will change without warning.
export class ReactNativeTransport implements Transport {
transformer?: ClientTransformer;
clientSubscriptionCallback?: (id: string, key: string, value: any) => void;
clientSubscriptionCallback?: (id: string, value: any) => void;
constructor() {
const subscriptionEventListener = eventEmitter.addListener('SDCoreEvent', (event) => {
const body = JSON.parse(event);
if (body.type === 'event') {
const { id, key, result } = body;
this.clientSubscriptionCallback(id, key, result);
} else if (body.type === 'response' || body.type === 'error') {
const { id, result } = JSON.parse(event);
if (result.type === 'event') {
if (this.clientSubscriptionCallback) this.clientSubscriptionCallback(id, result.data);
} else if (result.type === 'response' || result.type === 'error') {
throw new Error(
`Recieved event of type '${body.type}'. This should be impossible with the React Native transport!`
`Recieved event of type '${result.type}'. This should be impossible with the React Native transport!`
);
} else {
console.error(`Received event of unknown method '${body.type}'`);
console.error(`Received event of unknown method '${result.type}'`);
}
});
}
async doRequest(operation: OperationType, key: OperationKey): Promise<any> {
const body = JSON.parse(
async doRequest(operation: OperationType, key: string, input: any): Promise<any> {
const resp = JSON.parse(
await SDCore.sd_core_msg(
JSON.stringify({
operation,
key: this.transformer?.serialize(operation, key) || key
id: null,
method: operation,
params: {
path: key,
input
}
})
)
);
const body = resp.result;
if (body.type === 'error') {
const { status_code, message } = body;
throw new RSPCError(status_code, message);
const { code, message } = body;
throw new RSPCError(code, message);
} else if (body.type === 'response') {
return this.transformer?.deserialize(operation, key, body.result) || body.result;
return body.data;
} else if (body.type !== 'none') {
throw new Error(`RSPC ReactNative doRequest received invalid body type '${body?.type}'`);
}
}
}
type NonLibraryQueries = Exclude<Operations['queries'], { key: [any, LibraryArgs<any>] }> &
Extract<Operations['queries'], { key: [any] }>;
type NonLibraryQuery<K extends string> = Extract<NonLibraryQueries, { key: [K] | [K, any] }>;
type NonLibraryQueryKey = NonLibraryQueries['key'][0];
type NonLibraryQueryResult<K extends NonLibraryQueryKey> = NonLibraryQuery<K>['result'];
type NonLibraryProcedure<T extends keyof Procedures> =
| Exclude<Procedures[T], { input: LibraryArgs<any> }>
| Extract<Procedures[T], { input: never }>;
export function useBridgeQuery<K extends NonLibraryQueries['key']>(
key: K,
options?: UseQueryOptions<NonLibraryQueryResult<K[0]>, RSPCError>
): UseQueryResult<NonLibraryQueryResult<K[0]>, RSPCError> {
// @ts-ignore
return rspc.useQuery(key, options);
}
type LibraryQueries = Extract<Operations['queries'], { key: [string, LibraryArgs<any>] }>;
type LibraryQuery<K extends string> = Extract<LibraryQueries, { key: [K, any] }>;
type LibraryQueryKey = LibraryQueries['key'][0];
type LibraryQueryArgs<K extends string> = LibraryQuery<K>['key'][1] extends LibraryArgs<infer A>
? A
: never;
type LibraryQueryResult<K extends string> = LibraryQuery<K>['result'];
export function useLibraryQuery<K extends LibraryQueryKey>(
key: LibraryQueryArgs<K> extends null | undefined ? [K] : [K, LibraryQueryArgs<K>],
options?: UseQueryOptions<LibraryQueryResult<K>, RSPCError>
): UseQueryResult<LibraryQueryResult<K>, RSPCError> {
const store = useSnapshot(libraryStore);
const library_id = store.currentLibraryUuid;
if (!library_id) throw new Error(`Attempted to do library query with no library set!`);
// @ts-ignore
return rspc.useQuery([key[0], { library_id: library_id || '', arg: key[1] || null }], options);
}
type LibraryMutations = Extract<Operations['mutations'], { key: [string, LibraryArgs<any>] }>;
type LibraryMutation<K extends LibraryMutationKey> = Extract<LibraryMutations, { key: [K, any] }>;
type LibraryMutationKey = LibraryMutations['key'][0];
type LibraryMutationArgs<K extends LibraryMutationKey> =
LibraryMutation<K>['key'][1] extends LibraryArgs<infer A> ? A : never;
type LibraryMutationResult<K extends LibraryMutationKey> = LibraryMutation<K>['result'];
export function useLibraryMutation<K extends LibraryMutationKey>(
key: K,
options?: UseMutationOptions<LibraryMutationResult<K>, RSPCError>
) {
const ctx = rspc.useContext();
const store = useSnapshot(libraryStore);
const library_id = store.currentLibraryUuid;
if (!library_id) throw new Error(`Attempted to do library query with no library set!`);
// @ts-ignore
return _useMutation<LibraryMutationResult<K>, RSPCError, LibraryMutationArgs<K>>(
async (data) => ctx.client.mutation([key, { library_id: library_id || '', arg: data || null }]),
{
...options,
context: rspc.ReactQueryContext
}
);
}
type NonLibraryMutations = Exclude<Operations['mutations'], { key: [any, LibraryArgs<any>] }>;
type NonLibraryMutation<K extends NonLibraryMutationKey> = Extract<
NonLibraryMutations,
{ key: [K] | [K, any] }
type LibraryProcedures<T extends keyof Procedures> = Exclude<
Extract<Procedures[T], { input: LibraryArgs<any> }>,
{ input: never }
>;
type NonLibraryMutationKey = NonLibraryMutations['key'][0];
type NonLibraryMutationArgs<K extends NonLibraryMutationKey> = NonLibraryMutation<K>['key'][1];
type NonLibraryMutationResult<K extends NonLibraryMutationKey> = NonLibraryMutation<K>['result'];
export function useBridgeMutation<K extends NonLibraryMutationKey>(
key: K,
options?: UseMutationOptions<NonLibraryMutationResult<K>, RSPCError>
): UseMutationResult<NonLibraryMutationResult<K>, RSPCError, NonLibraryMutationArgs<K>> {
// @ts-ignore
return rspc.useMutation(key, options);
}
type MoreConstrainedQueries<T extends ProcedureDef> = T extends any
? T['input'] extends LibraryArgs<infer E>
? {
key: T['key'];
input: E;
result: T['result'];
}
: never
: never;
export const useBridgeQuery = rspc.customQuery<NonLibraryProcedure<'queries'>>(
(keyAndInput) => keyAndInput as any
);
export const useBridgeMutation = rspc.customMutation<NonLibraryProcedure<'mutations'>>(
(keyAndInput) => keyAndInput
);
export const useLibraryQuery = rspc.customQuery<
MoreConstrainedQueries<LibraryProcedures<'queries'>>
>((keyAndInput) => {
const library_id = getLibraryIdRaw();
if (library_id === null) throw new Error('Attempted to do library query with no library set!');
return [keyAndInput[0], { library_id, arg: keyAndInput[1] || null }];
});
export const useLibraryMutation = rspc.customMutation<
MoreConstrainedQueries<LibraryProcedures<'mutations'>>
>((keyAndInput) => {
const library_id = getLibraryIdRaw();
if (library_id === null) throw new Error('Attempted to do library query with no library set!');
return [keyAndInput[0], { library_id, arg: keyAndInput[1] || null }];
});
export function useInvalidateQuery() {
const context = rspc.useContext();
rspc.useSubscription(['invalidateQuery'], {
onNext: (invalidateOperation) => {
onData: (invalidateOperation) => {
const key = [invalidateOperation.key];
if (invalidateOperation.arg !== null) {
key.concat(invalidateOperation.arg);
+4
View File
@@ -27,6 +27,10 @@ export const libraryStore = proxyWithPersist({
getStorage: () => StorageEngine
});
export function getLibraryIdRaw(): string | null {
return libraryStore.currentLibraryUuid;
}
// this must be used at least once in the app to correct the initial state
// is memorized and can be used safely in any component
export const useCurrentLibrary = () => {
+322 -75
View File
@@ -1,112 +1,359 @@
/* eslint-disable */
// This file was generated by [rspc](https://github.com/oscartbeaumont/rspc). Do not edit this file manually.
export type Operations = {
queries:
{ key: ["files.readMetadata", LibraryArgs<number>], result: null } |
{ key: ["getNode"], result: NodeState } |
{ key: ["jobs.getHistory", LibraryArgs<null>], result: Array<JobReport> } |
{ key: ["jobs.getRunning", LibraryArgs<null>], result: Array<JobReport> } |
{ key: ["library.getStatistics", LibraryArgs<null>], result: Statistics } |
{ key: ["library.list"], result: Array<LibraryConfigWrapped> } |
{ key: ["locations.getById", LibraryArgs<number>], result: Location | null } |
{ key: ["locations.getExplorerData", LibraryArgs<LocationExplorerArgs>], result: ExplorerData } |
{ key: ["locations.indexer_rules.get", LibraryArgs<number>], result: IndexerRule } |
{ key: ["locations.indexer_rules.list", LibraryArgs<null>], result: Array<IndexerRule> } |
{ key: ["locations.list", LibraryArgs<null>], result: Array<{ id: number, pub_id: Array<number>, node_id: number, name: string | null, local_path: string | null, total_capacity: number | null, available_capacity: number | null, filesystem: string | null, disk_type: number | null, is_removable: boolean | null, is_online: boolean, is_archived: boolean, date_created: string, node: Node }> } |
{ key: ["tags.get", LibraryArgs<number>], result: Tag | null } |
{ key: ["tags.getExplorerData", LibraryArgs<number>], result: ExplorerData } |
{ key: ["tags.getForFile", LibraryArgs<number>], result: Array<Tag> } |
{ key: ["tags.list", LibraryArgs<null>], result: Array<Tag> } |
{ key: ["version"], result: string } |
{ key: ["volumes.list"], result: Array<Volume> },
mutations:
{ key: ["files.delete", LibraryArgs<number>], result: null } |
{ key: ["files.setFavorite", LibraryArgs<SetFavoriteArgs>], result: null } |
{ key: ["files.setNote", LibraryArgs<SetNoteArgs>], result: null } |
{ key: ["jobs.generateThumbsForLocation", LibraryArgs<GenerateThumbsForLocationArgs>], result: null } |
{ key: ["jobs.identifyUniqueFiles", LibraryArgs<IdentifyUniqueFilesArgs>], result: null } |
{ key: ["library.create", string], result: LibraryConfigWrapped } |
{ key: ["library.delete", string], result: null } |
{ key: ["library.edit", EditLibraryArgs], result: null } |
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |
{ key: ["locations.delete", LibraryArgs<number>], result: null } |
{ key: ["locations.fullRescan", LibraryArgs<number>], result: null } |
{ key: ["locations.indexer_rules.create", LibraryArgs<IndexerRuleCreateArgs>], result: IndexerRule } |
{ key: ["locations.indexer_rules.delete", LibraryArgs<number>], result: null } |
{ key: ["locations.quickRescan", LibraryArgs<null>], result: null } |
{ key: ["locations.update", LibraryArgs<LocationUpdateArgs>], result: null } |
{ key: ["tags.assign", LibraryArgs<TagAssignArgs>], result: null } |
{ key: ["tags.create", LibraryArgs<TagCreateArgs>], result: Tag } |
{ key: ["tags.delete", LibraryArgs<number>], result: null } |
{ key: ["tags.update", LibraryArgs<TagUpdateArgs>], result: null },
subscriptions:
{ key: ["invalidateQuery"], result: InvalidateOperationEvent } |
{ key: ["jobs.newThumbnail", LibraryArgs<null>], result: string }
export type Procedures = {
queries:
| { key: 'files.readMetadata'; input: LibraryArgs<number>; result: null }
| { key: 'getNode'; input: never; result: NodeState }
| { key: 'jobs.getHistory'; input: LibraryArgs<null>; result: Array<JobReport> }
| { key: 'jobs.getRunning'; input: LibraryArgs<null>; result: Array<JobReport> }
| { key: 'library.getStatistics'; input: LibraryArgs<null>; result: Statistics }
| { key: 'library.list'; input: never; result: Array<LibraryConfigWrapped> }
| { key: 'locations.getById'; input: LibraryArgs<number>; result: Location | null }
| {
key: 'locations.getExplorerData';
input: LibraryArgs<LocationExplorerArgs>;
result: ExplorerData;
}
| { key: 'locations.indexer_rules.get'; input: LibraryArgs<number>; result: IndexerRule }
| { key: 'locations.indexer_rules.list'; input: LibraryArgs<null>; result: Array<IndexerRule> }
| {
key: 'locations.list';
input: LibraryArgs<null>;
result: Array<{
id: number;
pub_id: Array<number>;
node_id: number;
name: string | null;
local_path: string | null;
total_capacity: number | null;
available_capacity: number | null;
filesystem: string | null;
disk_type: number | null;
is_removable: boolean | null;
is_online: boolean;
is_archived: boolean;
date_created: string;
node: Node;
}>;
}
| { key: 'tags.get'; input: LibraryArgs<number>; result: Tag | null }
| { key: 'tags.getExplorerData'; input: LibraryArgs<number>; result: ExplorerData }
| { key: 'tags.getForObject'; input: LibraryArgs<number>; result: Array<Tag> }
| { key: 'tags.list'; input: LibraryArgs<null>; result: Array<Tag> }
| { key: 'version'; input: never; result: string }
| { key: 'volumes.list'; input: never; result: Array<Volume> };
mutations:
| { key: 'files.delete'; input: LibraryArgs<number>; result: null }
| { key: 'files.setFavorite'; input: LibraryArgs<SetFavoriteArgs>; result: null }
| { key: 'files.setNote'; input: LibraryArgs<SetNoteArgs>; result: null }
| {
key: 'jobs.generateThumbsForLocation';
input: LibraryArgs<GenerateThumbsForLocationArgs>;
result: null;
}
| { key: 'jobs.identifyUniqueFiles'; input: LibraryArgs<IdentifyUniqueFilesArgs>; result: null }
| { key: 'library.create'; input: string; result: LibraryConfigWrapped }
| { key: 'library.delete'; input: string; result: null }
| { key: 'library.edit'; input: EditLibraryArgs; result: null }
| { key: 'locations.create'; input: LibraryArgs<LocationCreateArgs>; result: null }
| { key: 'locations.delete'; input: LibraryArgs<number>; result: null }
| { key: 'locations.fullRescan'; input: LibraryArgs<number>; result: null }
| {
key: 'locations.indexer_rules.create';
input: LibraryArgs<IndexerRuleCreateArgs>;
result: IndexerRule;
}
| { key: 'locations.indexer_rules.delete'; input: LibraryArgs<number>; result: null }
| { key: 'locations.quickRescan'; input: LibraryArgs<null>; result: null }
| { key: 'locations.update'; input: LibraryArgs<LocationUpdateArgs>; result: null }
| { key: 'tags.assign'; input: LibraryArgs<TagAssignArgs>; result: null }
| { key: 'tags.create'; input: LibraryArgs<TagCreateArgs>; result: Tag }
| { key: 'tags.delete'; input: LibraryArgs<number>; result: null }
| { key: 'tags.update'; input: LibraryArgs<TagUpdateArgs>; result: null };
subscriptions:
| { key: 'invalidateQuery'; input: never; result: InvalidateOperationEvent }
| { key: 'jobs.newThumbnail'; input: LibraryArgs<null>; result: string };
};
export interface ConfigMetadata { version: string | null }
export interface ConfigMetadata {
version: string | null;
}
export interface EditLibraryArgs { id: string, name: string | null, description: string | null }
export interface EditLibraryArgs {
id: string;
name: string | null;
description: string | null;
}
export type ExplorerContext = { type: "Location" } & Location | { type: "Tag" } & Tag
export type ExplorerContext = ({ type: 'Location' } & Location) | ({ type: 'Tag' } & Tag);
export interface ExplorerData { context: ExplorerContext, items: Array<ExplorerItem> }
export interface ExplorerData {
context: ExplorerContext;
items: Array<ExplorerItem>;
}
export type ExplorerItem = { type: "Path" } & { id: number, is_dir: boolean, location_id: number, materialized_path: string, name: string, extension: string | null, object_id: number | null, parent_id: number | null, key_id: number | null, date_created: string, date_modified: string, date_indexed: string, object: Object | null } | { type: "Object" } & { id: number, cas_id: string, integrity_checksum: string | null, name: string | null, extension: string | null, kind: number, size_in_bytes: string, key_id: number | null, hidden: boolean, favorite: boolean, important: boolean, has_thumbnail: boolean, has_thumbstrip: boolean, has_video_preview: boolean, ipfs_id: string | null, note: string | null, date_created: string, date_modified: string, date_indexed: string, file_paths: Array<FilePath> }
export type ExplorerItem =
| ({ type: 'Path' } & {
id: number;
is_dir: boolean;
location_id: number;
materialized_path: string;
name: string;
extension: string | null;
object_id: number | null;
parent_id: number | null;
key_id: number | null;
date_created: string;
date_modified: string;
date_indexed: string;
object: Object | null;
})
| ({ type: 'Object' } & {
id: number;
cas_id: string;
integrity_checksum: string | null;
name: string | null;
extension: string | null;
kind: number;
size_in_bytes: string;
key_id: number | null;
hidden: boolean;
favorite: boolean;
important: boolean;
has_thumbnail: boolean;
has_thumbstrip: boolean;
has_video_preview: boolean;
ipfs_id: string | null;
note: string | null;
date_created: string;
date_modified: string;
date_indexed: string;
file_paths: Array<FilePath>;
});
export interface FilePath { id: number, is_dir: boolean, location_id: number, materialized_path: string, name: string, extension: string | null, object_id: number | null, parent_id: number | null, key_id: number | null, date_created: string, date_modified: string, date_indexed: string }
export interface FilePath {
id: number;
is_dir: boolean;
location_id: number;
materialized_path: string;
name: string;
extension: string | null;
object_id: number | null;
parent_id: number | null;
key_id: number | null;
date_created: string;
date_modified: string;
date_indexed: string;
}
export interface GenerateThumbsForLocationArgs { id: number, path: string }
export interface GenerateThumbsForLocationArgs {
id: number;
path: string;
}
export interface IdentifyUniqueFilesArgs { id: number, path: string }
export interface IdentifyUniqueFilesArgs {
id: number;
path: string;
}
export interface IndexerRule { id: number, kind: number, name: string, parameters: Array<number>, date_created: string, date_modified: string }
export interface IndexerRule {
id: number;
kind: number;
name: string;
parameters: Array<number>;
date_created: string;
date_modified: string;
}
export interface IndexerRuleCreateArgs { kind: RuleKind, name: string, parameters: Array<number> }
export interface IndexerRuleCreateArgs {
kind: RuleKind;
name: string;
parameters: Array<number>;
}
export interface InvalidateOperationEvent { key: string, arg: any }
export interface InvalidateOperationEvent {
key: string;
arg: any;
}
export interface JobReport { id: string, name: string, data: Array<number> | null, metadata: any | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
export interface JobReport {
id: string;
name: string;
data: Array<number> | null;
metadata: any | null;
date_created: string;
date_modified: string;
status: JobStatus;
task_count: number;
completed_task_count: number;
message: string;
seconds_elapsed: number;
}
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused"
export type JobStatus = 'Queued' | 'Running' | 'Completed' | 'Canceled' | 'Failed' | 'Paused';
export interface LibraryArgs<T> { library_id: string, arg: T }
export interface LibraryArgs<T> {
library_id: string;
arg: T;
}
export interface LibraryConfig { version: string | null, name: string, description: string }
export interface LibraryConfig {
version: string | null;
name: string;
description: string;
}
export interface LibraryConfigWrapped { uuid: string, config: LibraryConfig }
export interface LibraryConfigWrapped {
uuid: string;
config: LibraryConfig;
}
export interface Location { id: number, pub_id: Array<number>, node_id: number, name: string | null, local_path: string | null, total_capacity: number | null, available_capacity: number | null, filesystem: string | null, disk_type: number | null, is_removable: boolean | null, is_online: boolean, is_archived: boolean, date_created: string }
export interface Location {
id: number;
pub_id: Array<number>;
node_id: number;
name: string | null;
local_path: string | null;
total_capacity: number | null;
available_capacity: number | null;
filesystem: string | null;
disk_type: number | null;
is_removable: boolean | null;
is_online: boolean;
is_archived: boolean;
date_created: string;
}
export interface LocationCreateArgs { path: string, indexer_rules_ids: Array<number> }
export interface LocationCreateArgs {
path: string;
indexer_rules_ids: Array<number>;
}
export interface LocationExplorerArgs { location_id: number, path: string, limit: number, cursor: string | null }
export interface LocationExplorerArgs {
location_id: number;
path: string;
limit: number;
cursor: string | null;
}
export interface LocationUpdateArgs { id: number, name: string | null, indexer_rules_ids: Array<number> }
export interface LocationUpdateArgs {
id: number;
name: string | null;
indexer_rules_ids: Array<number>;
}
export interface Node { id: number, pub_id: Array<number>, name: string, platform: number, version: string | null, last_seen: string, timezone: string | null, date_created: string }
export interface Node {
id: number;
pub_id: Array<number>;
name: string;
platform: number;
version: string | null;
last_seen: string;
timezone: string | null;
date_created: string;
}
export interface NodeConfig { version: string | null, id: string, name: string, p2p_port: number | null }
export interface NodeConfig {
version: string | null;
id: string;
name: string;
p2p_port: number | null;
}
export interface NodeState { version: string | null, id: string, name: string, p2p_port: number | null, data_path: string }
export interface NodeState {
version: string | null;
id: string;
name: string;
p2p_port: number | null;
data_path: string;
}
export interface Object { id: number, cas_id: string, integrity_checksum: string | null, name: string | null, extension: string | null, kind: number, size_in_bytes: string, key_id: number | null, hidden: boolean, favorite: boolean, important: boolean, has_thumbnail: boolean, has_thumbstrip: boolean, has_video_preview: boolean, ipfs_id: string | null, note: string | null, date_created: string, date_modified: string, date_indexed: string }
export interface Object {
id: number;
cas_id: string;
integrity_checksum: string | null;
name: string | null;
extension: string | null;
kind: number;
size_in_bytes: string;
key_id: number | null;
hidden: boolean;
favorite: boolean;
important: boolean;
has_thumbnail: boolean;
has_thumbstrip: boolean;
has_video_preview: boolean;
ipfs_id: string | null;
note: string | null;
date_created: string;
date_modified: string;
date_indexed: string;
}
export type RuleKind = "AcceptFilesByGlob" | "RejectFilesByGlob" | "AcceptIfChildrenDirectoriesArePresent" | "RejectIfChildrenDirectoriesArePresent"
export type RuleKind =
| 'AcceptFilesByGlob'
| 'RejectFilesByGlob'
| 'AcceptIfChildrenDirectoriesArePresent'
| 'RejectIfChildrenDirectoriesArePresent';
export interface SetFavoriteArgs { id: number, favorite: boolean }
export interface SetFavoriteArgs {
id: number;
favorite: boolean;
}
export interface SetNoteArgs { id: number, note: string | null }
export interface SetNoteArgs {
id: number;
note: string | null;
}
export interface Statistics { id: number, date_captured: string, total_object_count: number, library_db_size: string, total_bytes_used: string, total_bytes_capacity: string, total_unique_bytes: string, total_bytes_free: string, preview_media_bytes: string }
export interface Statistics {
id: number;
date_captured: string;
total_object_count: number;
library_db_size: string;
total_bytes_used: string;
total_bytes_capacity: string;
total_unique_bytes: string;
total_bytes_free: string;
preview_media_bytes: string;
}
export interface Tag { id: number, pub_id: Array<number>, name: string | null, color: string | null, total_objects: number | null, redundancy_goal: number | null, date_created: string, date_modified: string }
export interface Tag {
id: number;
pub_id: Array<number>;
name: string | null;
color: string | null;
total_objects: number | null;
redundancy_goal: number | null;
date_created: string;
date_modified: string;
}
export interface TagAssignArgs { object_id: number, tag_id: number, unassign: boolean }
export interface TagAssignArgs {
object_id: number;
tag_id: number;
unassign: boolean;
}
export interface TagCreateArgs { name: string, color: string }
export interface TagCreateArgs {
name: string;
color: string;
}
export interface TagUpdateArgs { id: number, name: string | null, color: string | null }
export interface TagUpdateArgs {
id: number;
name: string | null;
color: string | null;
}
export interface Volume { name: string, mount_point: string, total_capacity: bigint, available_capacity: bigint, is_removable: boolean, disk_type: string | null, file_system: string | null, is_root_filesystem: boolean }
export interface Volume {
name: string;
mount_point: string;
total_capacity: bigint;
available_capacity: bigint;
is_removable: boolean;
disk_type: string | null;
file_system: string | null;
is_root_filesystem: boolean;
}
+5 -5
View File
@@ -5,8 +5,8 @@ edition = "2021"
[dependencies]
sd-core = { path = "../../core", features = [] }
rspc = { version = "0.0.5", features = ["axum"] }
axum = "0.5.13"
tokio = { version = "1.17.0", features = ["sync", "rt-multi-thread", "signal"] }
tracing = "0.1.35"
ctrlc = "3.2.2"
rspc = { workspace = true, features = ["axum"] }
axum = "0.5.16"
tokio = { version = "1.21.2", features = ["sync", "rt-multi-thread", "signal"] }
tracing = "0.1.36"
ctrlc = "3.2.3"
+2 -2
View File
@@ -55,8 +55,8 @@ async fn main() {
})
})
.route(
"/rspcws",
router.axum_ws_handler(move || node.get_request_context()),
"/rspc/:id",
router.endpoint(move || node.get_request_context()).axum(),
)
.fallback((|| async { "404 Not Found: We're past the event horizon..." }).into_service());
+12 -10
View File
@@ -8,25 +8,27 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^4.5.11",
"@rspc/client": "^0.0.6",
"@fontsource/inter": "^4.5.13",
"@rspc/client": "^0.1.2",
"@sd/client": "workspace:*",
"@sd/interface": "workspace:*",
"@sd/ui": "workspace:*",
"@tanstack/react-query": "^4.0.10",
"@tanstack/react-query": "^4.10.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.15",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.0",
"autoprefixer": "^10.4.7",
"postcss": "^8.4.14",
"@vitejs/plugin-react": "^2.1.0",
"autoprefixer": "^10.4.12",
"postcss": "^8.4.17",
"rollup-plugin-visualizer": "^5.8.2",
"tailwind": "^4.0.0",
"typescript": "^4.7.4",
"vite": "^3.0.3",
"typescript": "^4.8.4",
"vite": "^3.1.4",
"vite-plugin-html": "^3.2.0",
"vite-plugin-svgr": "^2.2.1",
"vite-plugin-tsconfig-paths": "^1.1.0"
"vite-plugin-tsconfig-paths": "^1.2.0"
}
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { WebsocketTransport, createClient } from '@rspc/client';
import { Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
import { PlatformProvider, Procedures, queryClient, rspc } from '@sd/client';
import SpacedriveInterface, { Platform } from '@sd/interface';
import { useEffect } from 'react';
const client = createClient<Operations>({
const client = createClient<Procedures>({
transport: new WebsocketTransport(
import.meta.env.VITE_SDSERVER_BASE_URL || 'ws://localhost:8080/rspcws'
import.meta.env.VITE_SDSERVER_BASE_URL || 'ws://localhost:8080/rspc/ws'
)
});
+9 -11
View File
@@ -1,15 +1,13 @@
<!DOCTYPE html>
<html class="dark">
<head>
<meta charset="utf-8" />
<title>Spacedrive</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<head>
<meta charset="utf-8" />
<title>Spacedrive</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
+4 -2
View File
@@ -1,8 +1,10 @@
import App from './App';
import '@sd/ui/style';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import '@sd/ui/style';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
+3 -5
View File
@@ -1,7 +1,5 @@
{
"extends": "../../packages/config/interface.tsconfig.json",
"compilerOptions": {},
"include": [
"src"
]
"extends": "../../packages/config/interface.tsconfig.json",
"compilerOptions": {},
"include": ["src"]
}
+14 -1
View File
@@ -1,5 +1,7 @@
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import { createHtmlPlugin } from 'vite-plugin-html';
import svg from 'vite-plugin-svgr';
import tsconfigPaths from 'vite-plugin-tsconfig-paths';
@@ -10,7 +12,18 @@ export default defineConfig({
server: {
port: 8002
},
plugins: [react(), svg({ svgrOptions: { icon: true } }), tsconfigPaths()],
plugins: [
react(),
svg({ svgrOptions: { icon: true } }),
tsconfigPaths(),
createHtmlPlugin({
minify: true
}),
visualizer({
gzipSize: true,
brotliSize: true
})
],
root: 'src',
publicDir: '../../packages/interface/src/assets',
define: {
+17 -15
View File
@@ -25,44 +25,46 @@ hostname = "0.3.1"
# Universal Dependencies
base64 = "0.13.0"
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4.19", features = ["serde"] }
chrono = { version = "0.4.22", features = ["serde"] }
serde_json = "1.0"
futures = "0.3"
data-encoding = "2.3.2"
ring = "0.17.0-alpha.10"
ring = "0.17.0-alpha.11"
int-enum = "0.4.0"
rmp = "^0.8.11"
rmp-serde = "^1.1.0"
rmp-serde = "^1.1.1"
# Project dependencies
rspc = { workspace = true, features = ["uuid", "chrono", "tracing"] }
prisma-client-rust = { workspace = true }
rspc = { version = "0.0.5", features = ["uuid", "chrono", "tracing"] }
uuid = { version = "1.1.2", features = ["v4", "serde"] }
sysinfo = "0.23.9"
thiserror = "1.0.30"
sysinfo = "0.26.4"
thiserror = "1.0.37"
tokio = { version = "1.17.0", features = [
tokio = { version = "1.21.2", features = [
"sync",
"rt-multi-thread",
"io-util",
] }
include_dir = { version = "0.7.2", features = ["glob"] }
async-trait = "^0.1.52"
image = "0.24.1"
async-trait = "^0.1.57"
image = "0.24.4"
webp = "0.2.2"
ffmpeg-next = { version = "5.0.3", optional = true, features = [] }
ffmpeg-next = { version = "5.1.1", optional = true, features = [] }
sd-ffmpeg = { path = "../crates/ffmpeg", optional = true }
sd-file-ext = { path = "../crates/file-ext"}
fs_extra = "1.2.0"
tracing = "0.1.35"
tracing-subscriber = { version = "0.3.14", features = ["env-filter"] }
tracing = "0.1.36"
tracing-subscriber = { version = "0.3.15", features = ["env-filter"] }
async-stream = "0.3.3"
once_cell = "1.13.0"
ctor = "0.1.22"
once_cell = "1.15.0"
ctor = "0.1.23"
globset = { version = "^0.4.9", features = ["serde1"] }
itertools = "^0.10.3"
itertools = "^0.10.5"
enumflags2 = "0.7.5"
openssl-sys = "0.9.76" # We don't use this in the core but it exists so that the workspace level patch doesn't complain that the dependency isn't used.
[dev-dependencies]
tempfile = "^3.3.0"
tracing-test = "^0.2.3"
+46 -41
View File
@@ -5,42 +5,45 @@ use serde::Deserialize;
use super::{utils::LibraryRequest, RouterBuilder};
#[derive(Type, Deserialize)]
pub struct SetNoteArgs {
pub id: i32,
pub note: Option<String>,
}
#[derive(Type, Deserialize)]
pub struct SetFavoriteArgs {
pub id: i32,
pub favorite: bool,
}
pub(crate) fn mount() -> RouterBuilder {
<RouterBuilder>::new()
.library_query("readMetadata", |_, _id: i32, _| async move {
#[allow(unreachable_code)]
Ok(todo!())
.library_query("readMetadata", |t| {
t(|_, _id: i32, _| async move {
#[allow(unreachable_code)]
Ok(todo!())
})
})
.library_mutation("setNote", |_, args: SetNoteArgs, library| async move {
library
.db
.object()
.update(
object::id::equals(args.id),
vec![object::note::set(args.note)],
)
.exec()
.await?;
.library_mutation("setNote", |t| {
#[derive(Type, Deserialize)]
pub struct SetNoteArgs {
pub id: i32,
pub note: Option<String>,
}
invalidate_query!(library, "locations.getExplorerData");
t(|_, args: SetNoteArgs, library| async move {
library
.db
.object()
.update(
object::id::equals(args.id),
vec![object::note::set(args.note)],
)
.exec()
.await?;
Ok(())
invalidate_query!(library, "locations.getExplorerData");
Ok(())
})
})
.library_mutation(
"setFavorite",
|_, args: SetFavoriteArgs, library| async move {
.library_mutation("setFavorite", |t| {
#[derive(Type, Deserialize)]
pub struct SetFavoriteArgs {
pub id: i32,
pub favorite: bool,
}
t(|_, args: SetFavoriteArgs, library| async move {
library
.db
.object()
@@ -54,17 +57,19 @@ pub(crate) fn mount() -> RouterBuilder {
invalidate_query!(library, "locations.getExplorerData");
Ok(())
},
)
.library_mutation("delete", |_, id: i32, library| async move {
library
.db
.object()
.delete(object::id::equals(id))
.exec()
.await?;
})
})
.library_mutation("delete", |t| {
t(|_, id: i32, library| async move {
library
.db
.object()
.delete(object::id::equals(id))
.exec()
.await?;
invalidate_query!(library, "locations.getExplorerData");
Ok(())
invalidate_query!(library, "locations.getExplorerData");
Ok(())
})
})
}
+57 -55
View File
@@ -14,56 +14,56 @@ use std::path::PathBuf;
use super::{utils::LibraryRequest, CoreEvent, RouterBuilder};
#[derive(Type, Deserialize)]
pub struct GenerateThumbsForLocationArgs {
pub id: i32,
pub path: PathBuf,
}
#[derive(Type, Deserialize)]
pub struct IdentifyUniqueFilesArgs {
pub id: i32,
pub path: PathBuf,
}
pub(crate) fn mount() -> RouterBuilder {
<RouterBuilder>::new()
.library_query("getRunning", |ctx, _: (), _| async move {
Ok(ctx.jobs.get_running().await)
.library_query("getRunning", |t| {
t(|ctx, _: (), _| async move { Ok(ctx.jobs.get_running().await) })
})
.library_query("getHistory", |_, _: (), library| async move {
Ok(JobManager::get_history(&library).await?)
.library_query("getHistory", |t| {
t(|_, _: (), library| async move { Ok(JobManager::get_history(&library).await?) })
})
.library_mutation(
"generateThumbsForLocation",
|_, args: GenerateThumbsForLocationArgs, library| async move {
if library
.db
.location()
.count(vec![location::id::equals(args.id)])
.exec()
.await? == 0
{
return Err(LocationError::IdNotFound(args.id).into());
}
.library_mutation("generateThumbsForLocation", |t| {
#[derive(Type, Deserialize)]
pub struct GenerateThumbsForLocationArgs {
pub id: i32,
pub path: PathBuf,
}
library
.spawn_job(Job::new(
ThumbnailJobInit {
location_id: args.id,
path: PathBuf::new(),
background: true,
},
Box::new(ThumbnailJob {}),
))
.await;
t(
|_, args: GenerateThumbsForLocationArgs, library| async move {
if library
.db
.location()
.count(vec![location::id::equals(args.id)])
.exec()
.await? == 0
{
return Err(LocationError::IdNotFound(args.id).into());
}
Ok(())
},
)
.library_mutation(
"identifyUniqueFiles",
|_, args: IdentifyUniqueFilesArgs, library| async move {
library
.spawn_job(Job::new(
ThumbnailJobInit {
location_id: args.id,
path: PathBuf::new(),
background: true,
},
Box::new(ThumbnailJob {}),
))
.await;
Ok(())
},
)
})
.library_mutation("identifyUniqueFiles", |t| {
#[derive(Type, Deserialize)]
pub struct IdentifyUniqueFilesArgs {
pub id: i32,
pub path: PathBuf,
}
t(|_, args: IdentifyUniqueFilesArgs, library| async move {
if fetch_location(&library, args.id).exec().await?.is_none() {
return Err(rspc::Error::new(
ErrorCode::NotFound,
@@ -82,19 +82,21 @@ pub(crate) fn mount() -> RouterBuilder {
.await;
Ok(())
},
)
.library_subscription("newThumbnail", |ctx, _: (), _| {
// TODO: Only return event for the library that was subscribed to
})
})
.library_subscription("newThumbnail", |t| {
t(|ctx, _: (), _| {
// TODO: Only return event for the library that was subscribed to
let mut event_bus_rx = ctx.event_bus.subscribe();
async_stream::stream! {
while let Ok(event) = event_bus_rx.recv().await {
match event {
CoreEvent::NewThumbnail { cas_id } => yield cas_id,
_ => {}
let mut event_bus_rx = ctx.event_bus.subscribe();
async_stream::stream! {
while let Ok(event) = event_bus_rx.recv().await {
match event {
CoreEvent::NewThumbnail { cas_id } => yield cas_id,
_ => {}
}
}
}
}
})
})
}
+79 -73
View File
@@ -12,87 +12,93 @@ use serde::Deserialize;
use tokio::fs;
use uuid::Uuid;
#[derive(Type, Deserialize)]
pub struct EditLibraryArgs {
pub id: Uuid,
pub name: Option<String>,
pub description: Option<String>,
}
pub(crate) fn mount() -> RouterBuilder {
<RouterBuilder>::new()
.query("list", |ctx, _: ()| async move {
ctx.library_manager.get_all_libraries_config().await
.query("list", |t| {
t(|ctx, _: ()| async move { ctx.library_manager.get_all_libraries_config().await })
})
.library_query("getStatistics", |_, _: (), library| async move {
let _statistics = library
.db
.statistics()
.find_unique(statistics::id::equals(library.node_local_id))
.exec()
.await?;
.library_query("getStatistics", |t| {
t(|_, _: (), library| async move {
let _statistics = library
.db
.statistics()
.find_unique(statistics::id::equals(library.node_local_id))
.exec()
.await?;
// TODO: get from database, not sys
let volumes = get_volumes();
save_volume(&library).await?;
// TODO: get from database, not sys
let volumes = get_volumes();
save_volume(&library).await?;
let mut available_capacity: u64 = 0;
let mut total_capacity: u64 = 0;
if volumes.is_ok() {
for volume in volumes? {
total_capacity += volume.total_capacity;
available_capacity += volume.available_capacity;
let mut available_capacity: u64 = 0;
let mut total_capacity: u64 = 0;
if volumes.is_ok() {
for volume in volumes? {
total_capacity += volume.total_capacity;
available_capacity += volume.available_capacity;
}
}
let library_db_size = match fs::metadata(library.config().data_directory()).await {
Ok(metadata) => metadata.len(),
Err(_) => 0,
};
let thumbnail_folder_size =
get_size(library.config().data_directory().join("thumbnails"));
use statistics::*;
let params = vec![
id::set(1), // Each library is a database so only one of these ever exists
date_captured::set(Utc::now().into()),
total_object_count::set(0),
library_db_size::set(library_db_size.to_string()),
total_bytes_used::set(0.to_string()),
total_bytes_capacity::set(total_capacity.to_string()),
total_unique_bytes::set(0.to_string()),
total_bytes_free::set(available_capacity.to_string()),
preview_media_bytes::set(thumbnail_folder_size.unwrap_or(0).to_string()),
];
Ok(library
.db
.statistics()
.upsert(
statistics::id::equals(1), // Each library is a database so only one of these ever exists
params.clone(),
params,
)
.exec()
.await?)
})
})
.mutation("create", |t| {
t(|ctx, name: String| async move {
Ok(ctx
.library_manager
.create(LibraryConfig {
name: name.to_string(),
..Default::default()
})
.await?)
})
})
.mutation("edit", |t| {
#[derive(Type, Deserialize)]
pub struct EditLibraryArgs {
pub id: Uuid,
pub name: Option<String>,
pub description: Option<String>,
}
let library_db_size = match fs::metadata(library.config().data_directory()).await {
Ok(metadata) => metadata.len(),
Err(_) => 0,
};
let thumbnail_folder_size =
get_size(library.config().data_directory().join("thumbnails"));
use statistics::*;
let params = vec![
id::set(1), // Each library is a database so only one of these ever exists
date_captured::set(Utc::now().into()),
total_object_count::set(0),
library_db_size::set(library_db_size.to_string()),
total_bytes_used::set(0.to_string()),
total_bytes_capacity::set(total_capacity.to_string()),
total_unique_bytes::set(0.to_string()),
total_bytes_free::set(available_capacity.to_string()),
preview_media_bytes::set(thumbnail_folder_size.unwrap_or(0).to_string()),
];
Ok(library
.db
.statistics()
.upsert(
statistics::id::equals(1), // Each library is a database so only one of these ever exists
params.clone(),
params,
)
.exec()
.await?)
t(|ctx, args: EditLibraryArgs| async move {
Ok(ctx
.library_manager
.edit(args.id, args.name, args.description)
.await?)
})
})
.mutation("create", |ctx, name: String| async move {
Ok(ctx
.library_manager
.create(LibraryConfig {
name: name.to_string(),
..Default::default()
})
.await?)
})
.mutation("edit", |ctx, args: EditLibraryArgs| async move {
Ok(ctx
.library_manager
.edit(args.id, args.name, args.description)
.await?)
})
.mutation("delete", |ctx, id: Uuid| async move {
Ok(ctx.library_manager.delete_library(id).await?)
.mutation("delete", |t| {
t(|ctx, id: Uuid| async move { Ok(ctx.library_manager.delete_library(id).await?) })
})
}
+151 -134
View File
@@ -9,17 +9,11 @@ use crate::{
prisma::{file_path, indexer_rule, indexer_rules_in_location, location, object, tag},
};
use rspc::{self, ErrorCode, Type};
use rspc::{self, internal::MiddlewareBuilderLike, ErrorCode, Type};
use serde::{Deserialize, Serialize};
use tracing::info;
use super::{utils::LibraryRequest, RouterBuilder};
#[derive(Serialize, Deserialize, Type, Debug)]
pub struct ExplorerData {
pub context: ExplorerContext,
pub items: Vec<ExplorerItem>,
}
use super::{utils::LibraryRequest, Ctx, RouterBuilder};
#[derive(Serialize, Deserialize, Type, Debug)]
#[serde(tag = "type")]
@@ -29,9 +23,6 @@ pub enum ExplorerContext {
// Space(object_in_space::Data),
}
file_path::include!(file_path_with_object { object });
object::include!(object_with_file_paths { file_paths });
#[derive(Serialize, Deserialize, Type, Debug)]
#[serde(tag = "type")]
pub enum ExplorerItem {
@@ -39,36 +30,53 @@ pub enum ExplorerItem {
Object(Box<object_with_file_paths::Data>),
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
pub struct LocationExplorerArgs {
pub location_id: i32,
pub path: String,
pub limit: i32,
pub cursor: Option<String>,
#[derive(Serialize, Deserialize, Type, Debug)]
pub struct ExplorerData {
pub context: ExplorerContext,
pub items: Vec<ExplorerItem>,
}
pub(crate) fn mount() -> RouterBuilder {
file_path::include!(file_path_with_object { object });
object::include!(object_with_file_paths { file_paths });
// TODO(@Oscar): This return type sucks. Add an upstream rspc solution.
pub(crate) fn mount() -> rspc::RouterBuilder<
Ctx,
(),
impl MiddlewareBuilderLike<Ctx, LayerContext = Ctx> + Send + 'static,
> {
<RouterBuilder>::new()
.library_query("list", |_, _: (), library| async move {
Ok(library
.db
.location()
.find_many(vec![])
.include(location::include!({ node }))
.exec()
.await?)
.library_query("list", |t| {
t(|_, _: (), library| async move {
Ok(library
.db
.location()
.find_many(vec![])
.include(location::include!({ node }))
.exec()
.await?)
})
})
.library_query("getById", |_, location_id: i32, library| async move {
Ok(library
.db
.location()
.find_unique(location::id::equals(location_id))
.exec()
.await?)
.library_query("getById", |t| {
t(|_, location_id: i32, library| async move {
Ok(library
.db
.location()
.find_unique(location::id::equals(location_id))
.exec()
.await?)
})
})
.library_query(
"getExplorerData",
|_, args: LocationExplorerArgs, library| async move {
.library_query("getExplorerData", |t| {
#[derive(Clone, Serialize, Deserialize, Type, Debug)]
pub struct LocationExplorerArgs {
pub location_id: i32,
pub path: String,
pub limit: i32,
pub cursor: Option<String>,
}
t(|_, args: LocationExplorerArgs, library| async move {
let location = library
.db
.location()
@@ -124,119 +132,128 @@ pub(crate) fn mount() -> RouterBuilder {
})
.collect(),
})
},
)
.library_mutation(
"create",
|_, args: LocationCreateArgs, library| async move {
})
})
.library_mutation("create", |t| {
t(|_, args: LocationCreateArgs, library| async move {
let location = args.create(&library).await?;
scan_location(&library, location).await?;
Ok(())
},
)
.library_mutation(
"update",
|_, args: LocationUpdateArgs, library| async move {
})
})
.library_mutation("update", |t| {
t(|_, args: LocationUpdateArgs, library| async move {
args.update(&library).await.map_err(Into::into)
},
)
.library_mutation("delete", |_, location_id: i32, library| async move {
library
.db
.file_path()
.delete_many(vec![file_path::location_id::equals(location_id)])
.exec()
.await?;
library
.db
.indexer_rules_in_location()
.delete_many(vec![indexer_rules_in_location::location_id::equals(
location_id,
)])
.exec()
.await?;
library
.db
.location()
.delete(location::id::equals(location_id))
.exec()
.await?;
invalidate_query!(library, "locations.list");
info!("Location {} deleted", location_id);
Ok(())
})
})
.library_mutation("fullRescan", |_, location_id: i32, library| async move {
scan_location(
&library,
fetch_location(&library, location_id)
.include(indexer_job_location::include())
.library_mutation("delete", |t| {
t(|_, location_id: i32, library| async move {
library
.db
.file_path()
.delete_many(vec![file_path::location_id::equals(location_id)])
.exec()
.await?
.ok_or(LocationError::IdNotFound(location_id))?,
)
.await
.map_err(Into::into)
.await?;
library
.db
.indexer_rules_in_location()
.delete_many(vec![indexer_rules_in_location::location_id::equals(
location_id,
)])
.exec()
.await?;
library
.db
.location()
.delete(location::id::equals(location_id))
.exec()
.await?;
invalidate_query!(library, "locations.list");
info!("Location {} deleted", location_id);
Ok(())
})
})
.library_mutation("quickRescan", |_, _: (), _| async move {
#[allow(unreachable_code)]
Ok(todo!())
.library_mutation("fullRescan", |t| {
t(|_, location_id: i32, library| async move {
scan_location(
&library,
fetch_location(&library, location_id)
.include(indexer_job_location::include())
.exec()
.await?
.ok_or(LocationError::IdNotFound(location_id))?,
)
.await
.map_err(Into::into)
})
})
.library_mutation("quickRescan", |t| {
t(|_, _: (), _| async move {
#[allow(unreachable_code)]
Ok(todo!())
})
})
.merge("indexer_rules.", mount_indexer_rule_routes())
}
fn mount_indexer_rule_routes() -> RouterBuilder {
<RouterBuilder>::new()
.library_mutation(
"create",
|_, args: IndexerRuleCreateArgs, library| async move {
.library_mutation("create", |t| {
t(|_, args: IndexerRuleCreateArgs, library| async move {
args.create(&library).await.map_err(Into::into)
},
)
.library_mutation("delete", |_, indexer_rule_id: i32, library| async move {
library
.db
.indexer_rules_in_location()
.delete_many(vec![indexer_rules_in_location::indexer_rule_id::equals(
indexer_rule_id,
)])
.exec()
.await?;
library
.db
.indexer_rule()
.delete(indexer_rule::id::equals(indexer_rule_id))
.exec()
.await?;
Ok(())
})
})
.library_query("get", |_, indexer_rule_id: i32, library| async move {
library
.db
.indexer_rule()
.find_unique(indexer_rule::id::equals(indexer_rule_id))
.exec()
.await?
.ok_or_else(|| {
rspc::Error::new(
ErrorCode::NotFound,
format!("Indexer rule <id={indexer_rule_id}> not found"),
)
})
.library_mutation("delete", |t| {
t(|_, indexer_rule_id: i32, library| async move {
library
.db
.indexer_rules_in_location()
.delete_many(vec![indexer_rules_in_location::indexer_rule_id::equals(
indexer_rule_id,
)])
.exec()
.await?;
library
.db
.indexer_rule()
.delete(indexer_rule::id::equals(indexer_rule_id))
.exec()
.await?;
Ok(())
})
})
.library_query("list", |_, _: (), library| async move {
library
.db
.indexer_rule()
.find_many(vec![])
.exec()
.await
.map_err(Into::into)
.library_query("get", |t| {
t(|_, indexer_rule_id: i32, library| async move {
library
.db
.indexer_rule()
.find_unique(indexer_rule::id::equals(indexer_rule_id))
.exec()
.await?
.ok_or_else(|| {
rspc::Error::new(
ErrorCode::NotFound,
format!("Indexer rule <id={indexer_rule_id}> not found"),
)
})
})
})
.library_query("list", |t| {
t(|_, _: (), library| async move {
library
.db
.indexer_rule()
.find_many(vec![])
.exec()
.await
.map_err(Into::into)
})
})
}
+38 -33
View File
@@ -1,4 +1,5 @@
use std::{
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
@@ -62,12 +63,14 @@ pub(crate) fn mount() -> Arc<Router> {
// .export_ts_bindings(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("./index.ts")),
.set_ts_bindings_header("/* eslint-disable */"),
)
.query("version", |_, _: ()| env!("CARGO_PKG_VERSION"))
.query("getNode", |ctx, _: ()| async move {
Ok(NodeState {
config: ctx.config.get().await,
// We are taking the assumption here that this value is only used on the frontend for display purposes
data_path: ctx.config.data_directory().to_string_lossy().into_owned(),
.query("version", |t| t(|_, _: ()| env!("CARGO_PKG_VERSION")))
.query("getNode", |t| {
t(|ctx, _: ()| async move {
Ok(NodeState {
config: ctx.config.get().await,
// We are taking the assumption here that this value is only used on the frontend for display purposes
data_path: ctx.config.data_directory().to_string_lossy().into_owned(),
})
})
})
.merge("library.", libraries::mount())
@@ -77,46 +80,48 @@ pub(crate) fn mount() -> Arc<Router> {
.merge("files.", files::mount())
.merge("jobs.", jobs::mount())
// TODO: Scope the invalidate queries to a specific library (filtered server side)
.subscription("invalidateQuery", |ctx, _: ()| {
let mut event_bus_rx = ctx.event_bus.subscribe();
let mut last = Instant::now();
async_stream::stream! {
while let Ok(event) = event_bus_rx.recv().await {
match event {
CoreEvent::InvalidateOperation(op) => yield op,
CoreEvent::InvalidateOperationDebounced(op) => {
let current = Instant::now();
if current.duration_since(last) > Duration::from_millis(1000 / 60) {
last = current;
yield op;
}
},
_ => {}
.subscription("invalidateQuery", |t| {
t(|ctx, _: ()| {
let mut event_bus_rx = ctx.event_bus.subscribe();
let mut last = Instant::now();
async_stream::stream! {
while let Ok(event) = event_bus_rx.recv().await {
match event {
CoreEvent::InvalidateOperation(op) => yield op,
CoreEvent::InvalidateOperationDebounced(op) => {
let current = Instant::now();
if current.duration_since(last) > Duration::from_millis(1000 / 60) {
last = current;
yield op;
}
},
_ => {}
}
}
}
}
})
})
.build()
.arced();
InvalidRequests::validate(r.clone()); // This validates all invalidation calls.
export_ts_bindings(&r);
r
}
pub fn export_ts_bindings(r: &Router) {
r.export_ts(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../packages/client/src/core.ts"))
.expect("Error exporting rspc Typescript bindings!");
r.export_ts(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../apps/mobile/src/types/bindings.ts"),
)
.expect("Error exporting rspc Typescript bindings!");
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
/// This test will ensure the rspc router and all calls to `invalidate_query` are valid and also export an updated version of the Typescript bindings.
#[test]
fn test_and_export_rspc_bindings() {
let r = super::mount();
r.export_ts(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../packages/client/src/core.ts"),
)
.expect("Error exporting rspc Typescript bindings!");
r.export_ts(
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../apps/mobile/src/types/bindings.ts"),
)
.expect("Error exporting rspc Typescript bindings!");
super::export_ts_bindings(&super::mount());
}
}
+78
View File
@@ -0,0 +1,78 @@
// use std::collections::HashMap;
// use p2p::PeerId;
// use rspc::Type;
// use serde::Deserialize;
// use super::{LibraryArgs, RouterBuilder};
// #[derive(Type, Deserialize)]
// pub struct AcceptPairingRequestArgs {
// pub peer_id: PeerId,
// pub preshared_key: String,
// }
// pub(crate) fn mount() -> RouterBuilder {
// RouterBuilder::new()
// .query("getNodes", |ctx, arg: LibraryArgs<()>| async move {
// let (_, library) = arg.get_library(&ctx).await?;
// Ok(
// library.db.node().find_many(vec![]).exec().await?, // TODO: Make this work
// // .into_iter()
// // .filter_map(|v| {
// // if v.id == ctx.node_local_id {
// // None
// // } else {
// // Some(v.into())
// // }
// // })
// // .collect::<Vec<LibraryNode>>()
// )
// })
// .query("connectedPeers", |ctx, _: ()| async move {
// ctx.p2p
// .nm
// .connected_peers()
// .into_iter()
// .map(|(_, v)| (v.id, v.metadata))
// .collect::<HashMap<_, _>>()
// })
// .query("discoveredPeers", |ctx, _: ()| async move {
// ctx.p2p
// .nm
// .discovered_peers()
// .into_iter()
// // TODO: Make this better
// .map(|(_, v)| v)
// .collect::<Vec<_>>()
// })
// .mutation("pairNode", |ctx, arg: LibraryArgs<PeerId>| async move {
// let (peer_id, library) = arg.get_library(&ctx).await?;
// let preshared_key = ctx.p2p.pair(&library, peer_id).await.unwrap();
// // TODO: These aren't library queries so they can't be invalidated with the current system. We can fix this with the normalised cache!
// // invalidate_query!(ctx, "p2p.discoveredPeers": (), ());
// // invalidate_query!(ctx, "p2p.connectedPeers": (), ());
// Ok(preshared_key)
// })
// .mutation(
// "unpairNode",
// |_, _: LibraryArgs<PeerId>| async move { todo!() },
// )
// .mutation(
// "acceptPairingRequest",
// |ctx, arg: AcceptPairingRequestArgs| async move {
// ctx.p2p
// .pairing_requests
// .lock()
// .unwrap()
// .remove(&arg.peer_id)
// .unwrap()
// .send(Ok(arg.preshared_key))
// .unwrap(); // TODO: Remove unwrap
// },
// )
// }
+155 -136
View File
@@ -12,168 +12,187 @@ use crate::{
use super::{utils::LibraryRequest, RouterBuilder};
#[derive(Type, Deserialize)]
pub struct TagCreateArgs {
pub name: String,
pub color: String,
}
#[derive(Debug, Type, Deserialize)]
pub struct TagAssignArgs {
pub object_id: i32,
pub tag_id: i32,
pub unassign: bool,
}
#[derive(Type, Deserialize)]
pub struct TagUpdateArgs {
pub id: i32,
pub name: Option<String>,
pub color: Option<String>,
}
pub(crate) fn mount() -> RouterBuilder {
RouterBuilder::new()
.library_query("list", |_, _: (), library| async move {
Ok(library.db.tag().find_many(vec![]).exec().await?)
.library_query("list", |t| {
t(
|_, _: (), library| async move { Ok(library.db.tag().find_many(vec![]).exec().await?) },
)
})
.library_query("getExplorerData", |_, tag_id: i32, library| async move {
info!("Getting files for tag {}", tag_id);
.library_query("getExplorerData", |t| {
t(|_, tag_id: i32, library| async move {
info!("Getting files for tag {}", tag_id);
let tag = library
.db
.tag()
.find_unique(tag::id::equals(tag_id))
.exec()
.await?
.ok_or_else(|| {
rspc::Error::new(ErrorCode::NotFound, format!("Tag <id={tag_id}> not found"))
})?;
let tag = library
.db
.tag()
.find_unique(tag::id::equals(tag_id))
.exec()
.await?
.ok_or_else(|| {
rspc::Error::new(
ErrorCode::NotFound,
format!("Tag <id={tag_id}> not found"),
)
})?;
let files: Vec<ExplorerItem> = library
.db
.object()
.find_many(vec![object::tags::some(vec![
tag_on_object::tag_id::equals(tag_id),
])])
.include(object_with_file_paths::include())
.exec()
.await?
.into_iter()
.map(|mut object| {
// sorry brendan
// grab the first path and tac on the name
let oldest_path = &object.file_paths[0];
object.name = Some(oldest_path.name.clone());
object.extension = oldest_path.extension.clone();
// a long term fix for this would be to have the indexer give the Object a name and extension, sacrificing its own and only store newly found Path names that differ from the Object name
let objects: Vec<ExplorerItem> = library
.db
.object()
.find_many(vec![object::tags::some(vec![
tag_on_object::tag_id::equals(tag_id),
])])
.include(object_with_file_paths::include())
.exec()
.await?
.into_iter()
.map(|mut object| {
// sorry brendan
// grab the first path and tac on the name
let oldest_path = &object.file_paths[0];
object.name = Some(oldest_path.name.clone());
object.extension = oldest_path.extension.clone();
// a long term fix for this would be to have the indexer give the Object a name and extension, sacrificing its own and only store newly found Path names that differ from the Object name
let thumb_path = library
.config()
.data_directory()
.join(THUMBNAIL_CACHE_DIR_NAME)
.join(&object.cas_id)
.with_extension("webp");
let thumb_path = library
.config()
.data_directory()
.join(THUMBNAIL_CACHE_DIR_NAME)
.join(&object.cas_id)
.with_extension("webp");
object.has_thumbnail = thumb_path.exists();
object.has_thumbnail = thumb_path.exists();
ExplorerItem::Object(Box::new(object))
ExplorerItem::Object(Box::new(object))
})
.collect();
info!("Got objects {}", objects.len());
Ok(ExplorerData {
context: ExplorerContext::Tag(tag),
items: objects,
})
.collect();
info!("Got files {}", files.len());
Ok(ExplorerData {
context: ExplorerContext::Tag(tag),
items: files,
})
})
.library_query("getForFile", |_, object_id: i32, library| async move {
Ok(library
.db
.tag()
.find_many(vec![tag::tag_objects::some(vec![
tag_on_object::object_id::equals(object_id),
])])
.exec()
.await?)
})
.library_query("get", |_, tag_id: i32, library| async move {
Ok(library
.db
.tag()
.find_unique(tag::id::equals(tag_id))
.exec()
.await?)
})
.library_mutation("create", |_, args: TagCreateArgs, library| async move {
let created_tag = library
.db
.tag()
.create(
Uuid::new_v4().as_bytes().to_vec(),
vec![
tag::name::set(Some(args.name)),
tag::color::set(Some(args.color)),
],
)
.exec()
.await?;
invalidate_query!(library, "tags.list");
Ok(created_tag)
})
.library_mutation("assign", |_, args: TagAssignArgs, library| async move {
if args.unassign {
library
.library_query("getForObject", |t| {
t(|_, object_id: i32, library| async move {
Ok(library
.db
.tag_on_object()
.delete(tag_on_object::tag_id_object_id(args.tag_id, args.object_id))
.tag()
.find_many(vec![tag::tag_objects::some(vec![
tag_on_object::object_id::equals(object_id),
])])
.exec()
.await?;
} else {
library
.await?)
})
})
.library_query("get", |t| {
t(|_, tag_id: i32, library| async move {
Ok(library
.db
.tag_on_object()
.tag()
.find_unique(tag::id::equals(tag_id))
.exec()
.await?)
})
})
.library_mutation("create", |t| {
#[derive(Type, Deserialize)]
pub struct TagCreateArgs {
pub name: String,
pub color: String,
}
t(|_, args: TagCreateArgs, library| async move {
let created_tag = library
.db
.tag()
.create(
tag::id::equals(args.tag_id),
object::id::equals(args.object_id),
vec![],
Uuid::new_v4().as_bytes().to_vec(),
vec![
tag::name::set(Some(args.name)),
tag::color::set(Some(args.color)),
],
)
.exec()
.await?;
invalidate_query!(library, "tags.list");
Ok(created_tag)
})
})
.library_mutation("assign", |t| {
#[derive(Debug, Type, Deserialize)]
pub struct TagAssignArgs {
pub object_id: i32,
pub tag_id: i32,
pub unassign: bool,
}
invalidate_query!(library, "tags.getForFile");
t(|_, args: TagAssignArgs, library| async move {
if args.unassign {
library
.db
.tag_on_object()
.delete(tag_on_object::tag_id_object_id(args.tag_id, args.object_id))
.exec()
.await?;
} else {
library
.db
.tag_on_object()
.create(
tag::id::equals(args.tag_id),
object::id::equals(args.object_id),
vec![],
)
.exec()
.await?;
}
Ok(())
invalidate_query!(library, "tags.getForObject");
Ok(())
})
})
.library_mutation("update", |_, args: TagUpdateArgs, library| async move {
library
.db
.tag()
.update(
tag::id::equals(args.id),
vec![tag::name::set(args.name), tag::color::set(args.color)],
)
.exec()
.await?;
.library_mutation("update", |t| {
#[derive(Type, Deserialize)]
pub struct TagUpdateArgs {
pub id: i32,
pub name: Option<String>,
pub color: Option<String>,
}
invalidate_query!(library, "tags.list");
t(|_, args: TagUpdateArgs, library| async move {
library
.db
.tag()
.update(
tag::id::equals(args.id),
vec![tag::name::set(args.name), tag::color::set(args.color)],
)
.exec()
.await?;
Ok(())
invalidate_query!(library, "tags.list");
Ok(())
})
})
.library_mutation("delete", |_, tag_id: i32, library| async move {
library
.db
.tag()
.delete(tag::id::equals(tag_id))
.exec()
.await?;
.library_mutation("delete", |t| {
t(|_, tag_id: i32, library| async move {
library
.db
.tag()
.delete(tag::id::equals(tag_id))
.exec()
.await?;
invalidate_query!(library, "tags.list");
invalidate_query!(library, "tags.list");
Ok(())
Ok(())
})
})
}
+130 -66
View File
@@ -1,5 +1,13 @@
use std::sync::Arc;
use futures::{Future, Stream};
use rspc::{internal::specta, ErrorCode, IntoLayerResult, Type};
use rspc::{
internal::{
specta, BuiltProcedureBuilder, MiddlewareBuilderLike, RequestResult,
UnbuiltProcedureBuilder,
},
ErrorCode, Type,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use uuid::Uuid;
@@ -12,116 +20,172 @@ pub(crate) struct LibraryArgs<T> {
pub arg: T,
}
// WARNING: This is system is using internal API's which means it will break between rspc release. I would avoid copying it unless you understand the cost of maintaining it!
pub trait LibraryRequest {
fn library_query<T, TArg, TResult, TResultMarker>(
fn library_query<TUnbuiltResolver, TUnbuiltResult, TUnbuiltResultMarker, TBuiltResolver, TArg>(
self,
key: &'static str,
resolver: fn(Ctx, TArg, LibraryContext) -> TResult,
builder: impl FnOnce(
UnbuiltProcedureBuilder<Ctx, TUnbuiltResolver>,
) -> BuiltProcedureBuilder<TBuiltResolver>,
) -> Self
where
TArg: DeserializeOwned + specta::Type + Send + 'static,
TResult: Future<Output = Result<T, rspc::Error>> + Send + 'static,
T: IntoLayerResult<TResultMarker> + Send + Serialize + specta::Type;
TUnbuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send,
TBuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send + Sync + 'static,
TUnbuiltResult: RequestResult<TUnbuiltResultMarker> + Send,
TArg: DeserializeOwned + specta::Type + Send + 'static;
fn library_mutation<T, TArg, TResult, TResultMarker>(
fn library_mutation<
TUnbuiltResolver,
TUnbuiltResult,
TUnbuiltResultMarker,
TBuiltResolver,
TArg,
>(
self,
key: &'static str,
resolver: fn(Ctx, TArg, LibraryContext) -> TResult,
builder: impl FnOnce(
UnbuiltProcedureBuilder<Ctx, TUnbuiltResolver>,
) -> BuiltProcedureBuilder<TBuiltResolver>,
) -> Self
where
TArg: DeserializeOwned + specta::Type + Send + 'static,
TResult: Future<Output = Result<T, rspc::Error>> + Send + 'static,
T: IntoLayerResult<TResultMarker> + Send + Serialize + specta::Type;
TUnbuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send,
TBuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send + Sync + 'static,
TUnbuiltResult: RequestResult<TUnbuiltResultMarker> + Send,
TArg: DeserializeOwned + specta::Type + Send + 'static;
fn library_subscription<T, TArg, TResult>(
fn library_subscription<TResolver, TArg, TStream, TResult>(
self,
key: &'static str,
resolver: fn(Ctx, TArg, Uuid) -> T,
builder: impl Fn(UnbuiltProcedureBuilder<Ctx, TResolver>) -> BuiltProcedureBuilder<TResolver>,
) -> Self
where
TArg: DeserializeOwned + specta::Type + Send + 'static,
T: Stream<Item = TResult> + Send + 'static,
TResult: Serialize + specta::Type;
TArg: DeserializeOwned + specta::Type + 'static,
TStream: Stream<Item = TResult> + Send + Sync + 'static,
TResult: Serialize + specta::Type,
TResolver: Fn(Ctx, TArg, Uuid) -> TStream + Send + Sync + 'static;
}
// Note: This will break with middleware context switching but that's fine for now
impl LibraryRequest for rspc::RouterBuilder<Ctx, (), Ctx> {
fn library_query<T, TArg, TResult, TResultMarker>(
impl<TMiddleware> LibraryRequest for rspc::RouterBuilder<Ctx, (), TMiddleware>
where
TMiddleware: MiddlewareBuilderLike<Ctx, LayerContext = Ctx> + Send + 'static,
{
fn library_query<TUnbuiltResolver, TUnbuiltResult, TUnbuiltResultMarker, TBuiltResolver, TArg>(
self,
key: &'static str,
resolver: fn(Ctx, TArg, LibraryContext) -> TResult,
builder: impl FnOnce(
UnbuiltProcedureBuilder<Ctx, TUnbuiltResolver>,
) -> BuiltProcedureBuilder<TBuiltResolver>,
) -> Self
where
TUnbuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send,
TBuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send + Sync + 'static,
TUnbuiltResult: RequestResult<TUnbuiltResultMarker> + Send,
TArg: DeserializeOwned + specta::Type + Send + 'static,
TResult: Future<Output = Result<T, rspc::Error>> + Send + 'static,
T: IntoLayerResult<TResultMarker> + Send + Serialize + specta::Type,
{
self.query(key, move |ctx, arg: LibraryArgs<TArg>| async move {
let library = ctx
.library_manager
.get_ctx(arg.library_id)
.await
.ok_or_else(|| {
rspc::Error::new(
ErrorCode::BadRequest,
"You must specify a valid library to use this operation.".to_string(),
)
})?;
self.query(key, move |t| {
let resolver = Arc::new(builder(UnbuiltProcedureBuilder::new(t.data())).resolver);
resolver(ctx, arg.arg, library).await
t(move |ctx, arg: LibraryArgs<TArg>| {
let resolver = resolver.clone();
async move {
let library = ctx
.library_manager
.get_ctx(arg.library_id)
.await
.ok_or_else(|| {
rspc::Error::new(
ErrorCode::BadRequest,
"You must specify a valid library to use this operation."
.to_string(),
)
})?;
Ok(resolver(ctx, arg.arg, library)
.into_request_future()?
.exec()
.await?)
}
})
})
}
fn library_mutation<T, TArg, TResult, TResultMarker>(
fn library_mutation<
TUnbuiltResolver,
TUnbuiltResult,
TUnbuiltResultMarker,
TBuiltResolver,
TArg,
>(
self,
key: &'static str,
resolver: fn(Ctx, TArg, LibraryContext) -> TResult,
builder: impl FnOnce(
UnbuiltProcedureBuilder<Ctx, TUnbuiltResolver>,
) -> BuiltProcedureBuilder<TBuiltResolver>,
) -> Self
where
TUnbuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send,
TBuiltResolver: Fn(Ctx, TArg, LibraryContext) -> TUnbuiltResult + Send + Sync + 'static,
TUnbuiltResult: RequestResult<TUnbuiltResultMarker> + Send,
TArg: DeserializeOwned + specta::Type + Send + 'static,
TResult: Future<Output = Result<T, rspc::Error>> + Send + 'static,
T: IntoLayerResult<TResultMarker> + Send + Serialize + specta::Type,
{
self.mutation(key, move |ctx, arg: LibraryArgs<TArg>| async move {
let library = ctx
.library_manager
.get_ctx(arg.library_id)
.await
.ok_or_else(|| {
rspc::Error::new(
ErrorCode::BadRequest,
"You must specify a valid library to use this operation.".to_string(),
)
})?;
self.mutation(key, move |t| {
let resolver = Arc::new(builder(UnbuiltProcedureBuilder::new(t.data())).resolver);
resolver(ctx, arg.arg, library).await
t(move |ctx, arg: LibraryArgs<TArg>| {
let resolver = resolver.clone();
async move {
let library = ctx
.library_manager
.get_ctx(arg.library_id)
.await
.ok_or_else(|| {
rspc::Error::new(
ErrorCode::BadRequest,
"You must specify a valid library to use this operation."
.to_string(),
)
})?;
Ok(resolver(ctx, arg.arg, library)
.into_request_future()?
.exec()
.await?)
}
})
})
}
fn library_subscription<T, TArg, TResult>(
fn library_subscription<TResolver, TArg, TStream, TResult>(
self,
key: &'static str,
resolver: fn(Ctx, TArg, Uuid) -> T,
builder: impl Fn(UnbuiltProcedureBuilder<Ctx, TResolver>) -> BuiltProcedureBuilder<TResolver>,
) -> Self
where
TArg: DeserializeOwned + specta::Type + Send + 'static,
T: Stream<Item = TResult> + Send + 'static,
TArg: DeserializeOwned + specta::Type + 'static,
TStream: Stream<Item = TResult> + Send + Sync + 'static,
TResult: Serialize + specta::Type,
TResolver: Fn(Ctx, TArg, Uuid) -> TStream + Send + Sync + 'static,
{
self.subscription(key, move |ctx, arg: LibraryArgs<TArg>| {
// TODO: Make this fetch the library like the other functions. This needs upstream rspc work to be supported.
// let library = ctx
// .library_manager
// .get_ctx(arg.library_id)
// .await
// .ok_or_else(|| {
// rspc::Error::new(
// ErrorCode::BadRequest,
// "You must specify a valid library to use this operation.".to_string(),
// )
// })?;
self.subscription(key, |t| {
let resolver = Arc::new(builder(UnbuiltProcedureBuilder::new(t.data())).resolver);
resolver(ctx, arg.arg, arg.library_id)
t(move |ctx, arg: LibraryArgs<TArg>| {
// TODO(@Oscar): Upstream rspc work to allow this to work
// let library = ctx
// .library_manager
// .get_ctx(arg.library_id)
// .await
// .ok_or_else(|| {
// rspc::Error::new(
// ErrorCode::BadRequest,
// "You must specify a valid library to use this operation.".to_string(),
// )
// })?;
resolver(ctx, arg.arg, arg.library_id)
})
})
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::volume::get_volumes;
use super::{Router, RouterBuilder};
use super::RouterBuilder;
pub(crate) fn mount() -> RouterBuilder {
<Router>::new().query("list", |_, _: ()| Ok(get_volumes()?))
RouterBuilder::new().query("list", |t| t(|_, _: ()| Ok(get_volumes()?)))
}
+17 -1
View File
@@ -53,6 +53,12 @@ impl Node {
// dbg!(get_object_kind_from_extension("png"));
// let (non_blocking, _guard) = tracing_appender::non_blocking(rolling::daily(
// Path::new(&data_dir).join("logs"),
// "log",
// ));
// TODO: Make logs automatically delete after x time https://github.com/tokio-rs/tracing/pull/2169
tracing_subscriber::registry()
.with(
EnvFilter::from_default_env()
@@ -71,9 +77,19 @@ impl Node {
"desktop=debug"
.parse()
.expect("Error invalid tracing directive!"),
),
), // .add_directive(
// "rspc=debug"
// .parse()
// .expect("Error invalid tracing directive!"),
// ),
)
.with(fmt::layer().with_filter(CONSOLE_LOG_FILTER))
// .with(
// Layer::default()
// .with_writer(non_blocking)
// .with_ansi(false)
// .with_filter(LevelFilter::DEBUG),
// )
.init();
let event_bus = broadcast::channel(1024);
+6
View File
@@ -40,6 +40,12 @@ pub struct NodeConfig {
pub name: String,
// the port this node uses for peer to peer communication. By default a random free port will be chosen each time the application is started.
pub p2p_port: Option<u32>,
// /// The P2P identity public key
// pub p2p_cert: Vec<u8>,
// /// The P2P identity private key
// pub p2p_key: Vec<u8>,
// /// The address of the Spacetunnel discovery service being used.
// pub spacetunnel_addr: Option<String>,
}
#[derive(Error, Debug)]
+1 -1
View File
@@ -29,7 +29,7 @@ pub async fn load_and_migrate(db_url: &str) -> Result<PrismaClient, MigrationErr
.map(|v| v == "true")
.unwrap_or(false)
{
builder = builder.force_reset();
builder = builder.accept_data_loss().force_reset();
}
builder.await?;
+3 -3
View File
@@ -13,10 +13,10 @@ resolver = "2"
[dependencies]
ffmpeg-sys-next = "5.1.1"
thiserror = "1.0.35"
thiserror = "1.0.37"
webp = "0.2.2"
tokio = { version = "1.21.1", features = ["fs", "rt"] }
tokio = { version = "1.21.2", features = ["fs", "rt"] }
[dev-dependencies]
tempfile = "3.3.0"
tokio = { version = "1.21.1", features = ["fs", "rt", "macros"] }
tokio = { version = "1.21.2", features = ["fs", "rt", "macros"] }
+3 -3
View File
@@ -1,6 +1,6 @@
# FFMPEG Thumbnailer RS
Rust implementation of a thumbnail generation for video files using ffmpeg.
Rust implementation of a thumbnail generation for video files using ffmpeg.
Based on https://github.com/dirkvdb/ffmpegthumbnailer
For now only implements the minimum API for Spacedrive needs. PRs are welcome
@@ -32,8 +32,8 @@ async fn main() -> Result<(), ThumbnailerError> {
.with_film_strip(false)
.quality(80.0)?
.build();
thumbnailer.process("input.mp4", "output.webp").await
}
```
```
+22 -2
View File
@@ -3,6 +3,26 @@ name = "sd-p2p"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
sd-tunnel-utils = { path = "./tunnel/utils" }
dashmap = "5.3.4"
rcgen = "0.9.2"
rustls = "0.20.6"
tokio = { version = "1.19.2", features = ["macros", "sync"] }
if-watch = "1.1.1"
thiserror = "1.0.31"
mdns-sd = "0.5.5"
quinn = "0.8.3"
futures-util = "0.3.21"
ts-rs = "6.2.0"
serde = { version = "1.0.138", features = ["derive"] }
bip39 = { version = "1.0.1", features = ["rand"] }
rmp-serde = "1.1.0"
spake2 = "0.3.1"
ctrlc = { version = "3.2.2", features = ["termination"] }
tracing = "0.1.35"
specta = "0.0.2"
[dev-dependencies]
tokio = { version = "1.19.2", features = ["rt-multi-thread"] }
+9
View File
@@ -0,0 +1,9 @@
# P2P
This is the P2P library which powers Spacedrive's P2P functionality.
## Viewing debug logs
```bash
RUST_LOG="p2p=debug" cargo run -p server
```
+74
View File
@@ -0,0 +1,74 @@
# Spacedrive Peer to Peer
This document outlines the peer to peer protocol used by the Spacedrive desktop application. This document is designed to outlined how the system works and also discuss the security and decision making behind the system.
## Concepts
- **Peer** - TODO
### P2PManager
The peer to peer library is designed to be general purpose. This means none of the Spacedrive code is directly tied into the peer to peer library. This makes for a very nice separation of concerns but also introduces the requirement for an abstraction to sit above the P2P library so it can properly make decisions with the data that Spacedrive holds. This is where the `P2PManager` trait comes in. You must implement the `P2PManager` trait in your application code and then the peer to peer system will call various hooks, allowing your system to react to various events.
The `P2PManager` is implemented as a Rust trait which works very well for allowing the application to hook into the peer to peer system, however, in Rust async functions are not properly supported in traits. This is works very well when combined with an `tokio::mpsc::unbound_channel()` implemented in your application, so that you can run async code in response to a specific situtation. The only expect to syncronus methods is the `peer_paired` method as we want to be sure that the peer was properly saved into the database on both sides. This method returns a `Pin<Box<dyn Future<Output = Result<(), ()>>>>`.
It's important we maintain a good separation between the `P2PManager` and the application which is using it. This led to to the decision to make the P2P system focusing on getting a stream of bytes (`Vec<u8>`) between peers. This means the P2P system does not enforce a specific serialization method for your data, this gives you the choice in your application layer to choice whatever works best for the type of data you are going to be sending. This also means you are responsible for data compression. We use the [`rmp_serde`](https://crates.io/crates/rmp-serde) crate (which uses [msgpack](https://msgpack.org/index.html)) internally to send data between clients, and would reccomend it in your application however, this decision is entirely up to you.
### Identity keypair
Unpon installing Spacedrive your application with generate a public and private key paired which is called the identity keypair. These certificates facilitate secure communication and identify the client to other peers.
### Peer ID's
Each peer has a unique identifier which is called a peer id. This identifier is derived from a [SHA-1](https://en.wikipedia.org/wiki/SHA-1) hash of the identity keypair's public key.
**Note: We might change from SHA-1 to SHA-255 or SHA-256 in the near future. The current limitation is the maximum size of a DNS TXT record. No known SHA-1 collisions exist for certificates but given SHA-1 has been broken it would be preferable to use something more secure.**
## Discovery
Discovery is the first phase of the peer to peer process. The goal of discovery is to determine a list of other peers which we could potentially pair to. This phase is made up of multiple different protocol and the result of all of the systems are combined and returned to the application.
### LAN
To discovery other machines running Spacedrive over your local network, we make use of [mDNS](). mDNS is a protocol which transmits DNS packets using multicast UDP which allows a DNS record to be published to your local network and read by other devices on the network. This system is used commonly by other systems with similar goals such as [libp2p]() and [Apple's Airplay](). We are using [DNS-SD]() which makes use of DNS SRV and TXT records to advertise information about the current peer.
Spacedrive advertise a SRV record that looks like:
_{peer_id}\_spacedrive_._udp_.local. 86400 IN SRV 10 5 5223 server.example.com.
This system will continue to passively discover clients while Spacedrive is running.
### Global Discovery
The global discovery system works in a different way. TODO
#### Announcement
TODO: Discuss proto + security
```rust
Message::ClientAnnouncement { peer_id, addresses: vec!["192.168.0.1".to_string(), "1.1.1.1".to_string()] }
```
#### Query
TODO
```rust
Message::QueryClientAnnouncement(vec![peer_id, peer_id2]);
```
## General Overview
This system is designed on top of the following main technologies:
- [QUIC]() - A tcp-like protocol built on top of UDP. QUIC also supports [TLS 1.3]() for encryption and pro
## Pairing
TODO
# External Resources
- TODO: Magic Wormhole talk
- TODO: Syncthing spec
+120
View File
@@ -0,0 +1,120 @@
use std::{env, time::Duration};
use p2p::{Identity, NetworkManager, NetworkManagerConfig, P2PManager, Peer, PeerId, PeerMetadata};
use quinn::{RecvStream, SendStream};
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
#[derive(Debug, Clone)]
pub enum P2PEvent {
PeerDiscovered(PeerId),
PeerExpired(PeerId),
PeerConnected(PeerId),
PeerDisconnected(PeerId),
}
// SdP2PManager is part of your application and allows you to hook into the behavior of the P2PManager.
#[derive(Clone)]
pub struct SdP2PManager {
// peer_name is the name of the current peer. In a normal application this would be a display name set by the end user.
peer_name: String,
/// event_channel is used to send events to the application
event_channel: UnboundedSender<P2PEvent>,
}
impl P2PManager for SdP2PManager {
const APPLICATION_NAME: &'static str = "spacedrive";
fn get_metadata(&self) -> PeerMetadata {
PeerMetadata {
name: self.peer_name.clone(),
version: Some(env!("CARGO_PKG_VERSION").into()),
operating_system: todo!(),
}
}
fn peer_discovered(&self, nm: &NetworkManager<Self>, peer_id: &PeerId) {
self.event_channel
.send(P2PEvent::PeerDiscovered(peer_id.clone()));
nm.add_known_peer(peer_id.clone()); // Be careful doing this in a production application because it will just trust all clients
}
fn peer_expired(&self, nm: &NetworkManager<Self>, peer_id: PeerId) {
self.event_channel.send(P2PEvent::PeerExpired(peer_id));
}
fn peer_connected(&self, nm: &NetworkManager<Self>, peer_id: PeerId) {
self.event_channel.send(P2PEvent::PeerConnected(peer_id));
}
fn peer_disconnected(&self, nm: &NetworkManager<Self>, peer_id: PeerId) {
self.event_channel.send(P2PEvent::PeerDisconnected(peer_id));
}
fn accept_stream(&self, peer: &Peer<Self>, (mut tx, mut rx): (SendStream, RecvStream)) {
let peer = peer.clone();
tokio::spawn(async move {
let msg = rx.read_chunk(1024, true).await.unwrap().unwrap();
println!("Received '{:?}' from peer '{}'", msg.bytes, peer.id);
tx.write(b"Pong").await.unwrap();
});
}
}
#[tokio::main]
async fn main() {
let identity = Identity::new().unwrap();
let peer_id = PeerId::from_cert(&identity.clone().into_rustls().0);
let mut event_channel = unbounded_channel();
let nm = NetworkManager::new(
identity,
SdP2PManager {
peer_name: format!(
"{}-{}",
peer_id
.to_string()
.chars()
.into_iter()
.take(5)
.collect::<String>(),
env::consts::OS
),
event_channel: event_channel.0,
},
NetworkManagerConfig {
known_peers: Default::default(),
listen_port: None,
},
)
.await
.unwrap();
println!(
"Peer '{}' listening on: {:?}",
nm.peer_id(),
nm.listen_addr()
);
loop {
tokio::select! {
event = event_channel.1.recv() => {
if let Some(event) = event {
println!("{:?}", event);
match event {
P2PEvent::PeerConnected(peer_id) => {
nm.send_to(peer_id, b"Ping on Connection").await.unwrap();
}
_ => {}
}
}
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
println!("");
for (peer_id, peer) in nm.connected_peers() {
println!("Sending ping to '{:?}'", peer_id);
let resp = nm.send_to(peer_id, b"Ping").await.unwrap();
println!("Peer '{}' responded to ping with message '{:?}'", peer.id, resp.bytes);
}
}
};
}
}
@@ -0,0 +1,86 @@
/// The functions in this file are predominantly useless in the current system. This will be fixed in a future PR's.
use std::sync::Arc;
use sd_tunnel_utils::{Client, Message};
use tracing::warn;
use crate::{NetworkManager, NetworkManagerError, P2PManager};
/// GlobalDiscovery is the discovery system for discovering devices which are not on the same local network as you.
/// This is done through the Spacetunnel server hosted by Spacedrive Inc. it could however be hosted by anyone and documentation for doing so will be released in the future once we are confident in the current design.
pub(crate) struct GlobalDiscovery<TP2PManager: P2PManager> {
nm: Arc<NetworkManager<TP2PManager>>,
client: Client,
}
impl<TP2PManager: P2PManager> GlobalDiscovery<TP2PManager> {
pub fn init(nm: &Arc<NetworkManager<TP2PManager>>) -> Result<Self, NetworkManagerError> {
tracing::debug!("Starting mdns discovery service");
if let Some(url) = &nm.spacetunnel_url {
Ok(Self {
nm: nm.clone(),
client: Client::new(url.clone(), nm.endpoint.clone(), nm.identity.clone()),
})
} else {
panic!("Why no Spacetunnel? (~_^)");
// TODO: Refactor to allow the system to work without Spacetunnel enabled.
}
}
pub async fn poll(&self) {
tracing::debug!("Polling global discovery service");
// TODO: Allow the tunnel server to accept a list of PeerId's instead of doing heaps of requests
let peers = self.nm.known_peers.iter().map(|v| v.clone()).collect();
match self
.client
.send_message(Message::QueryClientAnnouncement(peers))
.await
{
Ok(_) => {
tracing::debug!("Successfully sent query announcement");
}
Err(err) => {
warn!(
"[TODO: WIP FEATURE REPORTED ERROR] Spacetunnel failed lookup peers with error: {:?}",
err
);
// TODO: Handle error when this is implemented.
}
}
// TODO: Handle error from discovery service
// self.nm.discovered_peers.insert(key, value); // TODO: make this work
// TODO: Open connection to peers if they are not already connected
}
pub async fn register(&self) {
// TODO: Send the metadata along with the discovery payload
// TODO: Only do announcement if data has changed or it's been over 10 minutes since last packet
let announcement = Message::ClientAnnouncement {
peer_id: self.nm.peer_id.clone(),
addresses: self.nm.lan_addrs.iter().map(|v| v.to_string()).collect(), // TODO: Include STUN address in this list
};
tracing::debug!(
"Registering self with global discovery service: {:?}",
announcement
);
match self.client.send_message(announcement).await {
Ok(_) => tracing::debug!("Successfully registered with global discovery service"),
Err(err) => {
warn!("[TODO: WIP FEATURE REPORTED ERROR] Spacetunnel failed announcement with error: {:?}", err);
// TODO: Handle error when this is implemented.
}
}
// TODO: Handle error from discovery service
}
pub(crate) fn shutdown(&self) {
tracing::debug!("Shutting down gloval discovery service");
// TODO: Remove the announcement from the tunnel
}
}
+147
View File
@@ -0,0 +1,147 @@
use std::{net::Ipv4Addr, sync::Arc};
use mdns_sd::{Receiver, ServiceDaemon, ServiceEvent, ServiceInfo};
use sd_tunnel_utils::PeerId;
use tracing::warn;
use crate::{NetworkManager, NetworkManagerError, P2PManager, PeerCandidate, PeerMetadata};
/// MDNS is the discovery system used for over local networks. It makes use of Multicast DNS (mDNS) to discover peers.
/// It should also conforms to the mDNS SD specification.
pub(crate) struct Mdns<TP2PManager: P2PManager> {
nm: Arc<NetworkManager<TP2PManager>>,
mdns: ServiceDaemon,
browser: Receiver<ServiceEvent>,
service_type: String,
}
impl<TP2PManager: P2PManager> Mdns<TP2PManager> {
pub fn init(nm: &Arc<NetworkManager<TP2PManager>>) -> Result<Self, NetworkManagerError> {
tracing::debug!("Starting mdns discovery service");
let mdns = ServiceDaemon::new()?;
let service_type = format!("_{}._udp.local.", TP2PManager::APPLICATION_NAME);
Ok(Self {
nm: nm.clone(),
browser: mdns.browse(&service_type)?,
mdns,
service_type,
})
}
pub async fn handle_mdns_event(&self) {
match self.browser.recv_async().await {
Ok(event) => {
tracing::debug!("Handling incoming mdns event: {:?}", event);
match event {
ServiceEvent::SearchStarted(_) => {}
ServiceEvent::ServiceFound(_, _) => {}
ServiceEvent::ServiceResolved(info) => {
let raw_peer_id = info
.get_fullname()
.replace(&format!(".{}", self.service_type), "");
match PeerId::from_string(raw_peer_id.clone()) {
Ok(peer_id) => {
// Prevent discovery of the current peer.
if peer_id == self.nm.peer_id {
return;
}
let peer = PeerCandidate {
id: peer_id.clone(),
metadata: PeerMetadata::from_hashmap(
&peer_id,
info.get_properties(),
),
addresses: info.get_addresses().iter().copied().collect(),
port: info.get_port(),
};
self.nm.add_discovered_peer(peer);
}
Err(_) => {
warn!(
"resolved peer advertising itself with an invalid peer_id '{}'",
raw_peer_id
);
}
}
}
ServiceEvent::ServiceRemoved(_, fullname) => {
let raw_peer_id = fullname.replace(&format!(".{}", self.service_type), "");
match PeerId::from_string(raw_peer_id.clone()) {
Ok(peer_id) => {
// Prevent discovery of the current peer.
if peer_id == self.nm.peer_id {
return;
}
self.nm.remove_discovered_peer(peer_id);
}
Err(_) => {
warn!(
"resolved peer advertising itself with an invalid peer_id '{}'",
raw_peer_id
);
}
}
}
ServiceEvent::SearchStopped(_) => {}
}
}
Err(err) => {
tracing::warn!(
"Error receiving MDNS event as the ServiceDaemon has been shut down: {:?}",
err
);
tracing::info!("Error receiving MDNS event as the ServiceDaemon has been shut down. Local discovery has been disabled, please restart your app to re-enable local discovery!");
}
}
}
pub async fn register(&self) {
let peer_id_str = &self.nm.peer_id.to_string();
let service_info = ServiceInfo::new(
&self.service_type,
peer_id_str,
&format!("{}.", peer_id_str),
&(self
.nm
.lan_addrs
.iter()
.map(|v| *v)
.collect::<Vec<Ipv4Addr>>())[..],
self.nm.listen_addr.port(),
Some(self.nm.manager.get_metadata().to_hashmap()),
);
tracing::debug!("Registering mdns service entry: {:?}", service_info);
match service_info {
Ok(service_info) => match self.mdns.register(service_info) {
Ok(_) => {}
Err(err) => {
warn!("failed to register mdns service: {}", err);
}
},
Err(err) => {
warn!("failed to register mdns service: {}", err);
}
}
}
/// shutdown shuts down the MDNS service. This will advertise the current peer as unavailable to the rest of the network.
pub(crate) fn shutdown(&self) {
tracing::debug!("Shutting down mdns discovery service");
// The panics caused by `.expect` are acceptable here because they are run during shutdown where nothing can be done if they were to fail.
self.mdns
.unregister(&format!("{}.{}", self.nm.peer_id, self.service_type))
.expect("Error unregistering the mDNS service")
.recv()
.expect("Error unregistering the mDNS service");
self.mdns
.shutdown()
.expect("Error shutting down mDNS service");
}
}
+7
View File
@@ -0,0 +1,7 @@
mod global_discovery;
mod mdns;
mod stack;
pub(crate) use global_discovery::*;
pub(crate) use mdns::*;
pub(crate) use stack::*;
+32
View File
@@ -0,0 +1,32 @@
use std::sync::Arc;
use crate::{GlobalDiscovery, Mdns, NetworkManager, NetworkManagerError, P2PManager};
/// Represents a stack of all of the different discovery mechanisms that are used by the P2P library.
/// Traits are not used due to Rust's current lack of proper support for async traits.
pub(crate) struct DiscoveryStack<TP2PManager: P2PManager> {
pub mdns: Arc<Mdns<TP2PManager>>,
pub global: Arc<GlobalDiscovery<TP2PManager>>,
}
impl<TP2PManager: P2PManager> DiscoveryStack<TP2PManager> {
pub async fn new(nm: &Arc<NetworkManager<TP2PManager>>) -> Result<Self, NetworkManagerError> {
let global = Arc::new(GlobalDiscovery::init(nm)?);
global.poll().await;
Ok(Self {
mdns: Arc::new(Mdns::init(nm)?),
global,
})
}
pub async fn register(&self) {
self.mdns.register().await;
self.global.register().await;
}
pub fn shutdown(&self) {
self.mdns.shutdown();
self.global.shutdown();
}
}
+17
View File
@@ -0,0 +1,17 @@
mod discovery;
mod network_manager;
mod p2p_manager;
mod peer;
mod utils;
pub(crate) use discovery::*;
pub use network_manager::*;
pub use p2p_manager::*;
pub use peer::*;
pub use sd_tunnel_utils::{read_value, write_value, PeerId};
pub use utils::*;
/// We reexport some types from `quinn` to avoid the user needing to add `quinn` and keep its version in sync with the p2p library.
pub mod quinn {
pub use quinn::{RecvStream, SendStream};
}
-3
View File
@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}
+13
View File
@@ -0,0 +1,13 @@
mod nm;
mod nm_config;
mod nm_error;
mod nm_internal;
mod nm_server;
mod proto;
pub use nm::*;
pub use nm_config::*;
pub use nm_error::*;
pub use nm_internal::*;
pub use nm_server::*;
pub use proto::*;
+439
View File
@@ -0,0 +1,439 @@
use std::{
collections::HashMap,
net::{Ipv4Addr, SocketAddr},
sync::Arc,
time::Duration,
};
use bip39::{Language, Mnemonic};
use dashmap::{DashMap, DashSet};
use futures_util::future::join_all;
use quinn::{Chunk, Endpoint, NewConnection, RecvStream, SendStream, ServerConfig};
use rustls::{Certificate, PrivateKey};
use sd_tunnel_utils::{quic, write_value, PeerId, UtilError};
use spake2::{Ed25519Group, Password, Spake2};
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, error, warn};
use crate::{
ConnectError, ConnectionEstablishmentPayload, ConnectionType, Identity, NetworkManagerConfig,
NetworkManagerError, NetworkManagerInternalEvent, P2PManager, PairingParticipantType,
PairingPayload, Peer, PeerCandidate,
};
/// Is the core of the P2P Library. It manages listening for and creating P2P network connections and also provides a nice API for the application embedding this library to interface with.
pub struct NetworkManager<TP2PManager: P2PManager> {
/// PeerId is the unique identifier of the current peer.
pub(crate) peer_id: PeerId,
/// identity is the TLS identity of the current peer.
pub(crate) identity: (Certificate, PrivateKey),
/// known_peers contains a list of all peers which are known to the network. These will be automatically connected if found.
/// We store these so when making a request to the global discovery server we know who to lookup.
pub(crate) known_peers: DashSet<PeerId>,
/// discovered_peers contains a list of all peers which have been discovered by any discovery mechanism.
discovered_peers: DashMap<PeerId, PeerCandidate>,
/// connected_peers
connected_peers: DashMap<PeerId, Peer<TP2PManager>>,
/// lan_addrs contains a list of all local addresses which exists on the current peer.
pub(crate) lan_addrs: DashSet<Ipv4Addr>,
/// listen_addr contains the address which the current peer is listening on. This peer will listening on IPv4 and IPv6 on a random port if none was provided at startup.
pub(crate) listen_addr: SocketAddr,
/// manager is a trait which implements P2PManager and is used so the NetworkManager can interact with the host application.
pub(crate) manager: TP2PManager,
/// endpoint is the QUIC endpoint that is used to send and receive network traffic between peers.
pub(crate) endpoint: Endpoint,
/// spacetunnel_server is the URL used to lookup information about the Spacetunnel server to establish a connection with.
pub(crate) spacetunnel_url: Option<String>,
/// internal_channel is a channel which is used to communicate with the main internal event loop.
internal_channel: mpsc::UnboundedSender<NetworkManagerInternalEvent>,
}
impl<TP2PManager: P2PManager> NetworkManager<TP2PManager> {
/// Initalise a new network manager for your application.
/// Be aware this will create a separate thread running the P2P manager event loop so this should really only be run once per application.
pub async fn new(
identity: Identity,
manager: TP2PManager,
config: NetworkManagerConfig,
) -> Result<Arc<Self>, NetworkManagerError> {
debug!("Creating new NetworkManager...");
if !TP2PManager::APPLICATION_NAME
.chars()
.all(char::is_alphanumeric)
{
return Err(NetworkManagerError::InvalidAppName);
}
let identity = identity.into_rustls();
let (endpoint, incoming) = Endpoint::server(
ServerConfig::with_crypto(Arc::new(quic::server_config(
vec![identity.0.clone()],
identity.1.clone(),
)?)),
format!("[::]:{}", config.listen_port.unwrap_or(0))
.parse()
.expect("unreachable error: invalid connection address. Please report if you encounter this error!"),
)
.map_err(NetworkManagerError::Server)?;
let internal_channel = mpsc::unbounded_channel();
let this = Arc::new(Self {
peer_id: PeerId::from_cert(&identity.0),
identity,
known_peers: config.known_peers.into_iter().collect(),
discovered_peers: DashMap::new(),
connected_peers: DashMap::new(),
lan_addrs: DashSet::new(),
listen_addr: endpoint.local_addr().map_err(NetworkManagerError::Server)?,
manager,
endpoint,
spacetunnel_url: config.spacetunnel_url,
internal_channel: internal_channel.0,
});
Self::event_loop(&this, incoming, internal_channel.1).await?;
Ok(this)
}
pub(crate) fn add_discovered_peer(&self, peer: PeerCandidate) {
debug!("Discovered peer: {:?}", peer);
self.discovered_peers.insert(peer.id.clone(), peer.clone());
self.manager.peer_discovered(self, &peer.id);
if self.known_peers.contains(&peer.id) {
match self
.internal_channel
.send(NetworkManagerInternalEvent::Connect(peer))
{
Ok(_) => {}
Err(err) => {
error!("Failed to send on internal_channel: {:?}", err);
}
}
}
}
pub(crate) fn remove_discovered_peer(&self, peer_id: PeerId) {
debug!("Removing discovered peer: {:?}", peer_id);
self.discovered_peers.remove(&peer_id);
self.manager.peer_expired(self, peer_id);
}
pub(crate) fn get_discovered_peer(&self, peer_id: &PeerId) -> Option<PeerCandidate> {
self.discovered_peers.get(peer_id).map(|v| v.clone())
}
pub(crate) fn is_peer_connected(&self, peer_id: &PeerId) -> bool {
self.connected_peers.contains_key(peer_id)
}
pub(crate) fn add_connected_peer(&self, peer: Peer<TP2PManager>) {
debug!("Connected with peer: {:?}", peer);
let peer_id = peer.id.clone();
self.connected_peers.insert(peer.id.clone(), peer);
self.manager.peer_connected(self, peer_id);
}
pub(crate) fn remove_connected_peer(&self, peer_id: PeerId) {
debug!("Disconnected with peer: {:?}", peer_id);
self.connected_peers.remove(&peer_id);
self.manager.peer_disconnected(self, peer_id);
}
/// returns the peer ID of the current peer. These are unique identifier derived from the peers public key.
pub fn peer_id(&self) -> PeerId {
self.peer_id.clone()
}
/// returns the address that the NetworkManager will listen on for incoming connections from other peers.
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
/// adds a new peer to the known peers list. This will cause the NetworkManager to attempt to connect to the peer if it is discovered.
pub fn add_known_peer(&self, peer_id: PeerId) {
debug!("Adding '{:?}' as a known peer", peer_id);
self.known_peers.insert(peer_id.clone());
match self
.internal_channel
.send(NetworkManagerInternalEvent::NewKnownPeer(peer_id))
{
Ok(_) => {}
Err(err) => {
error!("Failed to send on internal_channel: {:?}", err);
}
}
}
/// send a single message to a peer and await a single response. This is good for quick one-off communications but any longer term communication should be done with a stream.
/// TODO: Error type
pub async fn send_to(&self, peer_id: PeerId, data: &[u8]) -> Result<Chunk, NMError> {
debug!("Sending message to '{:?}'", peer_id);
tokio::time::sleep(Duration::from_millis(500)).await; // TODO: Fix this issue. This workaround is because DashMap is eventually consistent
let peer = self
.connected_peers
.get(&peer_id)
.ok_or(NMError::PeerNotConnected)?
.value()
.clone();
let (mut tx, mut rx) = peer.conn.open_bi().await?;
tx.write(data).await?;
let (oneshot_tx, oneshot_rx) = oneshot::channel();
tokio::spawn(async move {
// TODO: Max length of packet should be a constant in tunnel-utils::quic
match rx.read_chunk(64 * 1024, true).await {
Ok(Some(data)) => match oneshot_tx.send(data) {
Ok(_) => match tx.finish().await {
Ok(_) => {}
Err(err) => {
warn!("Failed to finish connection: {:?}", err);
}
},
Err(_) => {
error!("Failed to transmit result back to `NetworkManager::send_to` using oneshot! `send_to` will timeout and this error can be ignored.");
}
},
Ok(None) => {}
Err(err) => {
warn!(
"Failed to read from stream with peer '{}': {:?}",
peer.id, err
);
}
}
});
// TODO: add timeout for oneshot
Ok(oneshot_rx.await?)
}
pub fn broadcast(self: &Arc<Self>, data: Vec<u8>) {
let mut connections = Vec::with_capacity(self.connected_peers.len());
for peer in self.connected_peers.iter() {
connections.push((
peer.key().clone(),
peer.value().conn.open_bi(),
data.clone(),
));
}
let connections = connections
.into_iter()
.map(move |(peer_id, conn, data)| async move {
match conn.await {
Ok((mut tx, _)) => match tx.write(&data).await {
Ok(_) => {}
Err(err) => {
warn!(
"Failed to write to stream with peer '{}': {:?}",
peer_id, err
);
}
},
Err(err) => {
warn!(
"Failed to write to stream with peer '{}': {:?}",
peer_id, err
);
}
}
});
tokio::spawn(join_all(connections));
}
/// stream will return the tx and rx channel to a new stream with a remote peer.
/// Be aware that when you drop the rx channel, the stream will be closed and any data in transit will be lost.
pub async fn stream(&self, peer_id: &PeerId) -> Result<(SendStream, RecvStream), NMError> {
debug!("Opening stream with peer '{:?}'", peer_id);
Ok(self
.connected_peers
.get(peer_id)
.ok_or(NMError::PeerNotConnected)?
.conn
.open_bi()
.await?)
}
/// returns a list of the connected peers.
pub fn connected_peers(&self) -> HashMap<PeerId, Peer<TP2PManager>> {
self.connected_peers.clone().into_iter().collect()
}
/// returns a list of the discovered peers.
pub fn discovered_peers(&self) -> HashMap<PeerId, PeerCandidate> {
self.discovered_peers.clone().into_iter().collect()
}
// initiate_pairing_with_peer will initiate a pairing with a peer.
// This will cause the NetworkManager to attempt to connect to the peer if it is discovered and if it is, verify the preshared_key using PAKE before telling the [crate::P2PManager] that the pairing is complete.
pub async fn initiate_pairing_with_peer(
self: &Arc<Self>,
remote_peer_id: PeerId,
extra_data: HashMap<String, String>,
) -> Result<String, NMError> {
debug!("Starting pairing with '{:?}'", remote_peer_id);
// TODO: Ensure we are not already paired with the peer
let candidate = self
.discovered_peers
.get(&remote_peer_id)
.ok_or(NMError::PeerNotFound)?
.clone();
let m = Mnemonic::generate_in(
Language::English,
24, /* This library doesn't work with any number here for some reason */
)?;
let preshared_key: String = m.word_iter().take(4).collect::<Vec<_>>().join("-");
let (spake, pake_msg) = Spake2::<Ed25519Group>::start_a(
&Password::new(preshared_key.as_bytes()),
&spake2::Identity::new(self.peer_id.as_bytes()),
&spake2::Identity::new(remote_peer_id.as_bytes()),
);
let NewConnection {
connection,
bi_streams,
..
} = Self::connect_to_peer_internal(&self.clone(), candidate).await?;
let (mut tx, mut rx) = connection.open_bi().await?;
write_value(
&mut tx,
&ConnectionEstablishmentPayload::PairingRequest {
pake_msg,
metadata: self.manager.get_metadata(),
extra_data: extra_data.clone(),
},
)
.await?;
let nm = self.clone();
tokio::spawn(async move {
// TODO: Timeout if reading chunk is not quick
// TODO: Get max chunk size from constant.
let data = match rx.read_chunk(64 * 1024, true).await {
Ok(Some(data)) => data,
Ok(None) => {
warn!("connection closed before we could read from it!");
return;
}
Err(err) => {
warn!("error reading from connection: {}", err);
return;
}
};
let payload = match rmp_serde::decode::from_read(&data.bytes[..]) {
Ok(payload) => payload,
Err(err) => {
warn!("error decoding pairing payload: {}", err);
return;
}
};
match payload {
PairingPayload::PairingAccepted { pake_msg, metadata } => {
match spake.finish(&pake_msg) {
Ok(_) => {} // We only use SPAKE2 to ensure the current connection is to the peer we expect, hence we don't use the key which is returned.
Err(err) => {
warn!(
"error pairing with peer. Connection has been tampered with! err: {:?}",
err
);
return;
}
};
let resp = match nm
.manager
.peer_paired(
&nm,
PairingParticipantType::Initiator,
&remote_peer_id,
&metadata,
&extra_data,
)
.await
{
Ok(_) => PairingPayload::PairingComplete,
Err(err) => {
warn!("p2p manager error: {:?}", err);
PairingPayload::PairingFailed
}
};
match write_value(&mut tx, &resp).await {
Ok(_) => {}
Err(err) => {
warn!(
"error encoding and sending pairing response to connection: {}",
err
);
return;
}
}
match Peer::new(
ConnectionType::Client,
remote_peer_id,
connection,
metadata,
nm,
)
.await
{
Ok(peer) => {
tokio::spawn(peer.handler(bi_streams));
}
Err(err) => {
warn!("error creating peer: {:?}", err);
}
}
}
PairingPayload::PairingFailed => {
panic!("Pairing failed");
// TODO
// self.manager
// .peer_paired_rollback(&self, &remote_peer_id, &extra_data)
// .await;
// TODO: emit event to frontend
}
_ => panic!("Invalid request!"),
}
});
Ok(preshared_key)
}
}
// TODO: rename + docs
#[derive(Error, Debug)]
pub enum NMError {
#[error("The peer is not currently connected")]
PeerNotConnected,
#[error("The peer could not be found")]
PeerNotFound,
#[error("Error communicating with peer")]
ConnectionError(#[from] quinn::ConnectionError),
#[error("Error communicating with peer")]
UtilError(#[from] UtilError),
#[error("Internal error receiving result from oneshot")]
RecvError(#[from] oneshot::error::RecvError),
#[error("Error writing message to peer")]
WriteError(#[from] quinn::WriteError),
#[error("Error connecting to peer")]
ConnectError(#[from] ConnectError),
#[error("Error generating preshared key")]
GeneratePresharedKeyError(#[from] bip39::Error),
}
@@ -0,0 +1,16 @@
use std::collections::HashSet;
use sd_tunnel_utils::PeerId;
/// Stores configuration which is given to the [crate::NetworkManager] at startup so it can resume from it's previous state.
#[derive(Clone)]
pub struct NetworkManagerConfig {
/// known_peers contains a list of all the peers that were connected last time the application was running.
/// These are used to know who to lookup when using the global discovery service.
pub known_peers: HashSet<PeerId>,
/// listen_port allows the user to specify which port to listen on for incoming connections.
/// By default the network manager will listen on a random free port which changes every time the application is restarted.
pub listen_port: Option<u16>,
/// TODO
pub spacetunnel_url: Option<String>,
}
@@ -0,0 +1,23 @@
use std::io;
use thiserror::Error;
/// Represents an error that occurs while initalising the [crate::NetworkManager].
#[derive(Error, Debug)]
pub enum NetworkManagerError {
// TODO: Cleanup the names of the errors
#[error("the application name your provided is invalid. Ensure it is alphanumeric!")]
InvalidAppName,
#[error("error starting the mDNS service")]
MDNSDaemon(#[from] mdns_sd::Error),
#[error("error attaching the shutdown handler")]
ShutdownHandler(#[from] ctrlc::Error),
#[error("error starting the if_watch service")]
IfWatch(io::Error),
#[error("error configuring certificates for the P2P server")]
Crypto(#[from] rustls::Error),
#[error("error starting P2P server")]
Server(io::Error),
#[error("error generating P2P identity")]
RcGen(#[from] rcgen::RcgenError),
}
@@ -0,0 +1,256 @@
use std::{
net::{IpAddr, Ipv4Addr, SocketAddrV4},
sync::Arc,
time::Duration,
};
use futures_util::StreamExt;
use if_watch::{IfEvent, IfWatcher};
use quinn::{ClientConfig, Incoming, NewConnection, VarInt};
use sd_tunnel_utils::{quic::client_config, PeerId};
use thiserror::Error;
use tokio::{select, sync::mpsc, time::sleep};
use tracing::{debug, error, warn};
use crate::{
ConnectionType, DiscoveryStack, NetworkManager, NetworkManagerError, P2PManager, Peer,
PeerCandidate,
};
/// Represents an event that should be handled by the [NetworkManager] event loop.
#[derive(Debug, Clone)]
pub(crate) enum NetworkManagerInternalEvent {
Connect(PeerCandidate),
NewKnownPeer(PeerId),
}
impl<TP2PManager: P2PManager> NetworkManager<TP2PManager> {
// this event_loop is run in a tokio task and is responsible for handling events emitted by components of the P2P library.
pub(crate) async fn event_loop(
nm: &Arc<Self>,
mut quic_incoming: Incoming,
mut internal_channel: mpsc::UnboundedReceiver<NetworkManagerInternalEvent>,
) -> Result<(), NetworkManagerError> {
debug!("Starting P2P event loop");
let mut if_watcher = IfWatcher::new()
.await
.map_err(NetworkManagerError::IfWatch)?;
let discovery = DiscoveryStack::new(nm).await?;
let (shutdown_signal_tx, mut shutdown_signal_rx) = mpsc::unbounded_channel(); // This should be able to be a oneshot but ctrlc is cringe
ctrlc::set_handler(move || {
debug!("Shutdown signal captured. Sending shutdown signal...");
match shutdown_signal_tx.send(()) {
Ok(_) => {}
Err(err) => {
error!(
"Failed to send shutdown signal. Falling back to hard shutdown. {:?}",
err
);
}
}
})?;
for iface in if_watcher.iter() {
Self::handle_ifwatch_event(nm, IfEvent::Up(*iface));
}
discovery.register().await;
debug!(
"Network adapters discovered on startup: {:?}",
nm.lan_addrs
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(",")
);
let nm = nm.clone();
tokio::spawn(async move {
loop {
// TODO: Deal with `discovery.register`'s network calls blocking the main event loop
select! {
conn = quic_incoming.next() => match conn {
Some(conn) => {
debug!("Handling incoming QUIC connection");
nm.clone().handle_connection(conn)
},
None => break,
},
event = Pin::new(&mut if_watcher) => {
match event {
Ok(event) => {
debug!("Handling ifwatch event: {:?}", event);
if Self::handle_ifwatch_event(&nm, event) {
discovery.register().await;
}
},
Err(_) => break,
}
}
_ = discovery.mdns.handle_mdns_event() => {}
_ = sleep(Duration::from_secs(15 * 60 /* 15 Minutes */)) => {
debug!("Discovery service registration timer reached");
discovery.register().await;
}
// TODO: Maybe use subscription system instead of polling or review this timeout!
_ = sleep(Duration::from_secs(60 /* 1 minute */)) => {
debug!("Discovery service pool timer reached");
discovery.global.poll().await; // TODO: this does network calls and blocks. Is this ok?
}
event = internal_channel.recv() => {
debug!("Received internal event: {:?}", event);
let event = match event {
Some(event) => event,
None => {
error!("internal_channel has been closed, stopping p2p event loop!");
break;
},
};
match event {
NetworkManagerInternalEvent::Connect(peer) => {
Self::connect_to_peer(&nm, peer).await;
}
NetworkManagerInternalEvent::NewKnownPeer(peer_id) => {
if let Some(peer) = nm.get_discovered_peer(&peer_id) {
Self::connect_to_peer(&nm, peer).await;
}
}
}
}
_ = shutdown_signal_rx.recv() => {
debug!("Event loop received shutdown signal. Shutting down...");
nm.endpoint.close(VarInt::from_u32(69 /* TODO */), b"BRUH");
discovery.shutdown();
debug!("P2P event loop shutdown");
return; // Shutdown p2p manager thread as program is exitting
}
};
}
});
Ok(())
}
fn handle_ifwatch_event(nm: &Arc<Self>, event: IfEvent) -> bool {
match event {
IfEvent::Up(iface) => {
let ip = match iface.addr() {
IpAddr::V4(ip) if ip != Ipv4Addr::LOCALHOST => ip,
_ => return false, // Currently IPv6 is not supported. Support will likely be added in the future.
};
nm.lan_addrs.insert(ip)
}
IfEvent::Down(iface) => {
let ip = match iface.addr() {
IpAddr::V4(ip) if ip != Ipv4Addr::LOCALHOST => ip,
_ => return false, // Currently IPv6 is not supported. Support will likely be added in the future.
};
nm.lan_addrs.remove(&ip).is_some()
}
}
}
async fn connect_to_peer(nm: &Arc<Self>, peer: PeerCandidate) {
tracing::debug!("Connecting to peer: {:?}", peer);
let metadata = peer.metadata.clone();
let peer_id = peer.id.clone();
if nm.is_peer_connected(&peer.id) && nm.peer_id <= peer.id {
return;
}
let NewConnection {
connection,
bi_streams,
..
} = match Self::connect_to_peer_internal(nm, peer).await {
Ok(connection) => connection,
Err(e) => {
warn!("failed to connect to peer {:?}: {:?}", peer_id, e);
return;
}
};
if nm.is_peer_connected(&peer_id) && nm.peer_id <= peer_id {
debug!(
"Closing new connection to peer '{}' as we are already connect!",
peer_id
);
connection.close(VarInt::from_u32(0), b"DUP_CONN");
return;
}
match Peer::new(
ConnectionType::Client,
peer_id,
connection,
metadata,
nm.clone(),
)
.await
{
Ok(peer) => {
tokio::spawn(peer.handler(bi_streams));
}
Err(e) => {
error!("failed to create peer: {:?}", e);
}
}
}
// TODO: Error type
pub(crate) async fn connect_to_peer_internal(
nm: &Arc<Self>,
peer: PeerCandidate,
) -> Result<NewConnection, ConnectError> {
tracing::debug!("Attempting connection to {:?}", peer);
// TODO: Guess the best default IP.
let mut i = 0;
let identity = nm.identity.clone();
let client_config =
ClientConfig::new(Arc::new(client_config(vec![identity.0], identity.1)?));
loop {
let address = match peer.addresses.get(i) {
Some(address) => address,
None => break None,
};
debug!(
"Attempting connection to peer '{}' at address {:?}",
peer.id, address
);
// TODO: Shorter timeout for connections!
let conn = match nm.endpoint.connect_with(
client_config.clone(),
SocketAddrV4::new(*address, peer.port).into(),
&peer.id.to_string(),
) {
Ok(conn) => conn,
Err(e) => {
debug!("failed to connect to addr '{:?}': {}", address, e);
i += 1;
continue;
}
};
match conn.await {
Ok(conn) => break Some(conn),
Err(e) => {
debug!("failed to connect to addr '{:?}': {}", address, e);
i += 1;
continue;
}
}
}
.ok_or(ConnectError::UnableToConnect)
}
}
#[derive(Error, Debug)]
pub enum ConnectError {
#[error("Unable to connect to peer")]
UnableToConnect,
#[error("error setting up client TLS")]
TlsError(#[from] rustls::Error),
}
+239
View File
@@ -0,0 +1,239 @@
use std::{sync::Arc, time::Duration};
use futures_util::StreamExt;
use quinn::{Connecting, NewConnection, VarInt};
use rustls::Certificate;
use sd_tunnel_utils::{read_value, write_value, PeerId};
use spake2::{Ed25519Group, Password, Spake2};
use tokio::{sync::oneshot, time::sleep};
use tracing::{debug, error, info, warn};
use crate::{
ConnectionEstablishmentPayload, ConnectionType, NetworkManager, P2PManager,
PairingParticipantType, PairingPayload, Peer,
};
impl<TP2PManager: P2PManager> NetworkManager<TP2PManager> {
/// is called when a new connection is received from the 'QUIC' server listener to handle the connection.
pub(crate) fn handle_connection(self: Arc<Self>, conn: Connecting) {
tokio::spawn(async move {
let NewConnection {
connection,
mut bi_streams,
..
} = match conn.await {
Ok(conn) => conn,
Err(err) => {
warn!("error accepting connection: {:?}", err);
return;
}
};
// let handshake_data = connection
// .handshake_data()?
// .downcast::<HandshakeData>()?;
let peer_id = match connection
.peer_identity()
.map(|v| v.downcast::<Vec<Certificate>>())
{
Some(Ok(certs)) if certs.len() == 1 => PeerId::from_cert(&certs[0]),
Some(Ok(_)) => {
warn!("client presenting an invalid number of certificates!");
return;
}
Some(Err(_)) => {
warn!("error decoding certificates from connection!");
return;
}
_ => unimplemented!(),
};
// TODO: Reenable this
// if let Some(server_name) = handshake_data.server_name {
// if server_name != peer_id.to_string() {
// println!("{} {}", server_name, peer_id.to_string()); // TODO: BRUH
// println!(
// "p2p warning: client presented a certificate and servername which don't match!"
// );
// return;
// }
// } else {
// println!(
// "p2p warning: client presented a certificate and servername which don't match!"
// );
// return;
// }
// TODO: Do this check again before adding to array because the `ConnectionEstablishmentPayload` adds delay
if self.is_peer_connected(&peer_id) && self.peer_id > peer_id {
debug!(
"Closing new connection to peer '{}' as we are already connect!",
peer_id
);
connection.close(VarInt::from_u32(0), b"DUP_CONN");
return;
}
let stream = tokio::select! {
stream = bi_streams.next() => {
match stream {
Some(stream) => stream,
None => {
warn!("connection closed before we could read from it!");
return;
}
}
}
_ = sleep(Duration::from_secs(1)) => {
warn!("Connection create connection establishment stream in expected time.");
return;
}
};
match stream {
Ok((mut tx, mut rx)) => {
let payload = match read_value(&mut rx).await {
Ok(msg) => msg,
Err(err) => {
warn!("error decoding connection establishment payload: {}", err);
return;
}
};
match payload {
ConnectionEstablishmentPayload::ConnectionRequest => {
debug!("ConnectionRequest from peer '{}'", peer_id);
// TODO: Only allow peers we trust to get pass this point
}
ConnectionEstablishmentPayload::PairingRequest {
pake_msg,
metadata,
extra_data,
} => {
debug!("PairingRequest from peer '{}'", peer_id);
// TODO: Ensure we are not already paired with the peer
let (oneshot_tx, oneshot_rx) = oneshot::channel();
self.manager.peer_pairing_request(
&self,
&peer_id,
&metadata,
&extra_data,
oneshot_tx,
);
// TODO: Have a timeout and console warning if the P2PManager doesn't respond
let preshared_key = match oneshot_rx.await {
Ok(Ok(preshared_key)) => preshared_key,
Ok(Err(err)) => {
warn!("P2PManager reported error pairing: {:?}", err);
return;
}
Err(err) => {
warn!("error receiving response for P2PManager: {:?}", err);
return;
}
};
let (spake, outgoing_pake_msg) = Spake2::<Ed25519Group>::start_b(
&Password::new(preshared_key.as_bytes()),
&spake2::Identity::new(peer_id.as_bytes()),
&spake2::Identity::new(self.peer_id.as_bytes()),
);
match spake.finish(&pake_msg) {
Ok(_) => {} // We only use SPAKE2 to ensure the current connection is to the peer we expect, hence we don't use the key which is returned.
Err(err) => {
warn!(
"error pairing with peer. Connection has been tampered with! err: {:?}",
err
);
return;
}
};
let resp = match self
.manager
.peer_paired(
&self,
PairingParticipantType::Accepter,
&peer_id,
&metadata,
&extra_data,
)
.await
{
Ok(_) => PairingPayload::PairingAccepted {
pake_msg: outgoing_pake_msg,
metadata: self.manager.get_metadata(),
},
Err(err) => {
warn!("p2p manager error: {:?}", err);
PairingPayload::PairingFailed
}
};
match write_value(&mut tx, &resp).await {
Ok(_) => {}
Err(err) => {
warn!("error encoding and sending pairing response: {}", err);
return;
}
};
let payload = match read_value(&mut rx).await {
Ok(payload) => payload,
Err(err) => {
warn!("error reading and decoding pairing payload: {}", err);
return;
}
};
match payload {
PairingPayload::PairingAccepted { .. } => {
todo!("invalid") // TODO: Remove this
}
PairingPayload::PairingComplete { .. } => {
info!("Pairing with peer '{}' complete.", peer_id);
}
PairingPayload::PairingFailed => {
error!("Pairing with peer '{}' complete.", peer_id);
// TODO
// self.manager
// .peer_paired_rollback(&self, &remote_peer_id, &extra_data)
// .await;
// TODO: emit event to frontend
return;
}
}
match Peer::new(
ConnectionType::Server,
peer_id.clone(),
connection,
metadata,
self,
)
.await
{
Ok(peer) => {
tokio::spawn(peer.handler(bi_streams));
}
Err(err) => {
error!("p2p warning: error creating peer: {:?}", err);
}
}
}
}
}
_ => {
error!("connection from peer '{}' didn't send establishment payload fast enough. Closing connection", peer_id);
}
}
});
}
}
+28
View File
@@ -0,0 +1,28 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::PeerMetadata;
/// Is sent as the first payload in each connection to establish the information and intent of the remote device.
/// This is sent by the QUIC client to the QUIC server.
#[derive(Debug, Serialize, Deserialize)]
pub enum ConnectionEstablishmentPayload {
PairingRequest {
pake_msg: Vec<u8>,
metadata: PeerMetadata,
extra_data: HashMap<String, String>,
},
ConnectionRequest, // TODO: Add `PeerMetadata` as argument to this.
}
/// PairingPayload are exchanged during the pairing process to establish a secure long term relationship.
#[derive(Debug, Serialize, Deserialize)]
pub enum PairingPayload {
PairingAccepted {
pake_msg: Vec<u8>,
metadata: PeerMetadata,
},
PairingComplete,
PairingFailed,
}
+76
View File
@@ -0,0 +1,76 @@
use std::{collections::HashMap, future::Future, pin::Pin};
use quinn::{RecvStream, SendStream};
use sd_tunnel_utils::PeerId;
use tokio::sync::oneshot;
use crate::{NetworkManager, Peer, PeerMetadata};
/// Represents the type of the peer participating in pairing. This is useful for the P2PManager application to know but is not used in the P2PManager itself.
pub enum PairingParticipantType {
// This peer initiated the pairing request
Initiator,
// This peer accepted a pairing request initiated by another device
Accepter,
}
/// Is implement by the application which is embedding this P2P library.
/// This trait allows your application which holds the users state to hook into the P2P lifecycle and make decisions from the state it holds.
#[allow(unused_variables)]
pub trait P2PManager: Clone + Send + Sync + Sized + 'static {
const APPLICATION_NAME: &'static str;
/// Called to get the metadata of the application. This metadata is sent as part of the discovery payload.
fn get_metadata(&self) -> PeerMetadata;
/// Called when a peer is discovered using any of the available discovery mechanisms .
fn peer_discovered(&self, nm: &NetworkManager<Self>, peer_id: &PeerId) {}
/// Called when a peer that had previously been discovered is now unavailable.
/// This could be due to the peer announcing it is going offline or due to a timeout.
fn peer_expired(&self, nm: &NetworkManager<Self>, peer_id: PeerId) {}
/// Called when a connection is established with a peer.
/// This will happen after pairing or if a peer that is in the [NetworkManager]'s `known_peers` list is discovered.
fn peer_connected(&self, nm: &NetworkManager<Self>, peer_id: PeerId) {}
/// Called when a connection to a peer is disconnected.
/// This could occur due to the remote peer announcing it is going offline, or the device not responding to network activity for a certain timeout.
fn peer_disconnected(&self, nm: &NetworkManager<Self>, peer_id: PeerId) {}
/// Called when a peer request to pair with you. The application should accept or reject the pairing request by returning the preshared_key enter by the user through the `password_resp` oneshot channel.
/// The application MUST respond to the channel regardless of result.
fn peer_pairing_request(
&self,
nm: &NetworkManager<Self>,
peer_id: &PeerId,
metadata: &PeerMetadata,
extra_data: &HashMap<String, String>,
password_resp: oneshot::Sender<Result<String, ()>>,
) {
}
/// Called when a peer has been paired with you. This function will block the pairing process until it is complete.
/// Pairing MAY fail after this function is completed due to the nature of having to run it on both machines. It is expected any changes will be reverted in `peer_paired_rollback`.
fn peer_paired<'a>(
&'a self,
nm: &'a NetworkManager<Self>,
direction: PairingParticipantType,
peer_id: &'a PeerId,
peer_metadata: &'a PeerMetadata,
extra_data: &'a HashMap<String, String>,
) -> Pin<Box<dyn Future<Output = Result<(), ()>> + Send + 'a>>;
/// Called when pairing failed but `peer_paired` was called. This function will undo any changes that may of been made by `peer_paired`.
fn peer_paired_rollback<'a>(
&'a self,
nm: &'a NetworkManager<Self>,
direction: PairingParticipantType,
peer_id: &'a PeerId,
peer_metadata: &'a PeerMetadata,
extra_data: &'a HashMap<String, String>,
) -> Pin<Box<dyn Future<Output = ()> + Send + Sync + 'a>>;
/// Called when a network stream is created. This will contain your application code to communicate with the remote device.
fn accept_stream(&self, peer: &Peer<Self>, stream: (SendStream, RecvStream)) {}
}
+8
View File
@@ -0,0 +1,8 @@
#[allow(clippy::module_inception)]
mod peer;
mod peer_candidate;
mod peer_metadata;
pub use peer::*;
pub use peer_candidate::*;
pub use peer_metadata::*;
+106
View File
@@ -0,0 +1,106 @@
use std::{
fmt::{self, Formatter},
sync::Arc,
};
use futures_util::StreamExt;
use quinn::{ApplicationClose, Connection, IncomingBiStreams};
use sd_tunnel_utils::PeerId;
use tracing::{debug, error};
use crate::{NetworkManager, P2PManager, PeerMetadata};
/// This emum represents the type of the connection to the current peer.
/// QUIC is a client/server protocol so when doing P2P communication one client will be the server and one will be the client from a QUIC perspective.
/// The protocol is bi-directional so this doesn't matter a huge amount and the P2P library does it's best to hide this detail from the embedding application as thinking about this can be very confusing.
/// The decision for who is the client and server should be treated as arbitrary and shouldn't affect how the protocol operates.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionType {
/// I am the QUIC server.
Server,
/// I am the QUIC client.
Client,
}
/// Represents a currently connected peer. This struct holds the connection as well as any information the network manager may required about the remote peer.
/// It also stores a reference to the network manager for communication back to the [P2PManager].
/// The [Peer] acts as an abstraction above the QUIC connection which could be a client or server so that when building code we don't have to think about the technicalities of the connection.
#[derive(Clone)]
pub struct Peer<TP2PManager: P2PManager> {
/// peer_id holds the id of the remote peer. This is their unique identifier.
pub id: PeerId,
/// conn_type holds the type of connection that is being established.
pub conn_type: ConnectionType,
/// metadata holds the metadata of the remote peer. This includes information such as their display name and version.
pub metadata: PeerMetadata,
/// conn holds the quinn::Connection that is being used to communicate with the remote peer. This allows creating new streams.
pub(crate) conn: Connection,
/// nm is a reference to the network manager. This is used to send messages back to the P2PManager.
nm: Arc<NetworkManager<TP2PManager>>,
}
impl<TP2PManager: P2PManager> fmt::Debug for Peer<TP2PManager> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Peer")
.field("id", &self.id)
.field("conn_type", &self.conn_type)
.field("metadata", &self.metadata)
.finish()
}
}
impl<TP2PManager: P2PManager> Peer<TP2PManager> {
/// create a new peer from a [quinn::Connection].
pub(crate) async fn new(
conn_type: ConnectionType,
id: PeerId,
conn: Connection,
metadata: PeerMetadata,
nm: Arc<NetworkManager<TP2PManager>>,
) -> Result<Self, ()> {
Ok(Self {
id,
conn_type,
metadata,
conn,
nm,
})
}
/// handler is run in a separate thread for each peer connection and is responsible for keep the connection alive and handling incoming streams.
pub(crate) async fn handler(self, mut bi_streams: IncomingBiStreams) {
debug!(
"Started handler thread for connection with remote peer '{}'",
self.id
);
self.nm.add_connected_peer(self.clone());
while let Some(stream) = bi_streams.next().await {
match stream {
Err(quinn::ConnectionError::ApplicationClosed(ApplicationClose {
reason, ..
})) => {
debug!("Connection with peer closed due to '{:?}'", reason);
// TODO: This is hacky, fix!
if reason != "DUP_CONN" {
self.nm.remove_connected_peer(self.id);
}
break;
}
Err(err) => {
error!(
"Connection error when communicating with peer '{:?}': {:?}",
self.id, err
);
self.nm.remove_connected_peer(self.id);
break;
}
Ok(stream) => {
debug!("Accepting stream from peer '{:?}'", self.id);
self.nm.manager.accept_stream(&self, stream);
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
use std::net::Ipv4Addr;
use sd_tunnel_utils::PeerId;
use serde::{Deserialize, Serialize};
use crate::PeerMetadata;
/// Represents a peer that has been discovered but not paired with.
/// It is called a candidate as it contains all of the information required to connection and pair with the peer.
/// A peer candidate discovered through mDNS may have been modified by an attacker on your local network but this is deemed acceptable as the attacker can only modify primitive metadata such a name or Spacedrive version which is used for pairing.
/// When we initiated communication with the device we will ensure we are talking to the correct device using PAKE (specially SPAKE2) for pairing and verifying the TLS certificate for general communication.
#[derive(Debug, Clone, Serialize, Deserialize)] // TODO: Type
pub struct PeerCandidate {
pub id: PeerId,
pub metadata: PeerMetadata,
pub addresses: Vec<Ipv4Addr>,
pub port: u16,
}
+94
View File
@@ -0,0 +1,94 @@
use std::{collections::HashMap, env, str::FromStr};
use sd_tunnel_utils::PeerId;
use serde::{Deserialize, Serialize};
use specta::Type;
/// Represents the operating system which the remote peer is running.
/// This is not used internally and predominantly is designed to be used for display purposes by the embedding application.
#[derive(Debug, Clone, Type, Serialize, Deserialize)]
pub enum OperationSystem {
Windows,
Linux,
MacOS,
IOS,
Android,
Other(String),
}
impl OperationSystem {
pub fn get_os() -> Self {
match env::consts::OS {
"windows" => OperationSystem::Windows,
"macos" => OperationSystem::MacOS,
"linux" => OperationSystem::Linux,
"ios" => OperationSystem::IOS,
"android" => OperationSystem::Android,
platform => OperationSystem::Other(platform.into()),
}
}
}
impl From<OperationSystem> for String {
fn from(os: OperationSystem) -> Self {
match os {
OperationSystem::Windows => "Windows".into(),
OperationSystem::Linux => "Linux".into(),
OperationSystem::MacOS => "MacOS".into(),
OperationSystem::IOS => "IOS".into(),
OperationSystem::Android => "Android".into(),
OperationSystem::Other(s) => {
let mut chars = s.chars();
chars.next();
chars.as_str().to_string()
}
}
}
}
impl FromStr for OperationSystem {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut chars = s.chars();
match chars.next() {
Some('w') => Ok(OperationSystem::Windows),
Some('l') => Ok(OperationSystem::Linux),
Some('m') => Ok(OperationSystem::MacOS),
Some('i') => Ok(OperationSystem::IOS),
Some('a') => Ok(OperationSystem::Android),
_ => Ok(OperationSystem::Other(chars.as_str().to_string())),
}
}
}
/// Represents public metadata about a peer. This is designed to hold information which is required among all applications using the P2P library.
/// This metadata is discovered through the discovery process or sent by the connecting device when establishing a new P2P connection.
#[derive(Debug, Clone, Type, Serialize, Deserialize)]
pub struct PeerMetadata {
pub name: String,
pub operating_system: Option<OperationSystem>,
pub version: Option<String>,
}
impl PeerMetadata {
pub fn from_hashmap(peer_id: &PeerId, hashmap: &HashMap<String, String>) -> Self {
Self {
name: hashmap
.get("name")
.map(|v| v.to_string())
.unwrap_or_else(|| peer_id.to_string()),
operating_system: hashmap.get("os").map(|v| v.parse().ok()).unwrap_or(None),
version: hashmap.get("version").map(|v| v.to_string()),
}
}
pub fn to_hashmap(self) -> HashMap<String, String> {
let mut hashmap = HashMap::new();
hashmap.insert("name".to_string(), self.name);
if let Some(version) = self.version {
hashmap.insert("version".to_string(), version);
}
hashmap
}
}
+47
View File
@@ -0,0 +1,47 @@
use std::net::Ipv4Addr;
use rcgen::{CertificateParams, DistinguishedName, DnType, RcgenError, SanType};
/// The common name of the identity certificate generated by sd-p2p.
const CERTIFICATE_COMMON_NAME: &str = "sd-p2p-identity";
/// Is the identity which respresents the current peer. An Identity is made from a public key and a private key combo. [crate::PeerId]'s are derived from the public key portion of a peer's [Identity].
/// The public key is safe to share while the private key must remain private to ensure the connections between peers are secure.
#[derive(Clone)]
pub struct Identity {
cert: Vec<u8>,
key: Vec<u8>,
}
impl Identity {
/// Create a new Identity for the current peer.
pub fn new() -> Result<Self, RcgenError> {
let mut params: CertificateParams = Default::default();
params.distinguished_name = DistinguishedName::new();
params
.distinguished_name
.push(DnType::CommonName, CERTIFICATE_COMMON_NAME);
params.subject_alt_names = vec![SanType::IpAddress(Ipv4Addr::LOCALHOST.into())];
let cert = rcgen::Certificate::from_params(params)?;
Ok(Self {
cert: cert.serialize_der()?,
key: cert.serialize_private_key_der(),
})
}
/// Load the current identity from it's raw form.
pub fn from_raw(cert: Vec<u8>, key: Vec<u8>) -> Result<Self, RcgenError> {
Ok(Self { cert, key })
}
/// Convert this identity into it's raw form so it can be saved.
pub fn to_raw(&self) -> (Vec<u8>, Vec<u8>) {
(self.cert.clone(), self.key.clone())
}
/// Convert this identity into rustls compatible form so it can be used for the QUIC TLS handshake.
pub fn into_rustls(self) -> (rustls::Certificate, rustls::PrivateKey) {
(rustls::Certificate(self.cert), rustls::PrivateKey(self.key))
}
}
+3
View File
@@ -0,0 +1,3 @@
mod identity;
pub use identity::*;
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "sd-tunnel"
version = "0.1.0"
edition = "2021"
default-run = "sd-tunnel"
[dependencies]
sd-tunnel-utils = { path = "./utils" }
base64 = "0.13.0"
dotenv = "0.15.0"
futures = "0.3.21"
quinn = "0.8.3"
rcgen = "0.9.2"
rustls = "0.20.6"
serde = { version = "1.0.137", features = ["derive"] }
tokio = { version = "1.19.2", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.35"
tracing-subscriber = { version = "0.3.11", features = ["env-filter"] }
metrics = "0.19.0"
metrics-exporter-prometheus = { version = "0.10.0", features = ["http-listener"] }
thiserror = "1.0.31"
rmp-serde = "1.1.0"
bb8-redis = "0.11.0"
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1.3-labs
FROM rust:latest as build
# Set working directory for build container
WORKDIR /app
# Cache the Rust build between Docker builds
RUN --mount=type=cache,target=/usr/local/cargo/registry
RUN --mount=type=cache,target=/app/target
# Copy prerequisites and install dependencies
# TODO: Make this work - it's difficult cause the Cargo.lock is outside the Docker build context
# RUN mkdir src && echo "fn main(){}" > src/main.rs
# COPY Cargo.toml ../../Cargo.lock /app/
# RUN mkdir .cargo && cargo vendor > .cargo/config
# Copy in code and build it
COPY . /app
RUN cargo build --release
# Create minimal non-root production container
FROM gcr.io/distroless/cc:nonroot
# Expose ports
EXPOSE 9000
# Copy in binary and set it as startup command
COPY --from=build /app/target/release/tunnel /
CMD ["/tunnel"]
+12
View File
@@ -0,0 +1,12 @@
**Warning: Deploying this application is currently at your own risk! It hasn't been battle tested and support will not be provided!**
# Spacetunnel
TODO: Write some docs
## Usage
```bash
cargo run -p tunnel --bin generate-env
cargo run -p tunnel
```
+21
View File
@@ -0,0 +1,21 @@
app = "sd-tunnel"
[env]
SD_PORT = 9000
SD_BIND_ADDR = "fly-global-services"
[metrics]
port = 9000
path = "/metrics"
[experimental]
allowed_public_ports = []
auto_rollback = true
[[services]]
protocol = "udp"
internal_port = 9000
[[services.ports]]
handlers = []
port = 443
+33
View File
@@ -0,0 +1,33 @@
use std::{fs, path::Path};
use base64::encode;
use rcgen::generate_simple_self_signed;
fn main() {
println!("Issuing sdtunnel certificate...");
let env_file = Path::new("./.env");
if env_file.exists() {
println!("File '{}' already exists. Exiting...", env_file.display());
return;
}
// TODO: Replace 'generate_simple_self_signed' with full code so we have full control over generated certificate.
let cert = generate_simple_self_signed(vec!["sdtunnel.spacedrive.com".into()]).unwrap();
match fs::write(
env_file,
format!(
r#"SD_ROOT_CERTIFICATE={}
SD_ROOT_CERTIFICATE_KEY={}
SD_REDIS_URL=redis://127.0.0.1/"#,
encode(cert.serialize_der().unwrap()),
encode(cert.serialize_private_key_der())
),
) {
Ok(_) => {}
Err(err) => println!("Error writing to '{}': {}", env_file.display(), err),
}
println!("Generate keypair!");
}
+300
View File
@@ -0,0 +1,300 @@
use base64::decode;
use bb8_redis::{
bb8::Pool,
redis::{cmd, RedisError},
RedisConnectionManager,
};
use dotenv::dotenv;
use futures::StreamExt;
use metrics::increment_counter;
use metrics_exporter_prometheus::PrometheusBuilder;
use quinn::{ApplicationClose, Endpoint, ServerConfig};
use rustls::Certificate;
use sd_tunnel_utils::{
quic, ClientAnnouncementResponse, Message, MessageError, PeerId, MAX_MESSAGE_SIZE,
};
use std::{
collections::HashMap,
env,
net::{Ipv4Addr, ToSocketAddrs},
sync::Arc,
};
use thiserror::Error;
use tracing::{debug, error, info};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[tokio::main]
async fn main() {
dotenv().ok();
tracing_subscriber::registry()
.with(fmt::layer())
.with(
EnvFilter::from_default_env()
.add_directive("info".parse().expect("Error invalid tracing directive!"))
.add_directive(
"tunnel=debug"
.parse()
.expect("Error invalid tracing directive!"),
),
)
.init();
let certificate = match env::var("SD_ROOT_CERTIFICATE") {
Ok(certificate) => {
rustls::Certificate(decode(certificate).expect("Error decoding 'SD_ROOT_CERTIFICATE'"))
}
Err(_) => {
error!("Error: 'SD_ROOT_CERTIFICATE' env var is not set!");
return;
}
};
let priv_key = match env::var("SD_ROOT_CERTIFICATE_KEY") {
Ok(key) => {
rustls::PrivateKey(decode(key).expect("Error decoding 'SD_ROOT_CERTIFICATE_KEY'"))
}
Err(_) => {
error!("Error: 'SD_ROOT_CERTIFICATE_KEY' env var is not set!");
return;
}
};
let redis_url = match env::var("SD_REDIS_URL") {
Ok(redis_url) => redis_url,
Err(_) => {
error!("Error: 'SD_REDIS_URL' env var is not set!");
return;
}
};
let server_port = env::var("SD_PORT")
.map(|port| port.parse::<u16>().unwrap_or(9000))
.unwrap_or(9000);
let bind_addr = env::var("SD_BIND_ADDR").unwrap_or(Ipv4Addr::UNSPECIFIED.to_string());
let manager =
RedisConnectionManager::new(redis_url).expect("Error creating Redis connection manager!");
let redis_pool = Pool::builder()
.build(manager)
.await
.expect("Error creating Redis pool!");
let builder = PrometheusBuilder::new();
builder
.install()
.expect("failed to install recorder/exporter");
let addr = format!("{}:{}", bind_addr, server_port)
.to_socket_addrs()
.expect("Error looking up bind address")
.into_iter()
.next()
.expect("Error no bind addresses were found");
let server_config = ServerConfig::with_crypto(Arc::new(
quic::server_config(vec![certificate], priv_key)
.expect("Error initialising 'ServerConfig'!"),
));
let (endpoint, mut incoming) =
Endpoint::server(server_config, addr).expect("Error creating endpoint!");
info!(
"Listening on {}",
endpoint.local_addr().expect("Error passing local address!")
);
while let Some(conn) = incoming.next().await {
let remote_addr = conn.remote_address();
debug!("accepted connection from '{}'", remote_addr);
increment_counter!("spacetunnel_connections_accepted");
let fut = handle_connection(redis_pool.clone(), conn);
tokio::spawn(async move {
if let Err(e) = fut.await {
error!(
"'handle_connection' from remote '{}' threw error: {}",
remote_addr,
e.to_string()
);
increment_counter!("spacetunnel_connections_errored");
} else {
debug!("closed connection from '{}'", remote_addr);
}
});
}
}
async fn handle_connection(
redis_pool: Pool<RedisConnectionManager>,
conn: quinn::Connecting,
) -> Result<(), ConnectionError> {
let quinn::NewConnection {
connection,
mut bi_streams,
..
} = conn.await?;
let peer_id = match connection
.peer_identity()
.unwrap()
.downcast::<Vec<Certificate>>()
{
Ok(certs) if certs.len() == 1 => PeerId::from_cert(&certs[0]),
Ok(_) => {
error!("Error: peer has multiple client certificates!");
increment_counter!("spacetunnel_connections_invalid");
return Ok(());
}
Err(_) => {
error!("Error: peer did not provide a client certificates!");
increment_counter!("spacetunnel_connections_invalid");
return Ok(());
}
};
info!(
"established connection with peer '{}' from addr '{}'",
peer_id,
connection.remote_address()
);
// TODO: Ensure connections are closed automatically after an inactivity timeout
// TODO: Ensure streams are closed automatically after an inactivity timeout
let peer_id = &peer_id;
while let Some(stream) = bi_streams.next().await {
let stream = match stream {
Err(quinn::ConnectionError::ApplicationClosed(ApplicationClose {
error_code,
reason,
})) => {
debug!(
"closed connection with peer '{}' with error_code '{}' and reason '{:?}' ",
peer_id, error_code, reason
);
return Ok(());
}
Err(e) => return Err(e.into()),
Ok(s) => s,
};
debug!("accepted stream from peer '{}'", peer_id);
increment_counter!("spacetunnel_streams_accepted");
let peer_id = peer_id.clone();
let redis_pool = redis_pool.clone();
tokio::spawn(async move {
let (mut tx, mut rx) = stream;
let fut = handle_stream(redis_pool, &peer_id, (&mut tx, &mut rx));
if let Err(err) = fut.await {
error!("'handle_stream' threw error: {}", err.to_string());
if matches!(err, ConnectionError::RedisErr(_)) {
increment_counter!("spacetunnel_redis_error", "error_src" => "handle_stream");
} else {
increment_counter!("spacetunnel_stream_errored");
}
match Message::Error(MessageError::InternalServerErr).encode() {
Ok(msg) => {
let _ = tx.write_all(&msg).await;
}
Err(e) => {
error!("Error encoding error error message: {}", e.to_string());
increment_counter!("spacetunnel_stream_errored");
}
}
} else {
debug!("closed stream from peer '{}'", peer_id);
}
});
}
Ok(())
}
async fn handle_stream(
redis_pool: Pool<RedisConnectionManager>,
authenticated_peer_id: &PeerId,
(send, recv): (&mut quinn::SendStream, &mut quinn::RecvStream),
) -> Result<(), ConnectionError> {
let mut redis = match redis_pool.get().await {
Ok(conn) => conn,
Err(err) => {
error!("Error getting Redis connection: {}", err);
increment_counter!("spacetunnel_redis_error", "error_src" => "get");
return Ok(());
}
};
while let Some(chunk) = recv.read_chunk(MAX_MESSAGE_SIZE, true).await? {
let mut bytes: &[u8] = &chunk.bytes;
let msg = match Message::read(&mut bytes)? {
Message::ClientAnnouncement { peer_id, addresses } => {
if authenticated_peer_id != peer_id {
Message::Error(MessageError::InvalidAuthErr)
} else {
increment_counter!("spacetunnel_discovery_announcements");
let redis_key = format!("peer:announcement:{}", peer_id.to_string());
cmd("HSET")
.arg(&redis_key)
.arg("addresses")
.arg(addresses.join(","))
.query_async(&mut *redis)
.await?;
cmd("EXPIRE")
.arg(&redis_key)
.arg(60 * 60u32 /* 1 Hour in seconds */)
.query_async(&mut *redis)
.await?;
Message::ClientAnnouncementOk
}
}
Message::QueryClientAnnouncement(peer_ids) => {
increment_counter!("spacetunnel_discovery_announcement_queries");
// TODO: Rate limit number queries that can come from each each IP
// TODO: Check if peer is authorised to query this announcement. Syncthing don't do an auth check so for now it's fine being unauthorised.
if peer_ids.len() > 15 {
error!(
"Client requested too many client announcements '{}'",
peer_ids.len()
);
increment_counter!("spacetunnel_discovery_announcement_queries_invalid");
Message::Error(MessageError::InvalidReqErr)
} else {
let mut peers = Vec::with_capacity(peer_ids.len());
for peer_id in peer_ids.iter() {
let redis_key = format!("peer:announcement:{}", peer_id.to_string());
let resp: HashMap<String, String> = cmd("HGETALL")
.arg(&redis_key)
.query_async(&mut *redis)
.await?;
peers.push(ClientAnnouncementResponse {
peer_id: peer_id.clone(),
addresses: resp
.get("addresses")
.unwrap_or(&"".to_string())
.split(",")
.map(|v| v.to_string())
.collect(),
})
}
Message::QueryClientAnnouncementResponse(peers)
}
}
Message::ClientAnnouncementOk
| Message::QueryClientAnnouncementResponse { .. }
| Message::Error(_) => Message::Error(MessageError::InvalidReqErr),
};
send.write_all(&msg.encode()?).await?;
}
Ok(())
}
#[derive(Error, Debug)]
pub enum ConnectionError {
#[error("connection error: {0}")]
ConnectionErr(#[from] quinn::ConnectionError),
#[error("redis error: {0}")]
RedisErr(#[from] RedisError),
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "sd-tunnel-utils"
version = "0.1.0"
edition = "2021"
[dependencies]
quinn = "0.8.3"
ring = "0.16.20"
rmp = "0.8.11"
rmp-serde = "1.1.0"
rustls = { version = "0.20.6", default-features = false, features = ["quic", "dangerous_configuration"] } # 'dangerous_configuration' is required to implement custom certificate verifiers which we use due to the self-signed nature of the protocol.
serde = { version = "1.0.137", features = ["derive"] }
specta = "0.0.2"
thiserror = "1.0.31"
ts-rs = "6.2.0"
+80
View File
@@ -0,0 +1,80 @@
use std::{io, net::ToSocketAddrs, sync::Arc};
use quinn::{ClientConfig, Endpoint, NewConnection};
use rustls::{Certificate, PrivateKey};
use thiserror::Error;
use crate::{
quic::client_config,
rmp_quic::{read_value, UtilError},
write_value, Message,
};
/// represents an error which can be thrown by the client.
#[derive(Error, Debug)]
pub enum ClientError {
#[error("no valid Spacetunnel addresses were provided")]
MissingServerAddr,
#[error("error resolving DNS for Spacetunnel address")]
IoError(#[from] io::Error),
#[error("error setting up client TLS")]
TlsError(#[from] rustls::Error),
#[error("error connecting to Spacetunnel")]
ConnectError(#[from] quinn::ConnectError),
#[error("error communicating with Spacetunnel")]
ConnectionError(#[from] quinn::ConnectionError),
#[error("error writing message to Spacetunnel")]
UtilError(#[from] UtilError),
#[error("error writing message to Spacetunnel connection")]
WriteError(#[from] quinn::WriteError),
}
/// holds a connection to the Spacetunnel server and can be used to send messages to the server.
pub struct Client {
backend_url: String,
endpoint: Endpoint,
identity: (Certificate, PrivateKey),
}
impl Client {
pub fn new(
backend_url: String,
endpoint: Endpoint,
identity: (Certificate, PrivateKey),
) -> Self {
Self {
backend_url,
endpoint,
identity,
}
}
/// sends a message to the Spacetunnel server and awaits a response.
pub async fn send_message(&self, msg: Message) -> Result<Message, ClientError> {
let identity = self.identity.clone();
let NewConnection { connection, .. } = self
.endpoint
.connect_with(
ClientConfig::new(Arc::new(client_config(
vec![identity.0],
identity.1.clone(),
)?)),
self.backend_url
.to_socket_addrs()? // TODO: Make this only lookup IPv4 -> Filter IPV6's
.into_iter()
.next()
.ok_or(ClientError::MissingServerAddr)?,
"todo",
)?
.await?;
let (mut tx, mut rx) = connection.open_bi().await?;
write_value(&mut tx, &msg).await?;
let msg: Message = read_value(&mut rx).await?;
// tx.finish().await?;
// connection.close(VarInt::from_u32(0), b"DUP_CONN");
Ok(msg)
}
}
+10
View File
@@ -0,0 +1,10 @@
mod client;
mod peer_id;
mod proto;
pub mod quic;
mod rmp_quic;
pub use client::*;
pub use peer_id::*;
pub use proto::*;
pub use rmp_quic::*;
+64
View File
@@ -0,0 +1,64 @@
use std::{fmt, ops::Deref};
use ring::digest::digest;
use rustls::Certificate;
use serde::{Deserialize, Serialize};
use specta::Type;
use thiserror::Error;
/// is a unique identifier for a peer. These are derived from the public key of the peer.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Serialize, Deserialize, Type)]
pub struct PeerId(String);
impl PeerId {
/// from_str attempts to load a PeerId from a string. It will return an error if the PeerId is invalid.
pub fn from_string(id: String) -> Result<Self, PeerIdError> {
if id.len() != 40 {
return Err(PeerIdError::InvalidLength);
} else if !id.chars().all(char::is_alphanumeric) {
return Err(PeerIdError::InvalidCharacters);
}
Ok(Self(id))
}
/// from_cert will derive a [PeerId] from a [rustls::Certificate].
pub fn from_cert(cert: &Certificate) -> Self {
// SHA-1 is used due to the limitation of the length of a DNS record used for mDNS local network discovery.
let peer_id = digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, &cert.0)
.as_ref()
.iter()
.map(|b| format!("{:02x}", b))
.collect();
Self(peer_id)
}
}
impl PartialEq<PeerId> for &PeerId {
fn eq(&self, other: &PeerId) -> bool {
self.0 == other.0
}
}
impl fmt::Display for PeerId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Deref for PeerId {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// Represents an error that can occur when creating a [PeerId] from a string.
#[derive(Error, Debug)]
pub enum PeerIdError {
#[error("the PeerId must be 40 chars in length")]
InvalidLength,
#[error("the PeerId must be alphanumeric")]
InvalidCharacters,
}
+34
View File
@@ -0,0 +1,34 @@
use serde::{Deserialize, Serialize};
use crate::PeerId;
/// MessageError is an error that occurs when a message is malformed.
/// NEVER REMOVE OR REORDER VARIANTS OF THIS ENUM OR YOU WILL BREAK STUFF DUE TO SUBOPTIMAL MSGPACK ENCODING.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum MessageError {
InvalidAuthErr,
InvalidReqErr,
InternalServerErr,
}
/// ClientAnnouncementResponse is returned by the server when a client queries for an announcement.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClientAnnouncementResponse {
pub peer_id: PeerId,
pub addresses: Vec<String>,
}
/// Message is a single request that is sent between a client and the Spacetunnel server.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Message {
// Announce your current device addresses
ClientAnnouncement {
peer_id: PeerId,
addresses: Vec<String>,
},
ClientAnnouncementOk,
// Query for an existing client announcement
QueryClientAnnouncement(Vec<PeerId>),
QueryClientAnnouncementResponse(Vec<ClientAnnouncementResponse>),
Error(MessageError),
}
+101
View File
@@ -0,0 +1,101 @@
use std::{sync::Arc, time::SystemTime};
use rustls::{
client::{ServerCertVerified, ServerCertVerifier},
server::{ClientCertVerified, ClientCertVerifier},
Certificate, DistinguishedNames, Error, ServerName,
};
/// The Application-Layer Protocol Negotiation (ALPN) value for QUIC.
const ALPN_QUIC_HTTP: &[&[u8]] = &[b"hq-29"];
/// server_config will return a rustls::ServerConfig for a QUIC server. Ensures this matches the client config below!
pub fn server_config(
cert_chain: Vec<rustls::Certificate>,
key: rustls::PrivateKey,
) -> Result<rustls::ServerConfig, Error> {
let mut cfg = rustls::ServerConfig::builder()
.with_safe_default_cipher_suites()
.with_safe_default_kx_groups()
.with_protocol_versions(&[&rustls::version::TLS13])?
.with_client_cert_verifier(AllowAllClientCertificateVerifier::dangerously_new())
.with_single_cert(cert_chain, key)?;
cfg.max_early_data_size = u32::MAX;
cfg.alpn_protocols = ALPN_QUIC_HTTP.iter().map(|&x| x.into()).collect();
Ok(cfg)
}
/// client_config will return a rustls::ClientConfig for a QUIC client. Ensures this matches the server config above!
pub fn client_config(
cert_chain: Vec<rustls::Certificate>,
key: rustls::PrivateKey,
) -> Result<rustls::ClientConfig, Error> {
let mut cfg = rustls::ClientConfig::builder()
.with_safe_default_cipher_suites()
.with_safe_default_kx_groups()
.with_protocol_versions(&[&rustls::version::TLS13])?
// .with_root_certificates(root_store) // TODO: Do this
.with_custom_certificate_verifier(ServerCertificateVerifier::dangerously_new()) // TODO: Remove this and use chain instead
.with_single_cert(cert_chain, key)?;
cfg.alpn_protocols = ALPN_QUIC_HTTP.iter().map(|&x| x.into()).collect();
Ok(cfg)
}
/// ServerCertificateVerifier is a custom certificate verifier that is responsible for verifying the server certificate when making a QUIC connection.
pub(crate) struct ServerCertificateVerifier; // TODO: Private this
impl ServerCertificateVerifier {
// TODO: Private this
pub(crate) fn dangerously_new() -> Arc<Self> {
Arc::new(Self)
}
}
impl ServerCertVerifier for ServerCertificateVerifier {
fn verify_server_cert(
&self,
_end_entity: &Certificate,
_intermediates: &[Certificate],
_server_name: &ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8],
_now: std::time::SystemTime,
) -> Result<ServerCertVerified, rustls::Error> {
// TODO: Verify certificate expiry
// TODO: Verify certificate algorithms match
Ok(ServerCertVerified::assertion())
}
}
/// ClientCertificateVerifier is a custom certificate verifier that is responsible for verifying the client certificate when making a QUIC connection.
struct AllowAllClientCertificateVerifier;
impl AllowAllClientCertificateVerifier {
fn dangerously_new() -> Arc<dyn ClientCertVerifier> {
Arc::new(Self {})
}
}
impl ClientCertVerifier for AllowAllClientCertificateVerifier {
fn offer_client_auth(&self) -> bool {
true
}
fn client_auth_root_subjects(&self) -> Option<DistinguishedNames> {
Some(vec![])
}
fn verify_client_cert(
&self,
_end_entity: &Certificate,
_intermediates: &[Certificate],
_now: SystemTime,
) -> Result<ClientCertVerified, Error> {
// TODO: Verify certificate expiry
// TODO: Verify certificate algorithms match
// We accept any client with a valid certificate because any valid certificate will have a valid PeerId. It's ok to accept all connections cause this is the public service.
Ok(ClientCertVerified::assertion())
}
}
+43
View File
@@ -0,0 +1,43 @@
use quinn::{RecvStream, SendStream};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
/// MAX_MESSAGE_SIZE is the maximum size of a single message.
pub const MAX_MESSAGE_SIZE: usize = 64 * 1024;
#[derive(Error, Debug)]
pub enum UtilError {
#[error("error reading from stream as it was closed")]
StreamClosed,
#[error("error writing message")]
WriteError(#[from] quinn::WriteError),
#[error("error reading data")]
ReadError(#[from] quinn::ReadError),
#[error("error decoding message")]
DecodeError(#[from] rmp_serde::decode::Error),
#[error("error encoding message")]
EncodeError(#[from] rmp_serde::encode::Error),
}
// write_value is a helper to write a Serde struct to a [quin::SendStream].
pub async fn write_value<T>(tx: &mut SendStream, value: &T) -> Result<(), UtilError>
where
T: Serialize + Unpin + ?Sized,
{
let data = rmp_serde::encode::to_vec_named(value)?;
// rmp_serde doesn't support `AsyncWrite` so we have to allocate buffer here.
tx.write_all(&data).await?;
Ok(())
}
// read_value is a helper to read a Serde struct from a [quin::RecvStream].
pub async fn read_value<T>(rx: &mut RecvStream) -> Result<T, UtilError>
where
T: DeserializeOwned + ?Sized,
{
let data = rx
.read_chunk(MAX_MESSAGE_SIZE, true)
.await?
.ok_or(UtilError::StreamClosed)?;
Ok(rmp_serde::decode::from_read(&data.bytes[..])?)
}
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rspc = { version = "0.1.2", features = ["uuid"] }
rspc = { workspace = true, features = ["uuid"] }
serde = "1.0.145"
serde_json = "1.0.85"
uhlc = "0.5.1"
-1
View File
@@ -1,4 +1,3 @@
# crdt-rs
Just @brendonovich experimenting with CRDT stuff.
+31 -37
View File
@@ -1,5 +1,3 @@
```rust
pub fn update_with_timestamp(&self, timestamp: &Timestamp) -> Result<(), String> {
let mut now = (self.clock)();
@@ -31,49 +29,45 @@ pub fn update_with_timestamp(&self, timestamp: &Timestamp) -> Result<(), String>
```
```javascript
Timestamp.recv = function(msg) {
if (!clock) {
return null;
}
Timestamp.recv = function (msg) {
if (!clock) {
return null;
}
var now = Date.now();
var now = Date.now();
var msg_time = msg.millis();
var msg_time = msg.counter();
var msg_time = msg.millis();
var msg_time = msg.counter();
if (msg_time - now > config.maxDrift) {
throw new Timestamp.ClockDriftError();
}
if (msg_time - now > config.maxDrift) {
throw new Timestamp.ClockDriftError();
}
var last_time = clock.timestamp.millis();
var last_time = clock.timestamp.counter();
var last_time = clock.timestamp.millis();
var last_time = clock.timestamp.counter();
var max_time = Math.max(Math.max(last_time, now), msg_time);
var max_time = Math.max(Math.max(last_time, now), msg_time);
var last_time =
max_time === last_time && lNew === msg_time
? Math.max(last_time, msg_time) + 1
: max_time === last_time
? last_time + 1
: max_time === msg_time
? msg_time + 1
: 0;
var last_time =
max_time === last_time && lNew === msg_time
? Math.max(last_time, msg_time) + 1
: max_time === last_time
? last_time + 1
: max_time === msg_time
? msg_time + 1
: 0;
// 3.
if (max_time - phys > config.maxDrift) {
throw new Timestamp.ClockDriftError();
}
if (last_time > MAX_COUNTER) {
throw new Timestamp.OverflowError();
}
// 3.
if (max_time - phys > config.maxDrift) {
throw new Timestamp.ClockDriftError();
}
if (last_time > MAX_COUNTER) {
throw new Timestamp.OverflowError();
}
clock.timestamp.setMillis(max_time);
clock.timestamp.setCounter(last_time);
clock.timestamp.setMillis(max_time);
clock.timestamp.setCounter(last_time);
return new Timestamp(
clock.timestamp.millis(),
clock.timestamp.counter(),
clock.timestamp.node()
);
return new Timestamp(clock.timestamp.millis(), clock.timestamp.counter(), clock.timestamp.node());
};
```
+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}
+12 -12
View File
@@ -1,17 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<title>Tauri + Solid + Typescript App</title>
</head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<title>Tauri + Solid + Typescript App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
+30 -30
View File
@@ -1,32 +1,32 @@
{
"name": "@sd/sync-example",
"version": "0.0.0",
"description": "",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"tauri": "tauri"
},
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^1.1.0",
"@types/babel__core": "^7.1.19",
"@types/node": "^18.7.10",
"autoprefixer": "^10.4.12",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8",
"typescript": "^4.7.4",
"vite": "^3.0.0",
"vite-plugin-solid": "^2.3.0"
},
"dependencies": {
"@rspc/client": "~0.1.2",
"@rspc/solid": "~0.1.2",
"@rspc/tauri": "~0.1.2",
"@tanstack/solid-query": "4.7.1",
"clsx": "^1.2.1",
"solid-js": "^1.4.7"
}
"name": "@sd/sync-example",
"version": "0.0.0",
"description": "",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"tauri": "tauri"
},
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^1.1.1",
"@types/babel__core": "^7.1.19",
"@types/node": "^18.8.2",
"autoprefixer": "^10.4.12",
"postcss": "^8.4.17",
"tailwindcss": "^3.1.8",
"typescript": "^4.8.4",
"vite": "^3.1.4",
"vite-plugin-solid": "^2.3.9"
},
"dependencies": {
"@rspc/client": "~0.1.2",
"@rspc/solid": "~0.1.2",
"@rspc/tauri": "~0.1.2",
"@tanstack/solid-query": "4.10.1",
"clsx": "^1.2.1",
"solid-js": "^1.5.7"
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ tauri-build = { version = "1.1", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.1", features = ["api-all"] }
rspc = { version = "0.1.2", features = ["tauri", "uuid"] }
rspc = { workspace = true, features = ["tauri", "uuid"] }
sd-sync = { path = "../.." }
tokio = { version = "1.21.2", features = ["macros"] }
uuid = { version = "1.1.2", features = ["v4"] }
+64 -64
View File
@@ -1,66 +1,66 @@
{
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "example",
"version": "0.0.0"
},
"tauri": {
"allowlist": {
"all": true
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.tauri.dev",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": null
},
"updater": {
"active": false
},
"windows": [
{
"fullscreen": false,
"height": 600,
"resizable": true,
"title": "example",
"width": 800
}
]
}
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "example",
"version": "0.0.0"
},
"tauri": {
"allowlist": {
"all": true
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.tauri.dev",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": null
},
"updater": {
"active": false
},
"windows": [
{
"fullscreen": false,
"height": 600,
"resizable": true,
"title": "example",
"width": 800
}
]
}
}
+54 -60
View File
@@ -1,75 +1,69 @@
import clsx from "clsx";
import { createSignal, For, JSX, Suspense } from "solid-js";
import { queryClient, rspc } from "./rspc";
import clsx from 'clsx';
import { For, JSX, Suspense, createSignal } from 'solid-js';
import { queryClient, rspc } from './rspc';
export function App() {
const dbs = rspc.createQuery(() => ["dbs"]);
const dbs = rspc.createQuery(() => ['dbs']);
const createDb = rspc.createMutation("createDatabase", {
onSuccess: () => {
queryClient.invalidateQueries();
},
});
const removeDbs = rspc.createMutation("removeDatabases", {
onSuccess: () => queryClient.invalidateQueries(),
});
const createDb = rspc.createMutation('createDatabase', {
onSuccess: () => {
queryClient.invalidateQueries();
}
});
const removeDbs = rspc.createMutation('removeDatabases', {
onSuccess: () => queryClient.invalidateQueries()
});
return (
<div class="p-4 space-y-4">
<div class="space-x-4">
<Button onClick={() => createDb.mutate(undefined)}>Add Database</Button>
<Button onClick={() => removeDbs.mutate(undefined)}>
Remove Databases
</Button>
</div>
<ul class="gap-2 flex flex-row flex-wrap">
<For each={dbs.data}>
{(id) => (
<Suspense fallback={null}>
<DatabaseView id={id} />
</Suspense>
)}
</For>
</ul>
</div>
);
return (
<div class="p-4 space-y-4">
<div class="space-x-4">
<Button onClick={() => createDb.mutate(undefined)}>Add Database</Button>
<Button onClick={() => removeDbs.mutate(undefined)}>Remove Databases</Button>
</div>
<ul class="gap-2 flex flex-row flex-wrap">
<For each={dbs.data}>
{(id) => (
<Suspense fallback={null}>
<DatabaseView id={id} />
</Suspense>
)}
</For>
</ul>
</div>
);
}
interface DatabaseViewProps {
id: string;
id: string;
}
const TABS = ["Tags", "Files", "File Paths", "Messages"];
const TABS = ['Tags', 'Files', 'File Paths', 'Messages'];
function DatabaseView(props: DatabaseViewProps) {
const [currentTab, setCurrentTab] = createSignal<typeof TABS[number]>("Tags");
const [currentTab, setCurrentTab] = createSignal<typeof TABS[number]>('Tags');
return (
<div class="bg-indigo-300 rounded-md min-w-[40rem] flex-1 overflow-hidden">
<h1 class="p-2 text-xl font-medium">{props.id}</h1>
<div>
<nav class="space-x-2">
<For each={TABS}>
{(tab) => (
<button
class={clsx(
"px-2 py-1",
tab === currentTab() && "bg-indigo-400"
)}
onClick={() => setCurrentTab(tab)}
>
{tab}
</button>
)}
</For>
</nav>
<div></div>
</div>
</div>
);
return (
<div class="bg-indigo-300 rounded-md min-w-[40rem] flex-1 overflow-hidden">
<h1 class="p-2 text-xl font-medium">{props.id}</h1>
<div>
<nav class="space-x-2">
<For each={TABS}>
{(tab) => (
<button
class={clsx('px-2 py-1', tab === currentTab() && 'bg-indigo-400')}
onClick={() => setCurrentTab(tab)}
>
{tab}
</button>
)}
</For>
</nav>
<div></div>
</div>
</div>
);
}
function Button(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button {...props} class="bg-blue-500 text-white px-2 py-1 rounded-md" />
);
return <button {...props} class="bg-blue-500 text-white px-2 py-1 rounded-md" />;
}
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more