Merge pull request #401 from spacedriveapp/merge-p2p-code

P2P code
This commit is contained in:
Oscar Beaumont
2022-10-07 07:47:42 +08:00
committed by GitHub
126 changed files with 4277 additions and 934 deletions

1
.gitignore vendored
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

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
/target
.build

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']

View File

@@ -1,20 +0,0 @@
{
"pluginSearchDirs": [
"."
],
"useTabs": true,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "none",
"bracketSameLine": false,
"semi": true,
"quoteProps": "consistent",
"importOrder": [
"^[./]",
"^@sd/ui/(.*)$",
"^@sd/client/(.*)$",
"^@sd/interface/(.*)$"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}

BIN
Cargo.lock generated
View File

Binary file not shown.

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",

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).

View File

@@ -2,13 +2,13 @@ import { Button } from '@sd/ui';
import React from 'react';
import { PageContextBuiltIn } from 'vite-plugin-ssr';
import '@sd/ui/style';
import { Footer } from './components/Footer';
import NavBar from './components/NavBar';
import { PageContextProvider } from './renderer/usePageContext';
import './style.scss';
import '@sd/ui/style';
export default function App({
children,
pageContext

View File

@@ -7,35 +7,35 @@ const ghostURL = import.meta.env.VITE_API_URL;
export const blogEnabled = !!(ghostURL && ghostKey);
export const api = blogEnabled
? new GhostContentAPI({
url: ghostURL,
key: ghostKey,
version: 'v5.0'
})
: null;
? new GhostContentAPI({
url: ghostURL,
key: ghostKey,
version: 'v5.0'
})
: null;
export async function getPosts() {
if (!api) {
return [];
}
const posts = await api.posts
.browse({
include: ['tags', 'authors']
})
.catch(() => []);
return posts;
if (!api) {
return [];
}
const posts = await api.posts
.browse({
include: ['tags', 'authors']
})
.catch(() => []);
return posts;
}
export async function getPost(slug: string) {
if (!api) {
return null;
}
return await api.posts
.read(
{ slug },
{
include: ['tags', 'authors']
}
)
.catch(() => null);
if (!api) {
return null;
}
return await api.posts
.read(
{ slug },
{
include: ['tags', 'authors']
}
)
.catch(() => null);
}

View File

@@ -2,8 +2,6 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { ReactComponent as Info } from '@sd/interface/assets/svg/info.svg';
import AppEmbed, { AppEmbedPlaceholder } from '../components/AppEmbed';
import { Bubbles } from '../components/Bubbles';
// import { Bubbles } from '../components/Bubbles';
@@ -12,6 +10,8 @@ import NewBanner from '../components/NewBanner';
import { usePageContext } from '../renderer/usePageContext';
import { getWindow } from '../utils';
import { ReactComponent as Info } from '@sd/interface/assets/svg/info.svg';
interface SectionProps {
orientation: 'left' | 'right';
heading?: string;

View File

@@ -1,11 +1,11 @@
import { Helmet } from 'react-helmet';
import { ReactComponent as ArrowRight } from '@sd/interface/assets/svg/arrow-right.svg';
import Markdown from '../components/Markdown';
import { TeamMember, TeamMemberProps } from '../components/TeamMember';
import { resolveFilesGlob } from '../utils';
import { ReactComponent as ArrowRight } from '@sd/interface/assets/svg/arrow-right.svg';
const teamImages = resolveFilesGlob(import.meta.globEager('../assets/images/team/*'));
const investorImages = resolveFilesGlob(import.meta.globEager('../assets/images/investors/*'));

View File

@@ -116,7 +116,7 @@ html {
&.bloom-one {
@apply left-0 -ml-[300px];
background: url('/bloom-one.png') no-repeat center center;
background-size: contain;
background-size: contain;
animation-delay: 300ms;
}
&.bloom-two {
@@ -139,7 +139,6 @@ html {
background: url('/egg-bloom-two.png') no-repeat center center;
background-size: contain;
}
}
@keyframes bloomBurst {
@@ -170,47 +169,48 @@ html {
}
.slot-block {
@apply bg-gray-550 py-3 px-4 border-l-4 border-gray-400 rounded mb-2
@apply bg-gray-550 py-3 px-4 border-l-4 border-gray-400 rounded mb-2;
}
.slot-block.note {
@apply border-yellow-400 bg-yellow-300/20
@apply border-yellow-400 bg-yellow-300/20;
}
.slot-block.info {
@apply border-green-400 bg-green-400/20
@apply border-green-400 bg-green-400/20;
}
.slot-block.warning {
@apply border-red-400 bg-red-400/20
@apply border-red-400 bg-red-400/20;
}
.slot-block-title {
@apply text-white font-bold text-sm m-0 uppercase;
}
.slot-block-content {
@apply my-1 mx-0 mb-0 text-white
@apply my-1 mx-0 mb-0 text-white;
}
.doc-sidebar-button:hover, .doc-sidebar-button.nav-active {
.doc-sidebar-button:hover,
.doc-sidebar-button.nav-active {
&.product {
color:#459EE8;
color: #459ee8;
div {
background-color: #459EE8;
background-color: #459ee8;
}
}
&.developers {
color:#48BB78;
color: #48bb78;
div {
background-color: #48BB78;
background-color: #48bb78;
}
}
&.company {
color:#bb9247;
color: #bb9247;
div {
background-color: #bb9247;
}
}
&.changelog {
color:#8A47BB;
color: #8a47bb;
div {
background-color: #8A47BB;
background-color: #8a47bb;
}
}
}
@@ -222,7 +222,6 @@ html {
// #1D054B
// #9A3F8C
.custom-scroll {
-ms-overflow-style: none; /* for Internet Explorer, Edge */
scrollbar-width: none; /* for Firefox */
@@ -238,7 +237,6 @@ html {
@apply bg-[#00000006] dark:bg-[#00000030] my-[10px] rounded-[6px];
}
&::-webkit-scrollbar-thumb {
@apply rounded-[6px] bg-gray-300 dark:bg-gray-550 ;
@apply rounded-[6px] bg-gray-300 dark:bg-gray-550;
}
}
}

View File

@@ -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"
}
}

View File

@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
"info": {
"version": 1,
"author": "expo"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -2,111 +2,358 @@
// This file was generated by [rspc](https://github.com/oscartbeaumont/rspc). Do not edit this file manually.
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 }
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;
}

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>

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>

View File

@@ -1,7 +1,5 @@
{
"extends": "../../packages/config/interface.tsconfig.json",
"compilerOptions": {},
"include": [
"src"
]
"extends": "../../packages/config/interface.tsconfig.json",
"compilerOptions": {},
"include": ["src"]
}

78
core/src/api/p2p.rs Normal file
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
// },
// )
// }

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()
@@ -78,6 +84,12 @@ impl Node {
// ),
)
.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);

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)]

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
}
```
```

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
crates/p2p/README.md Normal file
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
```

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

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);
}
}
};
}
}

View File

@@ -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
}
}

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");
}
}

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::*;

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
crates/p2p/src/lib.rs Normal file
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};
}

View File

@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}

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::*;

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),
}

View File

@@ -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>,
}

View File

@@ -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),
}

View File

@@ -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),
}

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);
}
}
});
}
}

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,
}

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)) {}
}

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
crates/p2p/src/peer/peer.rs Normal file
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);
}
}
}
}
}

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,
}

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
}
}

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))
}
}

View File

@@ -0,0 +1,3 @@
mod identity;
pub use identity::*;

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"

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"]

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
```

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

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!");
}

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),
}

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"

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)
}
}

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::*;

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,
}

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),
}

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())
}
}

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[..])?)
}

View File

@@ -1,4 +1,3 @@
# crdt-rs
Just @brendonovich experimenting with CRDT stuff.

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());
};
```

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

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>

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.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"
}
"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"
}
}

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
}
]
}
}

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" />;
}

View File

@@ -1,15 +1,22 @@
// This file was generated by [rspc](https://github.com/oscartbeaumont/rspc). Do not edit this file manually.
export type Procedures = {
queries:
{ key: "db.tags", input: string, result: Record<string, Tag> } |
{ key: "dbs", input: never, result: Array<string> },
mutations:
{ key: "createDatabase", input: never, result: string } |
{ key: "removeDatabases", input: never, result: null },
subscriptions: never
queries:
| { key: 'db.tags'; input: string; result: Record<string, Tag> }
| { key: 'dbs'; input: never; result: Array<string> };
mutations:
| { key: 'createDatabase'; input: never; result: string }
| { key: 'removeDatabases'; input: never; result: null };
subscriptions: never;
};
export interface Color { red: number, green: number, blue: number }
export interface Color {
red: number;
green: number;
blue: number;
}
export interface Tag { color: Color, name: string }
export interface Tag {
color: Color;
name: string;
}

View File

@@ -1,17 +1,17 @@
/* @refresh reload */
import { render, Suspense } from "solid-js/web";
import { Suspense, render } from 'solid-js/web';
import "./index.css";
import { App } from "./App";
import { queryClient, rspc, rspcClient } from "./rspc";
import { App } from './App';
import './index.css';
import { queryClient, rspc, rspcClient } from './rspc';
render(
() => (
<rspc.Provider client={rspcClient} queryClient={queryClient}>
<Suspense fallback={null}>
<App />
</Suspense>
</rspc.Provider>
),
document.getElementById("root") as HTMLElement
() => (
<rspc.Provider client={rspcClient} queryClient={queryClient}>
<Suspense fallback={null}>
<App />
</Suspense>
</rspc.Provider>
),
document.getElementById('root') as HTMLElement
);

View File

@@ -1,22 +1,24 @@
import { QueryClient } from "@tanstack/solid-query";
import { createSolidQueryHooks } from "@rspc/solid";
import { createClient } from "@rspc/client";
import { TauriTransport } from "@rspc/tauri";
import { createClient } from '@rspc/client';
import { createSolidQueryHooks } from '@rspc/solid';
import { TauriTransport } from '@rspc/tauri';
import { QueryClient } from '@tanstack/solid-query';
import type { Procedures } from "./bindings"; // These were the bindings exported from your Rust code!
import type { Procedures } from './bindings';
// These were the bindings exported from your Rust code!
// You must provide the generated types as a generic and create a transport (in this example we are using HTTP Fetch) so that the client knows how to communicate with your API.
export const rspcClient = createClient<Procedures>({
// Refer to the integration your using for the correct transport.
transport: new TauriTransport(),
// Refer to the integration your using for the correct transport.
transport: new TauriTransport()
});
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
defaultOptions: {
queries: {
suspense: true
}
}
});
export const rspc = createSolidQueryHooks<Procedures>();

View File

@@ -1,15 +1,15 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true
}
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true
}
}

View File

@@ -1,25 +1,25 @@
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [solidPlugin()],
// Vite optons tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
// Tauri supports es2021
target: ["es2021", "chrome100", "safari13"],
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},
plugins: [solidPlugin()],
// Vite optons tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ['VITE_', 'TAURI_'],
build: {
// Tauri supports es2021
target: ['es2021', 'chrome100', 'safari13'],
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG
}
});

View File

@@ -4,13 +4,13 @@
This release has not happened yet, stay tuned...
:::
After __ months of development we are extremely excited to be releasing the first version of Spacedrive as an early public beta.
After \_\_ months of development we are extremely excited to be releasing the first version of Spacedrive as an early public beta.
This is an MVP, and by no means feature complete. Please test out the features listed below and give us feedback via Discord, email or GitHub Issues :D
This release is missing database synchronization between nodes (your devices), for now this renders connecting nodes useless, other than to transfer individual files. But don't worry, its coming very soon!
*Features:*
_Features:_
- Support for Windows, Linux and macOS, iOS and Android.
@@ -21,20 +21,20 @@ This release is missing database synchronization between nodes (your devices), f
- Connect multiple [Nodes](../architecture/nodes.md) to a Library via LAN.
- Add [Locations](../architecture/locations.md) to import files into Spacedrive.
- Indexer watch for changes and performs light re-scans.
- Identifier generates checksum and categorizes files into [Objects]()
- Define rules for indexer to ignore certain files or folders.
*Eventually Clouds will be supported and added as Cloud Locations*
_Eventually Clouds will be supported and added as Cloud Locations_
- Browse Locations via the [Explorer](../architecture/explorer.md) and view previews and metadata.
- Viewer options: row/grid item size, gap adjustment, show/hide info.
- Context menu: rename, copy, duplicate, delete, favorite and add tags.
- Multi-select with dedicated context menu options.
- Open with default OS app, in-app viewer (images/text only) or Apple Quicklook
- Automatically identify unique files to discover duplicates, shown in the inspector.
- Generate [Preview Media](../architecture/preview-media.md) for image, video and text.
@@ -45,7 +45,7 @@ This release is missing database synchronization between nodes (your devices), f
- Automated Spaces can include files that match criteria.
*Eventually Spaces will be sharable, publically or privately*
_Eventually Spaces will be sharable, publically or privately_
- Create photo [Albums](../architecture/albums.md) and add images.
@@ -67,4 +67,4 @@ This release is missing database synchronization between nodes (your devices), f
- Update installer.
- Optional crash reporting.
- Optional crash reporting.

View File

@@ -74,4 +74,4 @@ limit any of our or your liabilities in any way that is not permitted under appl
exclude any of our or your liabilities that may not be excluded under applicable law.
The limitations and prohibitions of liability set in this Section and elsewhere in this disclaimer: (a) are subject to the preceding paragraph; and (b) govern all liabilities arising under the disclaimer, including liabilities arising in contract, in tort, and for breach of statutory duty.
As long as the website and the information and services on the website are provided free of charge, we will not be liable for any loss or damage of any nature.
As long as the website and the information and services on the website are provided free of charge, we will not be liable for any loss or damage of any nature.

View File

@@ -1,6 +1,7 @@
---
index: 10
---
# Albums
you can put photos here
# Albums
you can put photos here

View File

@@ -4,4 +4,4 @@ index: 4
# Database
prisma client rust, sqlite, migrations, backup
prisma client rust, sqlite, migrations, backup

View File

@@ -2,7 +2,6 @@
index: 10
---
# Explorer
using the interface, features
using the interface, features

View File

@@ -4,4 +4,4 @@ index: 10
# Extensions
extended functionality of Spacedrive
extended functionality of Spacedrive

View File

@@ -4,4 +4,4 @@ index: 10
# Jobs
jobs are computation tasks performed by nodes in a Spacedrive network, they can be created by any node and performed by any or all nodes.
jobs are computation tasks performed by nodes in a Spacedrive network, they can be created by any node and performed by any or all nodes.

View File

@@ -1,6 +1,7 @@
---
index: 1
---
# Libraries
A library is the database that Spacedrive stores all file structures and metadata. It can be synchronized with other [Nodes]()
A library is the database that Spacedrive stores all file structures and metadata. It can be synchronized with other [Nodes]()

View File

@@ -4,4 +4,4 @@ index: 10
# Locations
indexing, identifying, watching, .spacedrive folder, online/offline
indexing, identifying, watching, .spacedrive folder, online/offline

View File

@@ -1,10 +1,9 @@
---
index: 3
---
# Nodes
Nodes are instances of the Spacedrive core running on a device, they are able to connect to each other via a peer-to-peer network. A node is able to run many libraries simultaneously, but must be authorized per-library in order to synchronize.
p2p, connecting nodes, protocols.

View File

@@ -7,8 +7,8 @@ new: true
# Objects
Objects are files discovered on your devices and drives, but can also be virtual, existing only within Spacedrive.
All metadata associated with files in Spacedrive is linked to the Object for that file.
All metadata associated with files in Spacedrive is linked to the Object for that file.
If two or more files are discovered that are identical, they are linked to the same Object in Spacedrive.
@@ -18,26 +18,26 @@ Some Objects are purely virtual, meaning they have no Path and are likely only u
## Types of object
| Name | Description | Code |
| ---------------- | ------------------------------------------------------------ | ---- |
| Unknown | A file that can not be identified by the indexer | 0 |
| Document | A known filetype, but without specific support | 1 |
| Folder | A virtual filesystem directory | 2 |
| Text File | A file that contains human-readable text | 3 |
| Package | A folder that opens an application | 4 |
| Image | An image file | 5 |
| Audio | An audio file | 6 |
| Video | A video file | 7 |
| Archive | A compressed archive of data | 8 |
| Executable | An executable program or application | 9 |
| Alias | A link to another Object | 10 |
| Encrypted Bytes | Raw bytes with self contained metadata | 11 |
| Link | A link to a web page, application or Space | 12 |
| Web Page Archive | A snapshot of a webpage, with HTML, JS, images and screenshot | 13 |
| Widget | A widget is a mini app that can be placed in a Space at various sizes, associated Widget struct required | 14 |
| Album | Albums can only have one level of children, and are associated with the Album struct | 15 |
| Name | Description | Code |
| ---------------- | ---------------------------------------------------------------------------------------------------------- | ---- |
| Unknown | A file that can not be identified by the indexer | 0 |
| Document | A known filetype, but without specific support | 1 |
| Folder | A virtual filesystem directory | 2 |
| Text File | A file that contains human-readable text | 3 |
| Package | A folder that opens an application | 4 |
| Image | An image file | 5 |
| Audio | An audio file | 6 |
| Video | A video file | 7 |
| Archive | A compressed archive of data | 8 |
| Executable | An executable program or application | 9 |
| Alias | A link to another Object | 10 |
| Encrypted Bytes | Raw bytes with self contained metadata | 11 |
| Link | A link to a web page, application or Space | 12 |
| Web Page Archive | A snapshot of a webpage, with HTML, JS, images and screenshot | 13 |
| Widget | A widget is a mini app that can be placed in a Space at various sizes, associated Widget struct required | 14 |
| Album | Albums can only have one level of children, and are associated with the Album struct | 15 |
| Collection | Its like a folder, but appears like a stack of files, designed for burst photos/associated groups of files | 16 |
| Database | A database file | 17 |
| Mesh | A 3D mesh object | 18 |
| Textured Mesh | A 3D mesh object with textures | 19 |
| Entity | A rigged character | 20 |
| Database | A database file | 17 |
| Mesh | A 3D mesh object | 18 |
| Textured Mesh | A 3D mesh object with textures | 19 |
| Entity | A rigged character | 20 |

View File

@@ -9,4 +9,4 @@ Spacedrive generates compressed preview media for images, videos and text files.
Preview media is stored in the Node's data folder in a single directory. Images are stored as WEBP format with their CAS id as the name.
ffmpeg, syncing, security
ffmpeg, syncing, security

View File

@@ -2,7 +2,6 @@
index: 11
---
# Search
Press CTRL+F while on a Spacedrive window to access search.

View File

@@ -1,10 +1,11 @@
---
index: 3
---
# Spaces
Spaces are virtual folders that can be shared publicly on the internet, or privately with friends, family and teams. Spaces contain [Objects](/docs/developers/architecture/objects) which can be physically stored on any connected Node, or by Spacedrive as a service. Objects can be organized and presented spatially, with various layouts and variable grid placements. Color theme, icon packs and typography can be customized per Space.
Objects can be added to a Space manually, or by matching a defined ruleset, similar to Tags.
Spacedrive comes with pre-defined Spaces, such as: photos, videos, screenshots, documents, .
Spacedrive comes with pre-defined Spaces, such as: photos, videos, screenshots, documents, .

View File

@@ -6,4 +6,4 @@ index: 13
Spacedrive heavily incentivize using tags to organize files efficiently by giving them more functionality and easier ways to apply them, individually and in bulk.
ways to add tags, tag automation
ways to add tags, tag automation

View File

@@ -3,4 +3,3 @@ index: 10
---
# Virtual Filesystem

View File

@@ -1,3 +1,3 @@
---
index: 10
---
---

View File

@@ -4,23 +4,27 @@ index: 500
---
# JavaScript Client
For developers who want to extend the functionality of Spacedrive, this library allows easy development of [Extensions](), providing full access to Spacedrive's functionality.
### Installation
```shell
$ npm i @spacedrive/client
```
Initialize the Spacedrive client
```ts
import spacedrive from "@spacedrive/client";
spacedrive.start()
```ts
import spacedrive from '@spacedrive/client';
spacedrive.start();
```
Add a location
```ts
const location = await spacedrive.location.create("/Users/jamie/Documents");
const location = await spacedrive.location.create('/Users/jamie/Documents');
location.scan();
```
```

View File

@@ -6,37 +6,42 @@ index: 1
To get started contributing to Spacedrive, follow this guide carefully.
This project uses [Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) and [pnpm](https://pnpm.io/installation).
This project uses [Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) and [pnpm](https://pnpm.io/installation).
## Installation
## Installation
1. **Clone repo**
```shell
git clone https://github.com/spacedriveapp/spacedrive && cd spacedrive
```
```shell
git clone https://github.com/spacedriveapp/spacedrive && cd spacedrive
```
2. **Run setup script**
**For Linux or MacOS users run:**
```shell
./.github/scripts/setup-system.sh
```
This will install FFMPEG and any other required dependencies for Spacedrive to build.
```shell
./.github/scripts/setup-system.sh
```
This will install FFMPEG and any other required dependencies for Spacedrive to build.
**...or for Windows users run using PowerShell:**
```shell
.\.github\scripts\setup-system.ps1
```
_This will install pnpm, LLVM, FFMPEG and any other required dependencies for Spacedrive to build. Ensure you run it like documented above as it expects it is executed from the root of the repository._
```shell
.\.github\scripts\setup-system.ps1
```
_This will install pnpm, LLVM, FFMPEG and any other required dependencies for Spacedrive to build. Ensure you run it like documented above as it expects it is executed from the root of the repository._
3. **Install dependencies**
```shell
pnpm i
```
```shell
pnpm i
```
4. **Run codegen & build required dependencies**
```shell
pnpm prep
```
## Running apps
```shell
pnpm prep
```
## Running apps
- **Desktop:** `pnpm desktop dev`
- **Landing:** `pnpm landing dev`
@@ -53,7 +58,7 @@ To run mobile app
1. Install [Android Studio](https://developer.android.com/studio) for Android and [Xcode](https://apps.apple.com/au/app/xcode/id497799835) for IOS development
2. `./.github/scripts/setup-system.sh mobile`
_The should setup most of the dependencies for the mobile app to build._
_The should setup most of the dependencies for the mobile app to build._
3. You must also ensure you have [NDK 24.0.8215888 and CMake](https://developer.android.com/studio/projects/install-ndk#default-version) in Android Studio
4. `cd apps/mobile && pnpm i` - This is a separate workspace, you need to do this!
5. `pnpm android` - runs on Android Emulator
@@ -65,4 +70,4 @@ To run mobile app
If you are having issues ensure you are using the following versions of Rust and Node:
- Rust version: **1.64.0**
- Node version: **17**
- Node version: **17**

View File

@@ -6,4 +6,4 @@ index: 0
```rust
pub struct DeveloperDocumentation;
```
```

View File

@@ -3,4 +3,4 @@ name: Open Source
index: 10
---
# Open Source
# Open Source

View File

@@ -3,4 +3,4 @@ name: Tech Stack
index: 10
---
# Tech Stack
# Tech Stack

View File

@@ -1,5 +1,5 @@
{
"name": "@sd/docs",
"description": "Allows turborepo to detect our markdown files",
"private": true
"name": "@sd/docs",
"description": "Allows turborepo to detect our markdown files",
"private": true
}

View File

@@ -1 +1 @@
# Features
# Features

View File

@@ -7,7 +7,7 @@ index: 0
Spacedrive is a cross-platform file manager. It connects your devices together to help you organize files from anywhere.
Beyond being an opinionated, swiss army knife file explorer, Spacedrive is a personal database. It identifies your files uniquely, understanding more file types than any operating system.
Beyond being an opinionated, swiss army knife file explorer, Spacedrive is a personal database. It identifies your files uniquely, understanding more file types than any operating system.
Create photo albums that you'll never loose, effortlessly catalogue terabytes of video, move files between devices dynamically to optimize space and redundancy, generate preview media for easy viewing, automate security and encryption—the list goes on.

View File

@@ -7,4 +7,4 @@ index: 0
::: slot note Note
Spacedrive is not yet released, these docs are a work in progress. Check out our [roadmap](/roadmap) to see a timeline leading to release!
:::
:::

View File

@@ -4,4 +4,3 @@ index: 0
---
# Terminology

Some files were not shown because too many files have changed in this diff Show More