Merge pull request #6 from spacedriveapp/main

Parity
This commit is contained in:
Arnab Chakraborty 2024-02-04 01:33:36 -05:00 committed by GitHub
commit 201913a197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 2081 additions and 2109 deletions

View file

@ -16,7 +16,7 @@ runs:
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ inputs.target }}
toolchain: '1.73'
toolchain: '1.75'
components: clippy, rustfmt
- name: Cache Rust Dependencies

527
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -85,7 +85,7 @@ webp = "0.2.6"
[patch.crates-io]
# Proper IOS Support
if-watch = { git = "https://github.com/oscartbeaumont/if-watch.git", rev = "f732786057e57250e863a9ea0b1874e4cc9907c2" }
if-watch = { git = "https://github.com/oscartbeaumont/if-watch.git", rev = "a92c17d3f85c1c6fb0afeeaf6c2b24d0b147e8c3" }
# Beta features
rspc = { git = "https://github.com/spacedriveapp/rspc.git", rev = "f3347e2e8bfe3f37bfacc437ca329fe71cdcb048" }

View file

@ -1,6 +1,6 @@
[package]
name = "sd-desktop"
version = "0.2.0"
version = "0.2.3"
description = "The universal file manager."
authors = ["Spacedrive Technology Inc <support@spacedrive.com>"]
default-run = "sd-desktop"
@ -47,6 +47,7 @@ tauri = { version = "=1.5.3", features = [
"tracing",
] }
tauri-plugin-window-state = { path = "../crates/tauri-plugin-window-state" }
directories = "5.0.1"
[target.'cfg(target_os = "linux")'.dependencies]
sd-desktop-linux = { path = "../crates/linux" }

View file

@ -0,0 +1,44 @@
use directories::BaseDirs;
use tokio::fs;
use tracing::{info, warn};
#[cfg(target_os = "linux")]
const EXTRA_DIRS: [&str; 1] = [".cache/spacedrive"];
#[cfg(target_os = "macos")]
const EXTRA_DIRS: [&str; 2] = ["Library/WebKit/Spacedrive", "Library/Caches/Spacedrive"];
pub async fn clear_localstorage() {
if let Some(base_dir) = BaseDirs::new() {
let data_dir = base_dir.data_dir().join("com.spacedrive.desktop"); // maybe tie this into something static?
fs::remove_dir_all(&data_dir)
.await
.map_err(|_| warn!("Unable to delete the `localStorage` primary directory."))
.ok();
// Windows needs both AppData/Local and AppData/Roaming clearing as it stores data in both
#[cfg(target_os = "windows")]
fs::remove_dir_all(&base_dir.data_local_dir().join("com.spacedrive.desktop"))
.await
.map_err(|_| warn!("Unable to delete the `localStorage` directory in Local AppData."))
.ok();
info!("Deleted {}", data_dir.display());
let home_dir = base_dir.home_dir();
#[cfg(any(target_os = "linux", target_os = "macos"))]
for path in EXTRA_DIRS {
fs::remove_dir_all(home_dir.join(path))
.await
.map_err(|_| warn!("Unable to delete a `localStorage` cache: {path}"))
.ok();
info!("Deleted {path}");
}
info!("Successfully wiped `localStorage` and related caches.")
} else {
warn!("Unable to source `BaseDirs` in order to clear `localStorage`.")
}
}

View file

@ -13,6 +13,7 @@ use std::{
use sd_core::{Node, NodeError};
use clear_localstorage::clear_localstorage;
use sd_fda::DiskAccess;
use serde::{Deserialize, Serialize};
use tauri::{
@ -24,12 +25,11 @@ use tauri_specta::{collect_events, ts, Event};
use tokio::time::sleep;
use tracing::error;
mod tauri_plugins;
mod theme;
mod clear_localstorage;
mod file;
mod menu;
mod tauri_plugins;
mod theme;
mod updater;
#[tauri::command(async)]
@ -197,11 +197,9 @@ async fn main() -> tauri::Result<()> {
}
};
let (node, router) = if let Some((node, router)) = node_router {
(node, router)
} else {
panic!("Unable to get the node or router");
};
let (node, router) = node_router.expect("Unable to get the node or router");
let should_clear_localstorage = node.libraries.get_all().await.is_empty();
let app = app
.plugin(rspc::integrations::tauri::plugin(router, {
@ -262,7 +260,14 @@ async fn main() -> tauri::Result<()> {
.setup(move |app| {
let app = app.handle();
println!("setup");
app.windows().iter().for_each(|(_, window)| {
if should_clear_localstorage {
println!("bruh?");
window.eval("localStorage.clear();").ok();
}
tokio::spawn({
let window = window.clone();
async move {

View file

@ -108,7 +108,7 @@ pub fn plugin<R: Runtime>() -> TauriPlugin<R> {
if updater_available {
window
.eval("window.__SD_UPDATER__ = true")
.eval("window.__SD_UPDATER__ = true;")
.expect("Failed to inject updater JS");
}
})

View file

@ -44,7 +44,6 @@
"digestAlgorithm": "sha256",
"timestampUrl": "",
"wix": {
"enableElevatedUpdateTask": true,
"dialogImagePath": "icons/WindowsDialogImage.bmp",
"bannerPath": "icons/WindowsBanner.bmp"
}

View file

@ -80,18 +80,22 @@ const routes = createRoutes(platform, cache);
function AppInner() {
const [tabs, setTabs] = useState(() => [createTab()]);
const [tabIndex, setTabIndex] = useState(0);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const selectedTab = tabs[selectedTabIndex]!;
function createTab() {
const history = createMemoryHistory();
const router = createMemoryRouterWithHistory({ routes, history });
const id = Math.random().toString();
const dispose = router.subscribe((event) => {
// we don't care about non-idle events as those are artifacts of form mutations + suspense
if (event.navigation.state !== 'idle') return;
setTabs((routers) => {
const index = routers.findIndex((r) => r.router === router);
const index = routers.findIndex((r) => r.id === id);
if (index === -1) return routers;
const routerAtIndex = routers[index]!;
@ -105,12 +109,12 @@ function AppInner() {
: Math.max(routerAtIndex.maxIndex, history.index)
};
return [...routers];
return [...routers]
});
});
return {
id: Math.random().toString(),
id,
router,
history,
dispose,
@ -121,8 +125,6 @@ function AppInner() {
};
}
const tab = tabs[tabIndex]!;
const createTabPromise = useRef(Promise.resolve());
const ref = useRef<HTMLDivElement>(null);
@ -131,38 +133,37 @@ function AppInner() {
const div = ref.current;
if (!div) return;
div.appendChild(tab.element);
div.appendChild(selectedTab.element);
return () => {
while (div.firstChild) {
div.removeChild(div.firstChild);
}
};
}, [tab.element]);
}, [selectedTab.element]);
return (
<RouteTitleContext.Provider
value={useMemo(
() => ({
setTitle(title) {
setTabs((oldTabs) => {
const tabs = [...oldTabs];
const tab = tabs[tabIndex];
if (!tab) return tabs;
setTitle(id, title) {
setTabs((tabs) => {
const tabIndex = tabs.findIndex(t => t.id === id);
if (tabIndex === -1) return tabs;
tabs[tabIndex] = { ...tab, title };
tabs[tabIndex] = { ...tabs[tabIndex]!, title };
return tabs;
return [...tabs];
});
}
}),
[tabIndex]
[]
)}
>
<TabsContext.Provider
value={{
tabIndex,
setTabIndex,
tabIndex: selectedTabIndex,
setTabIndex: setSelectedTabIndex,
tabs: tabs.map(({ router, title }) => ({ router, title })),
createTab() {
createTabPromise.current = createTabPromise.current.then(
@ -170,9 +171,10 @@ function AppInner() {
new Promise((res) => {
startTransition(() => {
setTabs((tabs) => {
const newTabs = [...tabs, createTab()];
const newTab = createTab();
const newTabs = [...tabs, newTab];
setTabIndex(newTabs.length - 1);
setSelectedTabIndex(newTabs.length - 1);
return newTabs;
});
@ -192,7 +194,7 @@ function AppInner() {
tabs.splice(index, 1);
setTabIndex(Math.min(tabIndex, tabs.length - 1));
setSelectedTabIndex(Math.min(selectedTabIndex, tabs.length - 1));
return [...tabs];
});
@ -201,15 +203,16 @@ function AppInner() {
}}
>
<SpacedriveInterfaceRoot>
{tabs.map((tab) =>
{tabs.map((tab, index) =>
createPortal(
<SpacedriveRouterProvider
key={tab.id}
routing={{
routes,
visible: tabIndex === tabs.indexOf(tab),
visible: selectedTabIndex === tabs.indexOf(tab),
router: tab.router,
currentIndex: tab.currentIndex,
tabId: tab.id,
maxIndex: tab.maxIndex
}}
/>,

View file

@ -54,7 +54,7 @@
},
"ios": {
"useFrameworks": "static",
"deploymentTarget": "13.0"
"deploymentTarget": "14.0"
}
}
]

Binary file not shown.

View file

@ -11,7 +11,7 @@ Pod::Spec.new do |s|
s.description = 'Spacedrive core for React Native'
s.author = 'Oscar Beaumont'
s.license = 'APGL-3.0'
s.platform = :ios, '13.0'
s.platform = :ios, '14.0'
s.source = { git: 'https://github.com/spacedriveapp/spacedrive' }
s.homepage = 'https://www.spacedrive.com'
s.static_framework = true

View file

@ -13,7 +13,7 @@
"android-studio": "open -a '/Applications/Android Studio.app' ./android",
"lint": "eslint src --cache",
"test": "cd ../.. && ./apps/mobile/scripts/run-maestro-tests ios",
"export": "expo export",
"export": "expo export",
"typecheck": "tsc -b"
},
"dependencies": {
@ -22,7 +22,7 @@
"@oscartbeaumont-sd/rspc-client": "=0.0.0-main-dc31e5b2",
"@oscartbeaumont-sd/rspc-react": "=0.0.0-main-dc31e5b2",
"@react-native-async-storage/async-storage": "~1.18.2",
"@react-native-masked-view/masked-view": "^0.3.1",
"@react-native-masked-view/masked-view": "^0.2.9",
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/drawer": "^6.6.3",
"@react-navigation/native": "^6.1.7",
@ -36,7 +36,7 @@
"dayjs": "^1.11.10",
"event-target-polyfill": "^0.0.3",
"expo": "~49.0.8",
"expo-blur": "^12.6.0",
"expo-blur": "^12.4.1",
"expo-build-properties": "~0.8.3",
"expo-linking": "~5.0.2",
"expo-media-library": "~15.4.1",
@ -58,8 +58,9 @@
"react-native-reanimated": "~3.3.0",
"react-native-safe-area-context": "4.6.3",
"react-native-screens": "~3.22.1",
"react-native-svg": "14.1.0",
"react-native-svg": "13.9.0",
"react-native-wheel-color-picker": "^1.2.0",
"rive-react-native": "^6.2.3",
"solid-js": "^1.8.8",
"twrnc": "^3.6.4",
"use-count-up": "^3.0.1",

View file

@ -39,9 +39,9 @@ const BrowseLocationItem: React.FC<BrowseLocationItemProps> = ({
return (
<Pressable onPress={onPress}>
<View
style={tw`h-fit w-[100px] flex-col justify-center gap-3 rounded-md border border-sidebar-line/50 bg-sidebar-box p-2`}
style={tw`h-auto w-[100px] flex-col justify-center gap-3 rounded-md border border-sidebar-line/50 bg-sidebar-box p-2`}
>
<View style={tw`flex-col justify-between w-full gap-1`}>
<View style={tw`w-full flex-col justify-between gap-1`}>
<View style={tw`flex-row items-center justify-between`}>
<View style={tw`relative`}>
<FolderIcon size={42} />
@ -97,7 +97,7 @@ const BrowseLocations = () => {
return (
<View style={tw`gap-5`}>
<View style={tw`flex-row items-center justify-between w-full px-7`}>
<View style={tw`w-full flex-row items-center justify-between px-7`}>
<Text style={tw`text-xl font-bold text-white`}>Locations</Text>
<View style={tw`flex-row gap-3`}>
<Pressable
@ -105,13 +105,13 @@ const BrowseLocations = () => {
navigation.navigate('Locations');
}}
>
<View style={tw`items-center justify-center w-8 h-8 rounded-md bg-accent`}>
<View style={tw`h-8 w-8 items-center justify-center rounded-md bg-accent`}>
<Eye weight="bold" size={18} style={tw`text-white`} />
</View>
</Pressable>
<Pressable onPress={() => modalRef.current?.present()}>
<View
style={tw`items-center justify-center w-8 h-8 bg-transparent border border-dashed rounded-md border-ink-faint`}
style={tw`h-8 w-8 items-center justify-center rounded-md border border-dashed border-ink-faint bg-transparent`}
>
<Plus weight="bold" size={18} style={tw`text-ink-faint`} />
</View>

View file

@ -22,7 +22,7 @@ const BrowseTagItem: React.FC<BrowseTagItemProps> = ({ tag, onPress }) => {
return (
<Pressable onPress={onPress} testID="browse-tag">
<View
style={tw`h-fit w-[90px] flex-col justify-center gap-2.5 rounded-md border border-sidebar-line/50 bg-sidebar-box p-2`}
style={tw`h-auto w-[90px] flex-col justify-center gap-2.5 rounded-md border border-sidebar-line/50 bg-sidebar-box p-2`}
>
<View style={tw`flex-row items-center justify-between`}>
<View

View file

@ -1,9 +1,9 @@
import { useNavigation } from '@react-navigation/native';
import { useClientContext } from '@sd/client';
import { MotiView } from 'moti';
import { CaretRight, Gear, Lock, Plus } from 'phosphor-react-native';
import { useRef, useState } from 'react';
import { Alert, Pressable, Text, View } from 'react-native';
import { useClientContext } from '@sd/client';
import { tw, twStyle } from '~/lib/tailwind';
import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack';
import { currentLibraryStore } from '~/utils/nav';
@ -48,7 +48,7 @@ const BrowseLibraryManager = ({ style }: Props) => {
</MotiView>
</View>
</Pressable>
<AnimatedHeight style={tw`absolute top-10 z-[10] w-full`} hide={dropdownClosed}>
<AnimatedHeight style={tw`absolute top-10 z-10 w-full`} hide={dropdownClosed}>
<View
style={tw`w-full rounded-b-md border border-sidebar-line bg-sidebar-button p-2`}
>
@ -83,7 +83,7 @@ const BrowseLibraryManager = ({ style }: Props) => {
<Pressable
style={tw`flex flex-row items-center px-1.5 py-[8px]`}
onPress={() => {
modalRef.current?.present()
modalRef.current?.present();
setDropdownClosed(true);
}}
>

View file

@ -45,7 +45,7 @@ const Job = ({ progress, message, error }: JobProps) => {
: tw.color('accent');
return (
<View
style={tw`h-fit w-[310px] flex-col rounded-md border border-sidebar-line/50 bg-sidebar-box`}
style={tw`h-auto w-[310px] flex-col rounded-md border border-sidebar-line/50 bg-sidebar-box`}
>
<View
style={tw`w-full flex-row items-center justify-between rounded-t-md border-b border-sidebar-line/80 bg-mobile-header/50 px-5 py-2`}

View file

@ -31,9 +31,9 @@ export default function Header({ title, showLibrary, searchType, navBack }: Prop
};
return (
<View style={tw`relative w-full pt-10 border-b h-fit border-app-line/50 bg-mobile-header`}>
<View style={tw`justify-center w-full pb-5 mx-auto mt-5 h-fit px-7`}>
<View style={tw`flex-row items-center justify-between w-full`}>
<View style={tw`relative h-auto w-full border-b border-app-line/50 bg-mobile-header pt-10`}>
<View style={tw`mx-auto mt-5 h-auto w-full justify-center px-7 pb-5`}>
<View style={tw`w-full flex-row items-center justify-between`}>
<View style={tw`flex-row items-center gap-5`}>
{navBack && (
<Pressable

View file

@ -1,5 +1,5 @@
import { CaretRight, Icon } from 'phosphor-react-native';
import { Pressable, Text, View, ViewStyle } from 'react-native';
import { Pressable, Text, View } from 'react-native';
import { tw, twStyle } from '~/lib/tailwind';
type SettingsItemProps = {
@ -7,31 +7,42 @@ type SettingsItemProps = {
onPress?: () => void;
leftIcon?: Icon;
rightArea?: React.ReactNode;
rounded?: 'top' | 'bottom';
};
export function SettingsItem(props: SettingsItemProps) {
//due to SectionList limitation of not being able to modify each section individually
//we have to use this 'hacky' way to make the top and bottom rounded
const borderRounded =
props.rounded === 'top' ? 'rounded-t-md' : props.rounded === 'bottom' && 'rounded-b-md';
const border =
props.rounded === 'top'
? 'border-t border-r border-l border-app-input'
: props.rounded === 'bottom'
? 'border-b border-app-input border-r border-l'
: 'border-app-input border-l border-r';
return (
<Pressable onPress={props.onPress}>
<View style={tw`flex flex-row items-center justify-between bg-app-darkBox px-4`}>
<View style={tw`flex flex-row items-center py-3`}>
{props.leftIcon &&
props.leftIcon({ size: 20, color: tw.color('ink'), style: tw`mr-3` })}
<Text style={tw`text-[14px] font-medium text-ink`}>{props.title}</Text>
<View style={twStyle(' border-app-input bg-sidebar-box', borderRounded, border)}>
<View style={tw`h-auto flex-row items-center`}>
{props.leftIcon && (
<View
style={tw`ml-4 mr-5 h-8 w-8 items-center justify-center rounded-full bg-app-input`}
>
{props.leftIcon({ size: 20, color: tw.color('ink-dull') })}
</View>
)}
<View
style={twStyle(
`flex-1 flex-row items-center justify-between py-4`,
borderRounded !== 'rounded-b-md' && 'border-b border-app-input'
)}
>
<Text style={tw`text-sm font-medium text-ink`}>{props.title}</Text>
<CaretRight style={tw`mr-4`} size={16} color={tw.color('ink')} />
</View>
</View>
{props.rightArea ? (
props.rightArea
) : (
<CaretRight size={16} color={tw.color('ink')} />
)}
</View>
</Pressable>
);
}
export function SettingsItemDivider(props: { style?: ViewStyle }) {
return (
<View style={twStyle('bg-app-darkLine', props.style)}>
<View style={tw`mx-3 border-b border-b-app-darkLine`} />
</View>
);
}

View file

@ -1,4 +1,5 @@
const COLORS = require('./Colors');
const plugin = require('tailwindcss/plugin');
module.exports = function (theme) {
return {
@ -14,6 +15,5 @@ module.exports = function (theme) {
variants: {
extend: {}
},
plugins: []
};
};

View file

@ -2,8 +2,11 @@ import { BottomTabScreenProps, createBottomTabNavigator } from '@react-navigatio
import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native';
import { StackScreenProps } from '@react-navigation/stack';
import { BlurView } from 'expo-blur';
import { CirclesFour, FolderOpen, Gear, Planet } from 'phosphor-react-native';
import { useEffect, useRef, useState } from 'react';
import { StyleSheet } from 'react-native';
import { TouchableWithoutFeedback } from 'react-native-gesture-handler';
import Rive, { RiveRef } from 'rive-react-native';
import { Style } from 'twrnc/dist/esm/types';
import { tw } from '~/lib/tailwind';
import { RootStackParamList } from '.';
@ -14,7 +17,86 @@ import SettingsStack, { SettingsStackParamList } from './tabs/SettingsStack';
const Tab = createBottomTabNavigator<TabParamList>();
//TouchableWithoutFeedback is used to prevent Android ripple effect
//State is being used to control the animation and make Rive work
//Tab.Screen listeners are needed because if a user taps on the tab text only, the animation won't play
//This may be revisted in the future to update accordingly
export default function TabNavigator() {
const [activeIndex, setActiveIndex] = useState(0);
const TabScreens: {
name: keyof TabParamList;
component: () => React.JSX.Element;
icon: React.ReactNode;
label: string;
labelStyle: Style;
testID: string;
}[] = [
{
name: 'OverviewStack',
component: OverviewStack,
icon: (
<TabBarButton
resourceName="tabs"
animationName="animate"
artboardName="overview"
style={{ width: 28 }}
active={activeIndex === 0}
/>
),
label: 'Overview',
labelStyle: tw`text-[10px] font-semibold`,
testID: 'overview-tab'
},
{
name: 'NetworkStack',
component: NetworkStack,
icon: (
<TabBarButton
resourceName="tabs"
animationName="animate"
artboardName="network"
style={{ width: 18, maxHeight: 23 }}
active={activeIndex === 1}
/>
),
label: 'Network',
labelStyle: tw`text-[10px] font-semibold`,
testID: 'network-tab'
},
{
name: 'BrowseStack',
component: BrowseStack,
icon: (
<TabBarButton
resourceName="tabs"
animationName="animate"
artboardName="browse"
style={{ width: 20 }}
active={activeIndex === 2}
/>
),
label: 'Browse',
labelStyle: tw`text-[10px] font-semibold`,
testID: 'browse-tab'
},
{
name: 'SettingsStack',
component: SettingsStack,
icon: (
<TabBarButton
resourceName="tabs"
animationName="animate"
artboardName="settings"
style={{ width: 19 }}
active={activeIndex === 3}
/>
),
label: 'Settings',
labelStyle: tw`text-[10px] font-semibold`,
testID: 'settings-tab'
}
];
return (
<Tab.Navigator
id="tab"
@ -34,72 +116,63 @@ export default function TabNavigator() {
tabBarInactiveTintColor: tw.color('ink-faint')
}}
>
<Tab.Screen
name="OverviewStack"
component={OverviewStack}
options={{
tabBarIcon: ({ focused }) => (
<Planet
size={22}
weight={focused ? 'bold' : 'regular'}
color={focused ? tw.color('accent') : tw.color('ink-dull')}
/>
),
tabBarLabel: 'Overview',
tabBarLabelStyle: tw`text-[10px] font-semibold`
}}
/>
<Tab.Screen
name="NetworkStack"
component={NetworkStack}
options={{
tabBarIcon: ({ focused }) => (
<CirclesFour
size={22}
weight={focused ? 'bold' : 'regular'}
color={focused ? tw.color('accent') : tw.color('ink-dull')}
/>
),
tabBarLabel: 'Network',
tabBarLabelStyle: tw`text-[10px] font-semibold`
}}
/>
<Tab.Screen
name="BrowseStack"
component={BrowseStack}
options={{
tabBarIcon: ({ focused }) => (
<FolderOpen
size={22}
weight={focused ? 'bold' : 'regular'}
color={focused ? tw.color('accent') : tw.color('ink-dull')}
/>
),
tabBarTestID: 'browse-tab',
tabBarLabel: 'Browse',
tabBarLabelStyle: tw`text-[10px] font-semibold`
}}
/>
<Tab.Screen
name="SettingsStack"
component={SettingsStack}
options={{
tabBarIcon: ({ focused }) => (
<Gear
size={22}
weight={focused ? 'bold' : 'regular'}
color={focused ? tw.color('accent') : tw.color('ink-dull')}
/>
),
tabBarTestID: 'settings-tab',
tabBarLabel: 'Settings',
tabBarLabelStyle: tw`text-[10px] font-semibold`
}}
/>
{TabScreens.map((screen, index) => (
<Tab.Screen
key={screen.name + index}
name={screen.name}
component={screen.component}
options={{
tabBarLabel: screen.label,
tabBarLabelStyle: screen.labelStyle,
tabBarIcon: () => (
<TouchableWithoutFeedback>{screen.icon}</TouchableWithoutFeedback>
),
tabBarTestID: screen.testID
}}
listeners={() => ({
tabPress: () => {
setActiveIndex(index);
}
})}
/>
))}
</Tab.Navigator>
);
}
interface TabBarButtonProps {
active: boolean;
resourceName: string;
animationName: string;
artboardName: string;
style?: any;
}
const TabBarButton = ({
active,
resourceName,
animationName,
artboardName,
style
}: TabBarButtonProps) => {
const ref = useRef<RiveRef>(null);
useEffect(() => {
if (active && ref.current) {
ref.current?.play();
} else ref.current?.stop();
}, [active]);
return (
<Rive
ref={ref}
autoplay={active}
resourceName={resourceName}
animationName={animationName}
artboardName={artboardName}
style={style}
/>
);
};
export type TabParamList = {
OverviewStack: NavigatorScreenParams<OverviewStackParamList>;
NetworkStack: NavigatorScreenParams<NetworkStackParamList>;

View file

@ -92,7 +92,7 @@ const LocationItem: React.FC<LocationItemProps> = ({
return (
<Pressable onPress={onPress}>
<View
style={tw`flex-row justify-between w-full gap-3 p-2 border rounded-md h-fit border-sidebar-line/50 bg-sidebar-box`}
style={tw`h-auto w-full flex-row justify-between gap-3 rounded-md border border-sidebar-line/50 bg-sidebar-box p-2`}
>
<View style={tw`flex-row items-center gap-2`}>
<View style={tw`relative`}>
@ -105,7 +105,7 @@ const LocationItem: React.FC<LocationItemProps> = ({
/>
</View>
<Text
style={tw`w-fit max-w-[160px] truncate text-sm font-bold text-white`}
style={tw`w-auto max-w-[160px] truncate text-sm font-bold text-white`}
numberOfLines={1}
>
{location.name}
@ -114,7 +114,7 @@ const LocationItem: React.FC<LocationItemProps> = ({
<View style={tw`flex-row items-center gap-3`}>
<View style={tw`rounded-md bg-app-input p-1.5`}>
<Text
style={tw`text-xs font-bold text-left truncate text-ink-dull`}
style={tw`truncate text-left text-xs font-bold text-ink-dull`}
numberOfLines={1}
>
{`${byteSize(location.size_in_bytes)}`}

View file

@ -1,26 +1,3 @@
import { useFeatureFlag, useP2PEvents } from '@sd/client';
export function P2P() {
// const pairingResponse = useBridgeMutation('p2p.pairingResponse');
// const activeLibrary = useLibraryContext();
const pairingEnabled = useFeatureFlag('p2pPairing');
useP2PEvents((data) => {
if (data.type === 'PairingRequest' && pairingEnabled) {
console.log('Pairing incoming from', data.name);
// TODO: open pairing screen and guide user through the process. For now we auto-accept
// pairingResponse.mutate([
// data.id,
// { decision: 'accept', libraryId: activeLibrary.library.uuid }
// ]);
}
// TODO: For now until UI is implemented
if (data.type === 'PairingProgress') {
console.log('Pairing progress', data);
}
});
return null;
}

View file

@ -15,7 +15,7 @@ import {
import React from 'react';
import { SectionList, Text, TouchableWithoutFeedback, View } from 'react-native';
import { DebugState, useDebugState, useDebugStateEnabler } from '@sd/client';
import { SettingsItem, SettingsItemDivider } from '~/components/settings/SettingsItem';
import { SettingsItem } from '~/components/settings/SettingsItem';
import { tw, twStyle } from '~/lib/tailwind';
import { SettingsStackParamList, SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack';
@ -25,6 +25,7 @@ type SectionType = {
title: string;
icon: Icon;
navigateTo: keyof SettingsStackParamList;
rounded?: 'top' | 'bottom';
}[];
};
@ -35,7 +36,8 @@ const sections: (debugState: DebugState) => SectionType[] = (debugState) => [
{
icon: GearSix,
navigateTo: 'GeneralSettings',
title: 'General'
title: 'General',
rounded: 'top'
},
{
icon: Books,
@ -55,7 +57,8 @@ const sections: (debugState: DebugState) => SectionType[] = (debugState) => [
{
icon: PuzzlePiece,
navigateTo: 'ExtensionsSettings',
title: 'Extensions'
title: 'Extensions',
rounded: 'bottom'
}
]
},
@ -65,7 +68,8 @@ const sections: (debugState: DebugState) => SectionType[] = (debugState) => [
{
icon: GearSix,
navigateTo: 'LibraryGeneralSettings',
title: 'General'
title: 'General',
rounded: 'top'
},
{
icon: HardDrive,
@ -80,7 +84,8 @@ const sections: (debugState: DebugState) => SectionType[] = (debugState) => [
{
icon: TagSimple,
navigateTo: 'TagsSettings',
title: 'Tags'
title: 'Tags',
rounded: 'bottom'
}
// {
// icon: Key,
@ -95,19 +100,22 @@ const sections: (debugState: DebugState) => SectionType[] = (debugState) => [
{
icon: FlyingSaucer,
navigateTo: 'About',
title: 'About'
title: 'About',
rounded: 'top'
},
{
icon: Heart,
navigateTo: 'Support',
title: 'Support'
title: 'Support',
rounded: !debugState.enabled ? 'bottom' : undefined
},
...(debugState.enabled
? ([
{
icon: Gear,
navigateTo: 'Debug',
title: 'Debug'
title: 'Debug',
rounded: 'bottom'
}
] as const)
: [])
@ -119,7 +127,7 @@ function renderSectionHeader({ section }: { section: { title: string } }) {
return (
<Text
style={twStyle(
'mb-2 ml-2 text-sm font-bold text-ink',
'mb-4 text-md font-bold text-ink',
section.title === 'Client' ? 'mt-2' : 'mt-5'
)}
>
@ -132,16 +140,16 @@ export default function SettingsScreen({ navigation }: SettingsStackScreenProps<
const debugState = useDebugState();
return (
<View style={tw`flex-1`}>
<View style={tw`flex-1 bg-mobile-screen px-7`}>
<SectionList
sections={sections(debugState)}
contentContainerStyle={tw`py-4`}
ItemSeparatorComponent={SettingsItemDivider}
contentContainerStyle={tw`py-5`}
renderItem={({ item }) => (
<SettingsItem
title={item.title}
leftIcon={item.icon}
onPress={() => navigation.navigate(item.navigateTo as any)}
rounded={item.rounded}
/>
)}
renderSectionHeader={renderSectionHeader}

View file

@ -14,9 +14,6 @@ const DebugScreen = ({ navigation }: SettingsStackScreenProps<'Debug'>) => {
<View style={tw`flex-1 p-4`}>
<Card style={tw`gap-y-4 bg-app-box`}>
<Text style={tw`font-semibold text-ink`}>Debug</Text>
<Button onPress={() => toggleFeatureFlag(['p2pPairing'])}>
<Text style={tw`text-ink`}>Toggle P2P</Text>
</Button>
<Button onPress={() => (debugState.rspcLogger = !debugState.rspcLogger)}>
<Text style={tw`text-ink`}>Toggle rspc logger</Text>
</Button>

View file

@ -7,11 +7,6 @@ import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack';
const NodesSettingsScreen = ({ navigation }: SettingsStackScreenProps<'NodesSettings'>) => {
const onlineNodes = useDiscoveredPeers();
const p2pPair = useBridgeMutation('p2p.pair', {
onSuccess(data) {
console.log(data);
}
});
return (
<View>
@ -20,25 +15,6 @@ const NodesSettingsScreen = ({ navigation }: SettingsStackScreenProps<'NodesSett
{[...onlineNodes.entries()].map(([id, node]) => (
<View key={id} style={tw`flex`}>
<Text style={tw`text-ink`}>{node.name}</Text>
<Button
onPress={() => {
if (!isEnabled('p2pPairing')) {
alert('P2P Pairing is not enabled!');
}
// TODO: This is not great
p2pPair.mutateAsync(id).then((id) => {
// TODO: Show UI lmao
// startPairing(id, {
// name: node.name,
// os: node.operating_system
// });
});
}}
>
<Text>Pair</Text>
</Button>
</View>
))}
</View>

View file

@ -1,13 +1,3 @@
appId: com.spacedrive.app
---
- launchApp
- tapOn:
id: 'browse-tab'
- tapOn:
id: 'add-tag'
- tapOn:
id: 'create-tag-name'
- inputText: 'MyTag'
- tapOn: Create
- assertVisible:
id: 'browse-tag'

View file

@ -10,6 +10,3 @@ appId: com.spacedrive.app
- tapOn:
id: 'share-minimal'
- tapOn: 'Continue'
- tapOn:
id: 'browse-tab'
- assertVisible: 'TestLib'

View file

@ -112,6 +112,7 @@ function App() {
<SpacedriveRouterProvider
routing={{
...router,
tabId: '',
routes,
visible: true
}}

View file

@ -1,6 +1,6 @@
[package]
name = "sd-core"
version = "0.2.0"
version = "0.2.3"
description = "Virtual distributed filesystem engine that powers Spacedrive."
authors = ["Spacedrive Technology Inc."]
rust-version = "1.73.0"
@ -122,6 +122,8 @@ tar = "0.4.40"
aws-sdk-s3 = { version = "1.5.0", features = ["behavior-version-latest"] }
aws-config = "1.0.3"
aws-credential-types = "1.0.3"
base91 = "0.1.0"
sd-actors = { version = "0.1.0", path = "../crates/actors" }
# Override features of transitive dependencies
[dependencies.openssl]

View file

@ -116,28 +116,30 @@ impl Actor {
}
}
// where the magic happens
async fn receive_crdt_operation(&mut self, op: CRDTOperation) {
// first, we update the HLC's timestamp with the incoming one.
// this involves a drift check + sets the last time of the clock
self.clock
.update_with_timestamp(&Timestamp::new(op.timestamp, op.instance.into()))
.ok();
.expect("timestamp has too much drift!");
let mut timestamp = {
let mut clocks = self.timestamps.write().await;
*clocks.entry(op.instance).or_insert_with(|| op.timestamp)
};
if timestamp < op.timestamp {
timestamp = op.timestamp;
}
// read the timestamp for the operation's instance, or insert one if it doesn't exist
let timestamp = self.timestamps.write().await.get(&op.instance).cloned();
// copy some fields bc rust ownership
let op_instance = op.instance;
let op_timestamp = op.timestamp;
let is_old = self.compare_message(&op).await;
if !is_old {
if !self.is_operation_old(&op).await {
// actually go and apply the operation in the db
self.apply_op(op).await.ok();
self.timestamps.write().await.insert(op_instance, timestamp);
// update the stored timestamp for this instance - will be derived from the crdt operations table on restart
self.timestamps.write().await.insert(
op_instance,
NTP64::max(timestamp.unwrap_or_default(), op_timestamp),
);
}
}
@ -145,11 +147,13 @@ impl Actor {
self.db
._transaction()
.run(|db| async move {
// apply the operation to the actual record
ModelSyncData::from_op(op.clone())
.unwrap()
.exec(&db)
.await?;
// write the operation to the operations table
write_crdt_op_to_db(&op, &db).await?;
Ok(())
@ -161,7 +165,8 @@ impl Actor {
Ok(())
}
async fn compare_message(&mut self, op: &CRDTOperation) -> bool {
// determines if an operation is old and shouldn't be applied
async fn is_operation_old(&mut self, op: &CRDTOperation) -> bool {
let db = &self.db;
let old_timestamp = {

View file

@ -28,9 +28,15 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.procedure("setApiOrigin", {
R.mutation(|node, origin: String| async move {
let mut origin_env = node.env.api_url.lock().await;
*origin_env = origin;
*origin_env = origin.clone();
node.config.write(|c| c.auth_token = None).await.ok();
node.config
.write(|c| {
c.auth_token = None;
c.sd_api_origin = Some(origin);
})
.await
.ok();
Ok(())
})
@ -38,6 +44,8 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
}
mod library {
use crate::{node::Platform, util::MaybeUndefined};
use super::*;
pub fn mount() -> AlphaRouter<Ctx> {
@ -59,14 +67,26 @@ mod library {
.procedure("create", {
R.with2(library())
.mutation(|(node, library), _: ()| async move {
sd_cloud_api::library::create(
let node_config = node.config.get().await;
let cloud_library = sd_cloud_api::library::create(
node.cloud_api_config().await,
library.id,
&library.config().await.name,
library.instance_uuid,
&library.identity.to_remote_identity(),
library.identity.to_remote_identity(),
node_config.id,
&node_config.name,
Platform::current().into(),
)
.await?;
node.libraries
.edit(
library.id,
None,
MaybeUndefined::Undefined,
MaybeUndefined::Value(cloud_library.id),
)
.await?;
invalidate_query!(library, "cloud.library.get");
@ -101,21 +121,53 @@ mod library {
&node,
)
.await?;
node.libraries
.edit(
library.id,
None,
MaybeUndefined::Undefined,
MaybeUndefined::Value(cloud_library.id),
)
.await?;
sd_cloud_api::library::join(
let node_config = node.config.get().await;
let instances = sd_cloud_api::library::join(
node.cloud_api_config().await,
library_id,
library.instance_uuid,
&library.identity.to_remote_identity(),
library.identity.to_remote_identity(),
node_config.id,
&node_config.name,
Platform::current().into(),
)
.await?;
for instance in instances {
crate::cloud::sync::receive::create_instance(
&library,
&node.libraries,
instance.uuid,
instance.identity,
instance.node_id,
instance.node_name,
instance.node_platform,
)
.await?;
}
invalidate_query!(library, "cloud.library.get");
invalidate_query!(library, "cloud.library.list");
Ok(LibraryConfigWrapped::from_library(&library).await)
})
})
.procedure("sync", {
R.with2(library())
.mutation(|(_, library), _: ()| async move {
library.do_cloud_sync();
Ok(())
})
})
}
}

View file

@ -24,7 +24,6 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
Ok(library.db.label().find_many(vec![]).exec().await?)
})
})
//
.procedure("listWithThumbnails", {
R.with2(library())
.query(|(_, library), cursor: label::name::Type| async move {

View file

@ -336,7 +336,10 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
name,
description,
}: EditLibraryArgs| async move {
Ok(node.libraries.edit(id, name, description).await?)
Ok(node
.libraries
.edit(id, name, description, MaybeUndefined::Undefined)
.await?)
},
)
})

View file

@ -393,7 +393,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
debug!("Disconnected {count} file paths from objects");
library.orphan_remover.invoke().await;
// library.orphan_remover.invoke().await;
}
// rescan location

View file

@ -9,7 +9,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
#[cfg(not(feature = "ai"))]
return Err(rspc::Error::new(
rspc::ErrorCode::MethodNotSupported,
"AI feature is not aviailable".to_string(),
"AI feature is not available".to_string(),
));
#[cfg(feature = "ai")]

View file

@ -1,4 +1,4 @@
use crate::p2p::{operations, P2PEvent, PairingDecision};
use crate::p2p::{operations, P2PEvent};
use sd_p2p::spacetunnel::RemoteIdentity;
@ -47,10 +47,13 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
})
})
})
// TODO: This has a potentially invalid map key and Specta don't like that. Can bring back in another PR.
// .procedure("state", {
// R.query(|node, _: ()| async move { Ok(node.p2p.state()) })
// })
.procedure("state", {
R.query(|node, _: ()| async move {
// TODO: This has a potentially invalid map key and Specta don't like that.
// TODO: This will bypass that check and for an debug route that's fine.
Ok(serde_json::to_value(node.p2p.state()).unwrap())
})
})
.procedure("spacedrop", {
#[derive(Type, Deserialize)]
pub struct SpacedropArgs {
@ -87,18 +90,6 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.mutation(|node, id: Uuid| async move {
node.p2p.cancel_spacedrop(id).await;
Ok(())
})
})
.procedure("pair", {
R.mutation(|node, id: RemoteIdentity| async move {
Ok(node.p2p.pairing.clone().originator(id, node).await)
})
})
.procedure("pairingResponse", {
R.mutation(|node, (pairing_id, decision): (u16, PairingDecision)| {
node.p2p.pairing.decision(pairing_id, decision);
Ok(())
})
})

View file

@ -1,15 +1,11 @@
use crate::cloud::sync::err_return;
use super::err_return;
use std::sync::Arc;
use tokio::sync::Notify;
use tracing::info;
use super::Library;
pub async fn run_actor((library, notify): (Arc<Library>, Arc<Notify>)) {
let Library { sync, .. } = library.as_ref();
pub async fn run_actor(sync: Arc<sd_core_sync::Manager>, notify: Arc<Notify>) {
loop {
{
let mut rx = sync.ingest.req_rx.lock().await;
@ -21,11 +17,11 @@ pub async fn run_actor((library, notify): (Arc<Library>, Arc<Notify>)) {
.await
.is_ok()
{
use crate::sync::ingest::*;
while let Some(req) = rx.recv().await {
const OPS_PER_REQUEST: u32 = 1000;
use sd_core_sync::*;
let timestamps = match req {
Request::FinishedIngesting => break,
Request::Messages { timestamps } => timestamps,
@ -33,7 +29,7 @@ pub async fn run_actor((library, notify): (Arc<Library>, Arc<Notify>)) {
};
let ops = err_return!(
sync.get_cloud_ops(crate::sync::GetOpsArgs {
sync.get_cloud_ops(GetOpsArgs {
clocks: timestamps,
count: OPS_PER_REQUEST,
})
@ -46,7 +42,7 @@ pub async fn run_actor((library, notify): (Arc<Library>, Arc<Notify>)) {
sync.ingest
.event_tx
.send(sd_core_sync::Event::Messages(MessagesEvent {
instance_id: library.sync.instance,
instance_id: sync.instance,
has_more: ops.len() == 1000,
messages: ops,
}))

View file

@ -1,14 +1,15 @@
use crate::{library::Library, Node};
use sd_sync::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::{atomic, Arc};
use tokio::sync::Notify;
use uuid::Uuid;
use std::sync::{atomic, Arc};
use crate::{library::Library, Node};
mod ingest;
mod receive;
mod send;
pub mod ingest;
pub mod receive;
pub mod send;
pub async fn declare_actors(library: &Arc<Library>, node: &Arc<Node>) {
let ingest_notify = Arc::new(Notify::new());
@ -16,25 +17,58 @@ pub async fn declare_actors(library: &Arc<Library>, node: &Arc<Node>) {
let autorun = node.cloud_sync_flag.load(atomic::Ordering::Relaxed);
let args = (library.clone(), node.clone());
actors
.declare("Cloud Sync Sender", move || send::run_actor(args), autorun)
.await;
let args = (library.clone(), node.clone(), ingest_notify.clone());
actors
.declare(
"Cloud Sync Receiver",
move || receive::run_actor(args),
"Cloud Sync Sender",
{
let library = library.clone();
let node = node.clone();
move || {
send::run_actor(
library.db.clone(),
library.id,
library.sync.clone(),
node.clone(),
)
}
},
autorun,
)
.await;
actors
.declare(
"Cloud Sync Receiver",
{
let library = library.clone();
let node = node.clone();
let ingest_notify = ingest_notify.clone();
move || {
receive::run_actor(
library.clone(),
node.libraries.clone(),
library.db.clone(),
library.id,
library.instance_uuid,
library.sync.clone(),
node.clone(),
ingest_notify,
)
}
},
autorun,
)
.await;
let args = (library.clone(), ingest_notify);
actors
.declare(
"Cloud Sync Ingest",
move || ingest::run_actor(args),
{
let library = library.clone();
move || ingest::run_actor(library.sync.clone(), ingest_notify)
},
autorun,
)
.await;
@ -64,9 +98,7 @@ macro_rules! err_return {
}
};
}
pub(crate) use err_return;
use tokio::sync::Notify;
pub type CompressedCRDTOperationsForModel = Vec<(Value, Vec<CompressedCRDTOperation>)>;

View file

@ -1,13 +1,12 @@
use crate::{
cloud::sync::{err_break, err_return, CompressedCRDTOperations},
library::Library,
Node,
};
use crate::library::{Libraries, Library};
use super::{err_break, err_return, CompressedCRDTOperations};
use sd_cloud_api::RequestConfigProvider;
use sd_core_sync::NTP64;
use sd_p2p::spacetunnel::{IdentityOrRemoteIdentity, RemoteIdentity};
use sd_prisma::prisma::{cloud_crdt_operation, instance, PrismaClient, SortOrder};
use sd_sync::CRDTOperation;
use sd_utils::{from_bytes_to_uuid, uuid_to_bytes};
use sd_utils::uuid_to_bytes;
use tracing::info;
use std::{
@ -22,84 +21,89 @@ use serde_json::to_vec;
use tokio::{sync::Notify, time::sleep};
use uuid::Uuid;
pub async fn run_actor((library, node, ingest_notify): (Arc<Library>, Arc<Node>, Arc<Notify>)) {
let db = &library.db;
let library_id = library.id;
let mut cloud_timestamps = {
let timestamps = library.sync.timestamps.read().await;
let batch = timestamps
.keys()
.map(|id| {
db.cloud_crdt_operation()
.find_first(vec![cloud_crdt_operation::instance::is(vec![
instance::pub_id::equals(uuid_to_bytes(*id)),
])])
.order_by(cloud_crdt_operation::timestamp::order(SortOrder::Desc))
})
.collect::<Vec<_>>();
err_return!(db._batch(batch).await)
.into_iter()
.zip(timestamps.keys())
.map(|(d, id)| {
let cloud_timestamp = NTP64(d.map(|d| d.timestamp).unwrap_or_default() as u64);
let sync_timestamp = *timestamps
.get(id)
.expect("unable to find matching timestamp");
let max_timestamp = Ord::max(cloud_timestamp, sync_timestamp);
(*id, max_timestamp)
})
.collect::<HashMap<_, _>>()
};
info!(
"Fetched timestamps for {} local instances",
cloud_timestamps.len()
);
pub async fn run_actor(
library: Arc<Library>,
libraries: Arc<Libraries>,
db: Arc<PrismaClient>,
library_id: Uuid,
instance_uuid: Uuid,
sync: Arc<sd_core_sync::Manager>,
cloud_api_config_provider: Arc<impl RequestConfigProvider>,
ingest_notify: Arc<Notify>,
) {
loop {
let instances = err_break!(
db.instance()
.find_many(vec![])
.select(instance::select!({ pub_id }))
.exec()
loop {
let mut cloud_timestamps = {
let timestamps = sync.timestamps.read().await;
err_return!(
db._batch(
timestamps
.keys()
.map(|id| {
db.cloud_crdt_operation()
.find_first(vec![cloud_crdt_operation::instance::is(vec![
instance::pub_id::equals(uuid_to_bytes(*id)),
])])
.order_by(cloud_crdt_operation::timestamp::order(
SortOrder::Desc,
))
})
.collect::<Vec<_>>()
)
.await
)
.into_iter()
.zip(timestamps.iter())
.map(|(d, (id, sync_timestamp))| {
let cloud_timestamp = NTP64(d.map(|d| d.timestamp).unwrap_or_default() as u64);
let max_timestamp = Ord::max(cloud_timestamp, *sync_timestamp);
(*id, max_timestamp)
})
.collect::<HashMap<_, _>>()
};
info!(
"Fetched timestamps for {} local instances",
cloud_timestamps.len()
);
let instance_timestamps = sync
.timestamps
.read()
.await
);
.keys()
.map(
|uuid| sd_cloud_api::library::message_collections::get::InstanceTimestamp {
instance_uuid: *uuid,
from_time: cloud_timestamps
.get(&uuid)
.cloned()
.unwrap_or_default()
.as_u64()
.to_string(),
},
)
.collect();
{
let collections = {
use sd_cloud_api::library::message_collections;
message_collections::get(
node.cloud_api_config().await,
let collections = err_break!(
sd_cloud_api::library::message_collections::get(
cloud_api_config_provider.get_request_config().await,
library_id,
library.instance_uuid,
instances
.into_iter()
.map(|i| {
let uuid = from_bytes_to_uuid(&i.pub_id);
message_collections::get::InstanceTimestamp {
instance_uuid: uuid,
from_time: cloud_timestamps
.get(&uuid)
.cloned()
.unwrap_or_default()
.as_u64()
.to_string(),
}
})
.collect::<Vec<_>>(),
instance_uuid,
instance_timestamps,
)
.await
};
let collections = err_break!(collections);
);
info!("Received {} collections", collections.len());
if collections.is_empty() {
break;
}
let mut cloud_library_data: Option<Option<sd_cloud_api::Library>> = None;
for collection in collections {
@ -108,8 +112,8 @@ pub async fn run_actor((library, node, ingest_notify): (Arc<Library>, Arc<Node>,
None => {
let Some(fetched_library) = err_break!(
sd_cloud_api::library::get(
node.cloud_api_config().await,
library.id
cloud_api_config_provider.get_request_config().await,
library_id
)
.await
) else {
@ -137,9 +141,13 @@ pub async fn run_actor((library, node, ingest_notify): (Arc<Library>, Arc<Node>,
err_break!(
create_instance(
db,
&library,
&libraries,
collection.instance_uuid,
err_break!(BASE64_STANDARD.decode(instance.identity.clone()))
instance.identity,
instance.node_id,
instance.node_name.clone(),
instance.node_platform,
)
.await
);
@ -152,7 +160,7 @@ pub async fn run_actor((library, node, ingest_notify): (Arc<Library>, Arc<Node>,
&BASE64_STANDARD.decode(collection.contents)
)));
err_break!(write_cloud_ops_to_db(compressed_operations.into_ops(), db).await);
err_break!(write_cloud_ops_to_db(compressed_operations.into_ops(), &db).await);
let collection_timestamp =
NTP64(collection.end_time.parse().expect("unable to parse time"));
@ -196,20 +204,26 @@ fn crdt_op_db(op: &CRDTOperation) -> cloud_crdt_operation::Create {
}
}
async fn create_instance(
db: &PrismaClient,
pub async fn create_instance(
library: &Arc<Library>,
libraries: &Libraries,
uuid: Uuid,
identity: Vec<u8>,
identity: RemoteIdentity,
node_id: Uuid,
node_name: String,
node_platform: u8,
) -> prisma_client_rust::Result<()> {
db.instance()
library
.db
.instance()
.upsert(
instance::pub_id::equals(uuid_to_bytes(uuid)),
instance::create(
uuid_to_bytes(uuid),
identity,
vec![],
"".to_string(),
0,
IdentityOrRemoteIdentity::RemoteIdentity(identity).to_bytes(),
node_id.as_bytes().to_vec(),
node_name,
node_platform as i32,
Utc::now().into(),
Utc::now().into(),
vec![],
@ -219,5 +233,10 @@ async fn create_instance(
.exec()
.await?;
library.sync.timestamps.write().await.insert(uuid, NTP64(0));
// Called again so the new instances are picked up
libraries.update_instances(library.clone()).await;
Ok(())
}

View file

@ -1,19 +1,23 @@
use crate::{cloud::sync::CompressedCRDTOperations, Node};
use super::CompressedCRDTOperations;
use sd_cloud_api::RequestConfigProvider;
use sd_core_sync::{GetOpsArgs, SyncMessage, NTP64};
use sd_prisma::prisma::instance;
use sd_prisma::prisma::{instance, PrismaClient};
use sd_utils::from_bytes_to_uuid;
use uuid::Uuid;
use std::{sync::Arc, time::Duration};
use tokio::time::sleep;
use super::{err_break, Library};
pub async fn run_actor((library, node): (Arc<Library>, Arc<Node>)) {
let db = &library.db;
let library_id = library.id;
use super::err_break;
pub async fn run_actor(
db: Arc<PrismaClient>,
library_id: Uuid,
sync: Arc<sd_core_sync::Manager>,
cloud_api_config_provider: Arc<impl RequestConfigProvider>,
) {
loop {
loop {
let instances = err_break!(
@ -29,7 +33,7 @@ pub async fn run_actor((library, node): (Arc<Library>, Arc<Node>)) {
let req_adds = err_break!(
sd_cloud_api::library::message_collections::request_add(
node.cloud_api_config().await,
cloud_api_config_provider.get_request_config().await,
library_id,
instances,
)
@ -42,22 +46,20 @@ pub async fn run_actor((library, node): (Arc<Library>, Arc<Node>)) {
for req_add in req_adds {
let ops = err_break!(
library
.sync
.get_ops(GetOpsArgs {
count: 1000,
clocks: vec![(
req_add.instance_uuid,
NTP64(
req_add
.from_time
.unwrap_or_else(|| "0".to_string())
.parse()
.expect("couldn't parse ntp64 value"),
),
)],
})
.await
sync.get_ops(GetOpsArgs {
count: 1000,
clocks: vec![(
req_add.instance_uuid,
NTP64(
req_add
.from_time
.unwrap_or_else(|| "0".to_string())
.parse()
.expect("couldn't parse ntp64 value"),
),
)],
})
.await
);
if ops.is_empty() {
@ -81,12 +83,19 @@ pub async fn run_actor((library, node): (Arc<Library>, Arc<Node>)) {
break;
}
err_break!(do_add(node.cloud_api_config().await, library_id, instances,).await);
err_break!(
do_add(
cloud_api_config_provider.get_request_config().await,
library_id,
instances,
)
.await
);
}
{
// recreate subscription each time so that existing messages are dropped
let mut rx = library.sync.subscribe();
let mut rx = sync.subscribe();
// wait until Created message comes in
loop {

View file

@ -2,14 +2,17 @@ use crate::{
api::{utils::InvalidateOperationEvent, CoreEvent},
library::Library,
object::media::thumbnail::WEBP_EXTENSION,
p2p::{operations, IdentityOrRemoteIdentity},
p2p::operations,
util::InfallibleResponse,
Node,
};
use sd_file_ext::text::is_text;
use sd_file_path_helper::{file_path_to_handle_custom_uri, IsolatedFilePathData};
use sd_p2p::{spaceblock::Range, spacetunnel::RemoteIdentity};
use sd_p2p::{
spaceblock::Range,
spacetunnel::{IdentityOrRemoteIdentity, RemoteIdentity},
};
use sd_prisma::prisma::{file_path, location};
use sd_utils::db::maybe_missing;

View file

@ -100,14 +100,20 @@ impl Node {
.await
.map_err(NodeError::FailedToInitializeConfig)?;
if let Some(url) = config.get().await.sd_api_origin {
*env.api_url.lock().await = url;
}
#[cfg(feature = "ai")]
sd_ai::init()?;
#[cfg(feature = "ai")]
let image_labeler_version = config.get().await.image_labeler_version;
let image_labeler_version = {
sd_ai::init()?;
config.get().await.image_labeler_version
};
let (locations, locations_actor) = location::Locations::new();
let (jobs, jobs_actor) = job::Jobs::new();
let libraries = library::Libraries::new(data_dir.join("libraries")).await?;
let (p2p, p2p_actor) = p2p::P2PManager::new(config.clone(), libraries.clone()).await?;
let node = Arc::new(Node {
data_dir: data_dir.to_path_buf(),
@ -297,6 +303,12 @@ impl Node {
}
}
impl sd_cloud_api::RequestConfigProvider for Node {
async fn get_request_config(self: &Arc<Self>) -> sd_cloud_api::RequestConfig {
Node::cloud_api_config(self).await
}
}
/// Error type for Node related errors.
#[derive(Error, Debug)]
pub enum NodeError {

View file

@ -1,10 +1,9 @@
use crate::{
node::{config::NodeConfig, Platform},
p2p::IdentityOrRemoteIdentity,
util::version_manager::{Kind, ManagedVersion, VersionManager, VersionManagerError},
};
use sd_p2p::spacetunnel::Identity;
use sd_p2p::spacetunnel::{Identity, IdentityOrRemoteIdentity};
use sd_prisma::prisma::{file_path, indexer_rule, instance, location, node, PrismaClient};
use sd_utils::{db::maybe_missing, error::FileIOError};
@ -33,7 +32,10 @@ pub struct LibraryConfig {
pub description: Option<String>,
/// id of the current instance so we know who this `.db` is. This can be looked up within the `Instance` table.
pub instance_id: i32,
/// cloud_id is the ID of the cloud library this library is linked to.
/// If this is set we can assume the library is synced with the Cloud.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cloud_id: Option<String>,
version: LibraryConfigVersion,
}
@ -83,6 +85,7 @@ impl LibraryConfig {
description,
instance_id,
version: Self::LATEST_VERSION,
cloud_id: None,
};
this.save(path).await.map(|()| this)

View file

@ -1,8 +1,4 @@
use crate::{
api::CoreEvent,
object::{media::thumbnail::get_indexed_thumbnail_path, orphan_remover::OrphanRemoverActor},
sync, Node,
};
use crate::{api::CoreEvent, object::media::thumbnail::get_indexed_thumbnail_path, sync, Node};
use sd_file_path_helper::{file_path_to_full_path, IsolatedFilePathData};
use sd_p2p::spacetunnel::Identity;
@ -20,7 +16,7 @@ use tokio::{fs, io, sync::broadcast, sync::RwLock};
use tracing::warn;
use uuid::Uuid;
use super::{Actors, LibraryConfig, LibraryManagerError};
use super::{LibraryConfig, LibraryManagerError};
// TODO: Finish this
// pub enum LibraryNew {
@ -43,17 +39,18 @@ pub struct Library {
// pub key_manager: Arc<KeyManager>,
/// p2p identity
pub identity: Arc<Identity>,
pub orphan_remover: OrphanRemoverActor,
// pub orphan_remover: OrphanRemoverActor,
// The UUID which matches `config.instance_id`'s primary key.
pub instance_uuid: Uuid,
do_cloud_sync: broadcast::Sender<()>,
pub env: Arc<crate::env::Env>,
// Look, I think this shouldn't be here but our current invalidation system needs it.
// TODO(@Oscar): Get rid of this with the new invalidation system.
event_bus_tx: broadcast::Sender<CoreEvent>,
pub actors: Arc<Actors>,
pub actors: Arc<sd_actors::Actors>,
}
impl Debug for Library {
@ -78,6 +75,7 @@ impl Library {
db: Arc<PrismaClient>,
node: &Arc<Node>,
sync: Arc<sync::Manager>,
do_cloud_sync: broadcast::Sender<()>,
) -> Arc<Self> {
Arc::new(Self {
id,
@ -86,8 +84,9 @@ impl Library {
db: db.clone(),
// key_manager,
identity,
orphan_remover: OrphanRemoverActor::spawn(db),
// orphan_remover: OrphanRemoverActor::spawn(db),
instance_uuid,
do_cloud_sync,
env: node.env.clone(),
event_bus_tx: node.event_bus.0.clone(),
actors: Default::default(),
@ -171,4 +170,10 @@ impl Library {
Ok(out)
}
pub fn do_cloud_sync(&self) {
if let Err(e) = self.do_cloud_sync.send(()) {
warn!("Error sending cloud resync message: {e:?}");
}
}
}

View file

@ -1,9 +1,9 @@
use crate::{
library::LibraryConfigError,
location::{indexer, LocationManagerError},
p2p::IdentityOrRemoteIdentityErr,
};
use sd_p2p::spacetunnel::IdentityOrRemoteIdentityErr;
use sd_utils::{
db::{self, MissingFieldError},
error::{FileIOError, NonUtf8PathError},

View file

@ -7,15 +7,15 @@ use crate::{
},
node::Platform,
object::tag,
p2p::{self, IdentityOrRemoteIdentity},
p2p::{self},
sync,
util::{mpscrr, MaybeUndefined},
Node,
};
use sd_core_sync::SyncMessage;
use sd_p2p::spacetunnel::Identity;
use sd_prisma::prisma::{crdt_operation, instance, location};
use sd_p2p::spacetunnel::{Identity, IdentityOrRemoteIdentity};
use sd_prisma::prisma::{crdt_operation, instance, location, SortOrder};
use sd_utils::{
db,
error::{FileIOError, NonUtf8PathError},
@ -27,6 +27,7 @@ use std::{
path::{Path, PathBuf},
str::FromStr,
sync::{atomic::AtomicBool, Arc},
time::Duration,
};
use chrono::Utc;
@ -34,6 +35,7 @@ use futures_concurrency::future::{Join, TryJoin};
use tokio::{
fs, io,
sync::{broadcast, RwLock},
time::sleep,
};
use tracing::{debug, error, info, warn};
use uuid::Uuid;
@ -244,6 +246,7 @@ impl Libraries {
id: Uuid,
name: Option<LibraryName>,
description: MaybeUndefined<String>,
cloud_id: MaybeUndefined<String>,
) -> Result<(), LibraryManagerError> {
// check library is valid
let libraries = self.libraries.read().await;
@ -267,6 +270,11 @@ impl Libraries {
config.description = Some(description)
}
}
match cloud_id {
MaybeUndefined::Undefined => {}
MaybeUndefined::Null => config.cloud_id = None,
MaybeUndefined::Value(cloud_id) => config.cloud_id = Some(cloud_id),
}
},
self.libraries_dir.join(format!("{id}.sdlibrary")),
)
@ -442,8 +450,8 @@ impl Libraries {
// let key_manager = Arc::new(KeyManager::new(vec![]).await?);
// seed_keymanager(&db, &key_manager).await?;
let timestamps = db
._batch(
let sync = sync::Manager::new(&db, instance_id, &self.emit_messages_flag, {
db._batch(
instances
.iter()
.map(|i| {
@ -451,6 +459,7 @@ impl Libraries {
.find_first(vec![crdt_operation::instance::is(vec![
instance::id::equals(i.id),
])])
.order_by(crdt_operation::timestamp::order(SortOrder::Desc))
})
.collect::<Vec<_>>(),
)
@ -463,10 +472,10 @@ impl Libraries {
sd_sync::NTP64(op.map(|o| o.timestamp).unwrap_or_default() as u64),
)
})
.collect::<HashMap<_, _>>();
let sync = sync::Manager::new(&db, instance_id, &self.emit_messages_flag, timestamps);
.collect()
});
let (tx, mut rx) = broadcast::channel(10);
let library = Library::new(
id,
config,
@ -476,6 +485,7 @@ impl Libraries {
db,
node,
Arc::new(sync.manager),
tx,
)
.await;
@ -494,7 +504,7 @@ impl Libraries {
.insert(library.id, Arc::clone(&library));
if should_seed {
library.orphan_remover.invoke().await;
// library.orphan_remover.invoke().await;
indexer::rules::seed::new_or_existing_library(&library).await?;
}
@ -517,6 +527,119 @@ impl Libraries {
error!("Failed to resume jobs for library. {:#?}", e);
}
tokio::spawn({
let this = self.clone();
let node = node.clone();
let library = library.clone();
async move {
loop {
debug!("Syncing library with cloud!");
if let Some(_) = library.config().await.cloud_id {
if let Ok(lib) =
sd_cloud_api::library::get(node.cloud_api_config().await, library.id)
.await
{
match lib {
Some(lib) => {
if let Some(this_instance) = lib
.instances
.iter()
.find(|i| i.uuid == library.instance_uuid)
{
let node_config = node.config.get().await;
let should_update = this_instance.node_id != node_config.id
|| this_instance.node_platform
!= (Platform::current() as u8)
|| this_instance.node_name != node_config.name;
if should_update {
warn!("Library instance on cloud is outdated. Updating...");
if let Err(err) =
sd_cloud_api::library::update_instance(
node.cloud_api_config().await,
library.id,
this_instance.uuid,
Some(node_config.id),
Some(node_config.name),
Some(Platform::current() as u8),
)
.await
{
error!(
"Failed to updating instance '{}' on cloud: {:#?}",
this_instance.uuid, err
);
}
}
}
if &lib.name != &*library.config().await.name {
warn!("Library name on cloud is outdated. Updating...");
if let Err(err) = sd_cloud_api::library::update(
node.cloud_api_config().await,
library.id,
Some(lib.name),
)
.await
{
error!(
"Failed to update library name on cloud: {:#?}",
err
);
}
}
for instance in lib.instances {
if let Err(err) =
crate::cloud::sync::receive::create_instance(
&library,
&node.libraries,
instance.uuid,
instance.identity,
instance.node_id,
instance.node_name,
instance.node_platform,
)
.await
{
error!(
"Failed to create instance from cloud: {:#?}",
err
);
}
}
}
None => {
warn!(
"Library not found on cloud. Removing from local node..."
);
let _ = this
.edit(
library.id.clone(),
None,
MaybeUndefined::Undefined,
MaybeUndefined::Null,
)
.await;
}
}
}
}
tokio::select! {
// Update instances every 2 minutes
_ = sleep(Duration::from_secs(120)) => {}
// Or when asked by user
Ok(_) = rx.recv() => {}
};
}
}
});
Ok(library)
}

View file

@ -1,4 +1,3 @@
mod actors;
mod config;
#[allow(clippy::module_inception)]
mod library;
@ -6,7 +5,6 @@ mod manager;
mod name;
mod statistics;
pub use actors::*;
pub use config::*;
pub use library::*;
pub use manager::*;

View file

@ -497,7 +497,7 @@ impl StatefulJob for IndexerJobInit {
if run_metadata.total_updated_paths > 0 {
// Invoking orphan remover here as we probably have some orphans objects due to updates
ctx.library.orphan_remover.invoke().await;
// ctx.library.orphan_remover.invoke().await;
}
if run_metadata.indexed_count > 0

View file

@ -190,7 +190,7 @@ pub async fn shallow(
invalidate_query!(library, "search.objects");
}
library.orphan_remover.invoke().await;
// library.orphan_remover.invoke().await;
Ok(())
}

View file

@ -852,7 +852,7 @@ pub async fn delete_directory(
db.file_path().delete_many(children_params).exec().await?;
library.orphan_remover.invoke().await;
// library.orphan_remover.invoke().await;
invalidate_query!(library, "search.paths");
invalidate_query!(library, "search.objects");

View file

@ -49,6 +49,9 @@ pub struct NodeConfig {
pub features: Vec<BackendFeature>,
/// Authentication for Spacedrive Accounts
pub auth_token: Option<sd_cloud_api::auth::OAuthToken>,
/// URL of the Spacedrive API
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sd_api_origin: Option<String>,
/// The aggreagation of many different preferences for the node
pub preferences: NodePreferences,
// Model version for the image labeler
@ -102,6 +105,7 @@ impl ManagedVersion<NodeConfigVersion> for NodeConfig {
features: vec![],
notifications: vec![],
auth_token: None,
sd_api_origin: None,
preferences: NodePreferences::default(),
image_labeler_version,
})

View file

@ -54,3 +54,9 @@ impl TryFrom<u8> for Platform {
Ok(s)
}
}
impl From<Platform> for u8 {
fn from(platform: Platform) -> Self {
platform as u8
}
}

View file

@ -105,7 +105,7 @@ impl StatefulJob for FileDeleterJobInit {
let init = self;
invalidate_query!(ctx.library, "search.paths");
ctx.library.orphan_remover.invoke().await;
// ctx.library.orphan_remover.invoke().await;
Ok(Some(json!({ "init": init })))
}

View file

@ -1,49 +0,0 @@
use sd_p2p::spacetunnel::{Identity, IdentityErr, RemoteIdentity};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IdentityOrRemoteIdentityErr {
#[error("IdentityErr({0})")]
IdentityErr(#[from] IdentityErr),
#[error("InvalidFormat")]
InvalidFormat,
}
/// TODO
#[derive(Debug, PartialEq)]
pub enum IdentityOrRemoteIdentity {
Identity(Identity),
RemoteIdentity(RemoteIdentity),
}
impl IdentityOrRemoteIdentity {
pub fn remote_identity(&self) -> RemoteIdentity {
match self {
Self::Identity(identity) => identity.to_remote_identity(),
Self::RemoteIdentity(identity) => {
RemoteIdentity::from_bytes(identity.get_bytes().as_slice()).expect("unreachable")
}
}
}
}
impl IdentityOrRemoteIdentity {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityOrRemoteIdentityErr> {
match bytes[0] {
b'I' => Ok(Self::Identity(Identity::from_bytes(&bytes[1..])?)),
b'R' => Ok(Self::RemoteIdentity(RemoteIdentity::from_bytes(
&bytes[1..],
)?)),
_ => Err(IdentityOrRemoteIdentityErr::InvalidFormat),
}
}
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Identity(identity) => [&[b'I'], &*identity.to_bytes()].concat(),
Self::RemoteIdentity(identity) => [[b'R'].as_slice(), &identity.get_bytes()].concat(),
}
}
}

View file

@ -2,7 +2,7 @@
use crate::library::{Libraries, Library, LibraryManagerEvent};
use sd_p2p::Service;
use sd_p2p::{spacetunnel::IdentityOrRemoteIdentity, Service};
use std::{
collections::HashMap,
@ -14,7 +14,7 @@ use tokio::sync::mpsc;
use tracing::{error, warn};
use uuid::Uuid;
use super::{IdentityOrRemoteIdentity, LibraryMetadata, P2PManager};
use super::{LibraryMetadata, P2PManager};
pub struct LibraryServices {
services: RwLock<HashMap<Uuid, Arc<Service<LibraryMetadata>>>>,
@ -44,36 +44,35 @@ impl LibraryServices {
}
}
pub(crate) async fn start(_manager: Arc<P2PManager>, _libraries: Arc<Libraries>) {
warn!("P2PManager has library communication disabled.");
// if let Err(err) = libraries
// .rx
// .clone()
// .subscribe(|msg| {
// let manager = manager.clone();
// async move {
// match msg {
// LibraryManagerEvent::InstancesModified(library)
// | LibraryManagerEvent::Load(library) => {
// manager
// .clone()
// .libraries
// .load_library(manager, &library)
// .await
// }
// LibraryManagerEvent::Edit(library) => {
// manager.libraries.edit_library(&library).await
// }
// LibraryManagerEvent::Delete(library) => {
// manager.libraries.delete_library(&library).await
// }
// }
// }
// })
// .await
// {
// error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
// }
pub(crate) async fn start(manager: Arc<P2PManager>, libraries: Arc<Libraries>) {
if let Err(err) = libraries
.rx
.clone()
.subscribe(|msg| {
let manager = manager.clone();
async move {
match msg {
LibraryManagerEvent::InstancesModified(library)
| LibraryManagerEvent::Load(library) => {
manager
.clone()
.libraries
.load_library(manager, &library)
.await
}
LibraryManagerEvent::Edit(library) => {
manager.libraries.edit_library(&library).await
}
LibraryManagerEvent::Delete(library) => {
manager.libraries.delete_library(&library).await
}
}
}
})
.await
{
error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
}
}
pub fn get(&self, id: &Uuid) -> Option<Arc<Service<LibraryMetadata>>> {
@ -125,8 +124,11 @@ impl LibraryServices {
let service = service.entry(library.id).or_insert_with(|| {
inserted = true;
Arc::new(
Service::new(library.id.to_string(), manager.manager.clone())
.expect("error creating service with duplicate service name"),
Service::new(
String::from_utf8_lossy(&base91::slice_encode(&*library.id.as_bytes())),
manager.manager.clone(),
)
.expect("error creating service with duplicate service name"),
)
});
service.add_known(identities);

View file

@ -1,25 +1,21 @@
#![warn(clippy::all, clippy::unwrap_used, clippy::panic)]
#![allow(clippy::unnecessary_cast)] // Yeah they aren't necessary on this arch, but they are on others
mod identity_or_remote_identity;
mod libraries;
mod library_metadata;
pub mod operations;
mod p2p_events;
mod p2p_manager;
mod p2p_manager_actor;
mod pairing;
mod peer_metadata;
mod protocol;
pub mod sync;
pub use identity_or_remote_identity::*;
pub use libraries::*;
pub use library_metadata::*;
pub use p2p_events::*;
pub use p2p_manager::*;
pub use p2p_manager_actor::*;
pub use pairing::*;
pub use peer_metadata::*;
pub use protocol::*;

View file

@ -4,7 +4,7 @@ use serde::Serialize;
use specta::Type;
use uuid::Uuid;
use super::{OperatingSystem, PairingStatus, PeerMetadata};
use super::PeerMetadata;
/// TODO: P2P event for the frontend
#[derive(Debug, Clone, Serialize, Type)]
@ -39,15 +39,4 @@ pub enum P2PEvent {
SpacedropRejected {
id: Uuid,
},
// Pairing was reuqest has come in.
// This will fire on the responder only.
PairingRequest {
id: u16,
name: String,
os: OperatingSystem,
},
PairingProgress {
id: u16,
status: PairingStatus,
}, // TODO: Expire peer + connection/disconnect
}

View file

@ -18,9 +18,7 @@ use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
use tracing::info;
use uuid::Uuid;
use super::{
LibraryMetadata, LibraryServices, P2PEvent, P2PManagerActor, PairingManager, PeerMetadata,
};
use super::{LibraryMetadata, LibraryServices, P2PEvent, P2PManagerActor, PeerMetadata};
pub struct P2PManager {
pub(crate) node: Service<PeerMetadata>,
@ -30,7 +28,6 @@ pub struct P2PManager {
pub manager: Arc<Manager>,
pub(super) spacedrop_pairing_reqs: Arc<Mutex<HashMap<Uuid, oneshot::Sender<Option<String>>>>>,
pub(super) spacedrop_cancelations: Arc<Mutex<HashMap<Uuid, Arc<AtomicBool>>>>,
pub pairing: Arc<PairingManager>,
node_config_manager: Arc<config::Manager>,
}
@ -54,17 +51,12 @@ impl P2PManager {
stream.listen_addrs()
);
// need to keep 'rx' around so that the channel isn't dropped
let (tx, rx) = broadcast::channel(100);
let pairing = PairingManager::new(manager.clone(), tx.clone());
let (register_service_tx, register_service_rx) = mpsc::channel(10);
let this = Arc::new(Self {
node: Service::new("node", manager.clone())
.expect("Hardcoded service name will never be a duplicate!"),
libraries: LibraryServices::new(register_service_tx),
pairing,
events: (tx, rx),
events: broadcast::channel(100),
manager,
spacedrop_pairing_reqs: Default::default(),
spacedrop_cancelations: Default::default(),

View file

@ -83,17 +83,6 @@ impl P2PManagerActor {
Header::Spacedrop(req) => {
operations::spacedrop::reciever(&this, req, event).await?
}
Header::Pair => {
this.pairing
.clone()
.responder(
event.identity,
event.stream,
&node.libraries,
node.clone(),
)
.await?
}
Header::Sync(library_id) => {
let mut tunnel =
Tunnel::responder(event.stream).await.map_err(|err| {

View file

@ -1,374 +0,0 @@
#![allow(clippy::panic, clippy::unwrap_used)] // TODO: Finish this
use crate::{
library::{Libraries, LibraryName},
node::Platform,
p2p::{Header, IdentityOrRemoteIdentity},
Node,
};
use sd_p2p::{
spacetunnel::{Identity, RemoteIdentity},
Manager,
};
use sd_prisma::prisma::instance;
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU16, Ordering},
Arc, RwLock,
},
};
use chrono::Utc;
use futures::channel::oneshot;
use serde::{Deserialize, Serialize};
use specta::Type;
use tokio::{
io::{AsyncRead, AsyncWrite, AsyncWriteExt},
sync::broadcast,
};
use tracing::{info, warn};
use uuid::Uuid;
mod proto;
use proto::*;
use super::P2PEvent;
pub struct PairingManager {
id: AtomicU16,
events_tx: broadcast::Sender<P2PEvent>,
pairing_response: RwLock<HashMap<u16, oneshot::Sender<PairingDecision>>>,
manager: Arc<Manager>,
}
impl PairingManager {
pub fn new(manager: Arc<Manager>, events_tx: broadcast::Sender<P2PEvent>) -> Arc<Self> {
Arc::new(Self {
id: AtomicU16::new(0),
events_tx,
pairing_response: RwLock::new(HashMap::new()),
manager,
})
}
fn emit_progress(&self, id: u16, status: PairingStatus) {
self.events_tx
.send(P2PEvent::PairingProgress { id, status })
.ok();
}
pub fn decision(&self, id: u16, decision: PairingDecision) {
if let Some(tx) = self.pairing_response.write().unwrap().remove(&id) {
tx.send(decision).ok();
}
}
// TODO: Error handling
pub async fn originator(self: Arc<Self>, identity: RemoteIdentity, node: Arc<Node>) -> u16 {
// TODO: Timeout for max number of pairings in a time period
let pairing_id = self.id.fetch_add(1, Ordering::SeqCst);
self.emit_progress(pairing_id, PairingStatus::EstablishingConnection);
info!("Beginning pairing '{pairing_id}' as originator to remote peer '{identity}'");
tokio::spawn(async move {
let mut stream = self.manager.stream(identity).await.unwrap();
stream.write_all(&Header::Pair.to_bytes()).await.unwrap();
// TODO: Ensure both clients are on a compatible version cause Prisma model changes will cause issues
// 1. Create new instance for originator and send it to the responder
self.emit_progress(pairing_id, PairingStatus::PairingRequested);
let node_config = node.config.get().await;
let now = Utc::now();
let identity = Identity::new();
let self_instance_id = Uuid::new_v4();
let req = PairingRequest(Instance {
id: self_instance_id,
identity: identity.to_remote_identity(),
node_id: node_config.id,
node_name: node_config.name.clone(),
node_platform: Platform::current(),
last_seen: now,
date_created: now,
});
stream.write_all(&req.to_bytes()).await.unwrap();
// 2.
match PairingResponse::from_stream(&mut stream).await.unwrap() {
PairingResponse::Accepted {
library_id,
library_name,
library_description,
instances,
} => {
info!("Pairing '{pairing_id}' accepted by remote into library '{library_id}'");
// TODO: Log all instances and library info
self.emit_progress(
pairing_id,
PairingStatus::PairingInProgress {
library_name: library_name.clone(),
library_description: library_description.clone(),
},
);
// TODO: Future - Library in pairing state
// TODO: Create library
if node
.libraries
.get_all()
.await
.into_iter()
.any(|i| i.id == library_id)
{
self.emit_progress(pairing_id, PairingStatus::LibraryAlreadyExists);
// TODO: Properly handle this at a protocol level so the error is on both sides
return;
}
let (this, instances): (Vec<_>, Vec<_>) = instances
.into_iter()
.partition(|i| i.id == self_instance_id);
if this.len() != 1 {
todo!("error handling");
}
let this = this.first().expect("unreachable");
if this.identity != identity.to_remote_identity() {
todo!("error handling. Something went really wrong!");
}
let library = node
.libraries
.create_with_uuid(
library_id,
LibraryName::new(library_name).unwrap(),
library_description,
false, // We will sync everything which will conflict with the seeded stuff
Some(instance::Create {
pub_id: this.id.as_bytes().to_vec(),
identity: IdentityOrRemoteIdentity::Identity(identity).to_bytes(),
node_id: this.node_id.as_bytes().to_vec(),
node_name: this.node_name.clone(), // TODO: Remove `clone`
node_platform: this.node_platform as i32,
last_seen: this.last_seen.into(),
date_created: this.date_created.into(),
_params: vec![],
}),
&node,
)
.await
.unwrap();
let library = node.libraries.get_library(&library.id).await.unwrap();
library
.db
.instance()
.create_many(
instances
.into_iter()
.map(|i| {
instance::CreateUnchecked {
pub_id: i.id.as_bytes().to_vec(),
identity: IdentityOrRemoteIdentity::RemoteIdentity(
i.identity,
)
.to_bytes(),
node_id: i.node_id.as_bytes().to_vec(),
node_name: i.node_name,
node_platform: i.node_platform as i32,
last_seen: i.last_seen.into(),
date_created: i.date_created.into(),
// timestamp: Default::default(), // TODO: Source this properly!
_params: vec![],
}
})
.collect(),
)
.exec()
.await
.unwrap();
// Called again so the new instances are picked up
node.libraries.update_instances(library.clone()).await;
// TODO: Done message to frontend
self.emit_progress(pairing_id, PairingStatus::PairingComplete(library_id));
stream.flush().await.unwrap();
// Remember, originator creates a new stream internally so the handler for this doesn't have to do anything.
super::sync::originator(library_id, &library.sync, &node.p2p).await;
}
PairingResponse::Rejected => {
info!("Pairing '{pairing_id}' rejected by remote");
self.emit_progress(pairing_id, PairingStatus::PairingRejected);
}
}
});
pairing_id
}
pub async fn responder(
self: Arc<Self>,
identity: RemoteIdentity,
mut stream: impl AsyncRead + AsyncWrite + Unpin,
library_manager: &Libraries,
node: Arc<Node>,
) -> Result<(), ()> {
let pairing_id = self.id.fetch_add(1, Ordering::SeqCst);
self.emit_progress(pairing_id, PairingStatus::EstablishingConnection);
info!("Beginning pairing '{pairing_id}' as responder to remote peer '{identity}'");
let remote_instance = match PairingRequest::from_stream(&mut stream).await {
Ok(v) => v,
Err((field_name, err)) => {
warn!("Error reading field '{field_name}' of pairing request from remote: {err}");
self.emit_progress(pairing_id, PairingStatus::PairingRejected);
// TODO: Attempt to send error to remote and reset connection
return Ok(());
}
}
.0;
self.emit_progress(pairing_id, PairingStatus::PairingDecisionRequest);
self.events_tx
.send(P2PEvent::PairingRequest {
id: pairing_id,
name: remote_instance.node_name.clone(),
os: remote_instance.node_platform.into(),
})
.ok();
// Prompt the user and wait
// TODO: After 1 minute remove channel from map and assume it was rejected
let (tx, rx) = oneshot::channel();
self.pairing_response
.write()
.unwrap()
.insert(pairing_id, tx);
let PairingDecision::Accept(library_id) = rx.await.unwrap() else {
info!("The user rejected pairing '{pairing_id}'!");
// self.emit_progress(pairing_id, PairingStatus::PairingRejected); // TODO: Event to remove from frontend index
stream
.write_all(&PairingResponse::Rejected.to_bytes())
.await
.unwrap();
return Ok(());
};
info!("The user accepted pairing '{pairing_id}' for library '{library_id}'!");
let library = library_manager.get_library(&library_id).await.unwrap();
// TODO: Rollback this on pairing failure
instance::Create {
pub_id: remote_instance.id.as_bytes().to_vec(),
identity: IdentityOrRemoteIdentity::RemoteIdentity(remote_instance.identity).to_bytes(),
node_id: remote_instance.node_id.as_bytes().to_vec(),
node_name: remote_instance.node_name,
node_platform: remote_instance.node_platform as i32,
last_seen: remote_instance.last_seen.into(),
date_created: remote_instance.date_created.into(),
// timestamp: Default::default(), // TODO: Source this properly!
_params: vec![],
}
.to_query(&library.db)
.exec()
.await
.unwrap();
library_manager.update_instances(library.clone()).await;
let library_config = library.config().await;
stream
.write_all(
&PairingResponse::Accepted {
library_id: library.id,
library_name: library_config.name.into(),
library_description: library_config.description,
instances: library
.db
.instance()
.find_many(vec![])
.exec()
.await
.unwrap()
.into_iter()
.filter_map(|i| {
let Ok(id) = Uuid::from_slice(&i.pub_id) else {
warn!("Invalid instance pub_id in database: {:?}", i.pub_id);
return None;
};
let Ok(node_id) = Uuid::from_slice(&i.node_id) else {
warn!("Invalid instance node_id in database: {:?}", i.node_id);
return None;
};
Some(Instance {
id,
identity: IdentityOrRemoteIdentity::from_bytes(&i.identity)
.unwrap()
.remote_identity(),
node_id,
node_name: i.node_name,
node_platform: Platform::try_from(i.node_platform as u8)
.unwrap_or(Platform::Unknown),
last_seen: i.last_seen.into(),
date_created: i.date_created.into(),
})
})
.collect(),
}
.to_bytes(),
)
.await
.unwrap();
// TODO: Pairing confirmation + rollback
self.emit_progress(pairing_id, PairingStatus::PairingComplete(library_id));
stream.flush().await.unwrap();
// Remember, originator creates a new stream internally so the handler for this doesn't have to do anything.
super::sync::originator(library_id, &library.sync, &node.p2p).await;
Ok(())
}
}
#[derive(Debug, Type, Serialize, Deserialize)]
#[serde(tag = "decision", content = "libraryId", rename_all = "camelCase")]
pub enum PairingDecision {
Accept(Uuid),
Reject,
}
#[derive(Debug, Hash, Clone, Serialize, Type)]
#[serde(tag = "type", content = "data")]
pub enum PairingStatus {
EstablishingConnection,
PairingRequested,
LibraryAlreadyExists,
PairingDecisionRequest,
PairingInProgress {
library_name: String,
library_description: Option<String>,
},
InitialSyncProgress(u8),
PairingComplete(Uuid),
PairingRejected,
}
// TODO: Unit tests

View file

@ -1,289 +0,0 @@
use crate::node::Platform;
use sd_p2p::{
proto::{decode, encode},
spacetunnel::RemoteIdentity,
};
use std::str::FromStr;
use chrono::{DateTime, Utc};
use tokio::io::{AsyncRead, AsyncReadExt};
use uuid::Uuid;
/// Terminology:
/// Instance - DB model which represents a single `.db` file.
/// Originator - begins the pairing process and is asking to join a library that will be selected by the responder.
/// Responder - is in-charge of accepting or rejecting the originator's request and then selecting which library to "share".
/// A modified version of `prisma::instance::Data` that uses proper validated types for the fields.
#[derive(Debug, PartialEq)]
pub struct Instance {
pub id: Uuid,
pub identity: RemoteIdentity,
pub node_id: Uuid,
pub node_name: String,
pub node_platform: Platform,
pub last_seen: DateTime<Utc>,
pub date_created: DateTime<Utc>,
}
/// 1. Request for pairing to a library that is owned and will be selected by the responder.
/// Sent `Originator` -> `Responder`.
#[derive(Debug, PartialEq)]
pub struct PairingRequest(/* Originator's instance */ pub Instance);
/// 2. Decision for whether pairing was accepted or rejected once a library is decided on by the user.
/// Sent `Responder` -> `Originator`.
#[derive(Debug, PartialEq)]
pub enum PairingResponse {
/// Pairing was accepted and the responder chose the library of their we are pairing to.
Accepted {
// Library information
library_id: Uuid,
library_name: String,
library_description: Option<String>,
// All instances in the library
// Copying these means we are instantly paired with everyone else that is already in the library
// NOTE: It's super important the `identity` field is converted from a private key to a public key before sending!!!
instances: Vec<Instance>,
},
// Process will terminate as the user doesn't want to pair
Rejected,
}
/// 3. Tell the responder that the database was correctly paired.
/// Sent `Originator` -> `Responder`.
#[derive(Debug, PartialEq)]
pub enum PairingConfirmation {
Ok,
Error,
}
impl Instance {
pub async fn from_stream(
stream: &mut (impl AsyncRead + Unpin),
) -> Result<Self, (&'static str, decode::Error)> {
Ok(Self {
id: decode::uuid(stream).await.map_err(|e| ("id", e))?,
identity: RemoteIdentity::from_bytes(
&decode::buf(stream).await.map_err(|e| ("identity", e))?,
)
.unwrap(), // TODO: Error handling
node_id: decode::uuid(stream).await.map_err(|e| ("node_id", e))?,
node_name: decode::string(stream).await.map_err(|e| ("node_name", e))?,
node_platform: stream
.read_u8()
.await
.map(|b| Platform::try_from(b).unwrap_or(Platform::Unknown))
.map_err(|e| ("node_platform", e.into()))?,
last_seen: DateTime::<Utc>::from_str(
&decode::string(stream).await.map_err(|e| ("last_seen", e))?,
)
.unwrap(), // TODO: Error handling
date_created: DateTime::<Utc>::from_str(
&decode::string(stream)
.await
.map_err(|e| ("date_created", e))?,
)
.unwrap(), // TODO: Error handling
})
}
pub fn to_bytes(&self) -> Vec<u8> {
let Self {
id,
identity,
node_id,
node_name,
node_platform,
last_seen,
date_created,
} = self;
let mut buf = Vec::new();
encode::uuid(&mut buf, id);
encode::buf(&mut buf, &identity.get_bytes());
encode::uuid(&mut buf, node_id);
encode::string(&mut buf, node_name);
buf.push(*node_platform as u8);
encode::string(&mut buf, &last_seen.to_string());
encode::string(&mut buf, &date_created.to_string());
buf
}
}
impl PairingRequest {
pub async fn from_stream(
stream: &mut (impl AsyncRead + Unpin),
) -> Result<Self, (&'static str, decode::Error)> {
Ok(Self(Instance::from_stream(stream).await?))
}
pub fn to_bytes(&self) -> Vec<u8> {
let Self(instance) = self;
Instance::to_bytes(instance)
}
}
impl PairingResponse {
pub async fn from_stream(
stream: &mut (impl AsyncRead + Unpin),
) -> Result<Self, (&'static str, decode::Error)> {
// TODO: Error handling
match stream.read_u8().await.unwrap() {
0 => Ok(Self::Accepted {
library_id: decode::uuid(stream).await.map_err(|e| ("library_id", e))?,
library_name: decode::string(stream)
.await
.map_err(|e| ("library_name", e))?,
library_description: match decode::string(stream)
.await
.map_err(|e| ("library_description", e))?
{
s if s.is_empty() => None,
s => Some(s),
},
instances: {
let len = stream.read_u16_le().await.unwrap();
let mut instances = Vec::with_capacity(len as usize); // TODO: Prevent DOS
for _ in 0..len {
instances.push(Instance::from_stream(stream).await.unwrap());
}
instances
},
}),
1 => Ok(Self::Rejected),
_ => todo!(),
}
}
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Accepted {
library_id,
library_name,
library_description,
instances,
} => {
let mut buf = vec![0];
encode::uuid(&mut buf, library_id);
encode::string(&mut buf, library_name);
encode::string(&mut buf, library_description.as_deref().unwrap_or(""));
buf.extend((instances.len() as u16).to_le_bytes());
for instance in instances {
buf.extend(instance.to_bytes());
}
buf
}
Self::Rejected => vec![1],
}
}
}
#[allow(unused)] // TODO: Remove this if still unused
impl PairingConfirmation {
pub async fn from_stream(
stream: &mut (impl AsyncRead + Unpin),
) -> Result<Self, (&'static str, decode::Error)> {
// TODO: Error handling
match stream.read_u8().await.unwrap() {
0 => Ok(Self::Ok),
1 => Ok(Self::Error),
_ => todo!(), // TODO: Error handling
}
}
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Ok => vec![0],
Self::Error => vec![1],
}
}
}
#[cfg(test)]
mod tests {
use sd_p2p::spacetunnel::Identity;
use super::*;
#[tokio::test]
async fn test_types() {
let identity = Identity::new();
let instance = || Instance {
id: Uuid::new_v4(),
identity: identity.to_remote_identity(),
node_id: Uuid::new_v4(),
node_name: "Node Name".into(),
node_platform: Platform::current(),
last_seen: Utc::now(),
date_created: Utc::now(),
};
{
let original = PairingRequest(instance());
let mut cursor = std::io::Cursor::new(original.to_bytes());
let result = PairingRequest::from_stream(&mut cursor).await.unwrap();
assert_eq!(original, result);
}
{
let original = PairingResponse::Accepted {
library_id: Uuid::new_v4(),
library_name: "Library Name".into(),
library_description: Some("Library Description".into()),
instances: vec![instance(), instance(), instance()],
};
let mut cursor = std::io::Cursor::new(original.to_bytes());
let result = PairingResponse::from_stream(&mut cursor).await.unwrap();
assert_eq!(original, result);
}
{
let original = PairingResponse::Accepted {
library_id: Uuid::new_v4(),
library_name: "Library Name".into(),
library_description: None,
instances: vec![],
};
let mut cursor = std::io::Cursor::new(original.to_bytes());
let result = PairingResponse::from_stream(&mut cursor).await.unwrap();
assert_eq!(original, result);
}
{
let original = PairingResponse::Rejected;
let mut cursor = std::io::Cursor::new(original.to_bytes());
let result = PairingResponse::from_stream(&mut cursor).await.unwrap();
assert_eq!(original, result);
}
{
let original = PairingConfirmation::Ok;
let mut cursor = std::io::Cursor::new(original.to_bytes());
let result = PairingConfirmation::from_stream(&mut cursor).await.unwrap();
assert_eq!(original, result);
}
{
let original = PairingConfirmation::Error;
let mut cursor = std::io::Cursor::new(original.to_bytes());
let result = PairingConfirmation::from_stream(&mut cursor).await.unwrap();
assert_eq!(original, result);
}
}
}

View file

@ -22,7 +22,6 @@ pub enum Header {
// TODO: Split out cause this is a broadcast
Ping,
Spacedrop(SpaceblockRequests),
Pair,
Sync(Uuid),
File(HeaderFile),
}
@ -55,7 +54,6 @@ impl Header {
SpaceblockRequests::from_stream(stream).await?,
)),
1 => Ok(Self::Ping),
2 => Ok(Self::Pair),
3 => Ok(Self::Sync(
decode::uuid(stream)
.await
@ -103,7 +101,6 @@ impl Header {
bytes
}
Self::Ping => vec![1],
Self::Pair => vec![2],
Self::Sync(uuid) => {
let mut bytes = vec![3];
encode::uuid(&mut bytes, uuid);

12
crates/actors/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "sd-actors"
version = "0.1.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
futures.workspace = true
tokio.workspace = true

View file

@ -5,9 +5,8 @@ license.workspace = true
edition.workspace = true
repository.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
sd-p2p = { path = "../p2p" }
reqwest = "0.11.22"
serde.workspace = true
serde_json.workspace = true
@ -15,3 +14,4 @@ thiserror = "1.0.50"
uuid.workspace = true
rspc = { workspace = true }
specta.workspace = true
base64.workspace = true

View file

@ -1,6 +1,9 @@
pub mod auth;
use std::{future::Future, sync::Arc};
use auth::OAuthToken;
use sd_p2p::spacetunnel::RemoteIdentity;
use serde::{Deserialize, Serialize};
use serde_json::json;
use specta::Type;
@ -12,6 +15,10 @@ pub struct RequestConfig {
pub auth_token: Option<auth::OAuthToken>,
}
pub trait RequestConfigProvider {
fn get_request_config(self: &Arc<Self>) -> impl Future<Output = RequestConfig> + Send;
}
#[derive(thiserror::Error, Debug)]
#[error("{0}")]
pub struct Error(String);
@ -26,6 +33,7 @@ impl From<Error> for rspc::Error {
#[serde(rename_all = "camelCase")]
#[specta(rename = "CloudLibrary")]
pub struct Library {
pub id: String,
pub uuid: Uuid,
pub name: String,
pub instances: Vec<Instance>,
@ -38,7 +46,10 @@ pub struct Library {
pub struct Instance {
pub id: String,
pub uuid: Uuid,
pub identity: String,
pub identity: RemoteIdentity,
pub node_id: Uuid,
pub node_name: String,
pub node_platform: u8,
}
#[derive(Serialize, Deserialize, Debug, Type)]
@ -185,18 +196,26 @@ pub mod library {
pub mod create {
use super::*;
#[derive(Debug, Deserialize)]
pub struct Response {
pub id: String,
}
pub async fn exec(
config: RequestConfig,
library_id: Uuid,
name: &str,
instance_uuid: Uuid,
instance_identity: &impl Serialize,
) -> Result<(), Error> {
instance_identity: RemoteIdentity,
node_id: Uuid,
node_name: &str,
node_platform: u8,
) -> Result<Response, Error> {
let Some(auth_token) = config.auth_token else {
return Err(Error("Authentication required".to_string()));
};
let resp = config
config
.client
.post(&format!(
"{}/api/v1/libraries/{}",
@ -205,19 +224,83 @@ pub mod library {
.json(&json!({
"name":name,
"instanceUuid": instance_uuid,
"instanceIdentity": instance_identity
"instanceIdentity": instance_identity,
"nodeId": node_id,
"nodeName": node_name,
"nodePlatform": node_platform
}))
.with_auth(auth_token)
.send()
.await
.map_err(|e| Error(e.to_string()))?
.text()
.json()
.await
.map_err(|e| Error(e.to_string()))?;
.map_err(|e| Error(e.to_string()))
}
}
println!("{resp}");
pub use update::exec as update;
pub mod update {
use super::*;
Ok(())
pub async fn exec(
config: RequestConfig,
library_id: Uuid,
name: Option<String>,
) -> Result<(), Error> {
let Some(auth_token) = config.auth_token else {
return Err(Error("Authentication required".to_string()));
};
config
.client
.patch(&format!(
"{}/api/v1/libraries/{}",
config.api_url, library_id
))
.json(&json!({
"name":name
}))
.with_auth(auth_token)
.send()
.await
.map_err(|e| Error(e.to_string()))
.map(|_| ())
}
}
pub use update_instance::exec as update_instance;
pub mod update_instance {
use super::*;
pub async fn exec(
config: RequestConfig,
library_id: Uuid,
instance_id: Uuid,
node_id: Option<Uuid>,
node_name: Option<String>,
node_platform: Option<u8>,
) -> Result<(), Error> {
let Some(auth_token) = config.auth_token else {
return Err(Error("Authentication required".to_string()));
};
config
.client
.patch(&format!(
"{}/api/v1/libraries/{}/{}",
config.api_url, library_id, instance_id
))
.json(&json!({
"nodeId": node_id,
"nodeName": node_name,
"nodePlatform": node_platform
}))
.with_auth(auth_token)
.send()
.await
.map_err(|e| Error(e.to_string()))
.map(|_| ())
}
}
@ -229,30 +312,34 @@ pub mod library {
config: RequestConfig,
library_id: Uuid,
instance_uuid: Uuid,
instance_identity: &impl Serialize,
) -> Result<(), Error> {
instance_identity: RemoteIdentity,
node_id: Uuid,
node_name: &str,
node_platform: u8,
) -> Result<Vec<Instance>, Error> {
let Some(auth_token) = config.auth_token else {
return Err(Error("Authentication required".to_string()));
};
let resp = config
config
.client
.post(&format!(
"{}/api/v1/libraries/{library_id}/instances/{instance_uuid}",
config.api_url
))
.json(&json!({ "instanceIdentity": instance_identity }))
.json(&json!({
"instanceIdentity": instance_identity,
"nodeId": node_id,
"nodeName": node_name,
"nodePlatform": node_platform
}))
.with_auth(auth_token)
.send()
.await
.map_err(|e| Error(e.to_string()))?
.text()
.json()
.await
.map_err(|e| Error(e.to_string()))?;
println!("{resp}");
Ok(())
.map_err(|e| Error(e.to_string()))
}
}

View file

@ -33,18 +33,20 @@ tokio-util = { workspace = true, features = ["compat"] }
tracing = { workspace = true }
uuid = { workspace = true }
ed25519-dalek = { version = "2.0.0", features = [] }
flume = "0.10.0" # Must match version used by `mdns-sd`
futures-core = "0.3.29"
if-watch = { version = "=3.1.0", features = [
ed25519-dalek = { version = "2.1.0", features = [] }
flume = "=0.11.0" # Must match version used by `mdns-sd`
futures-core = "0.3.30"
if-watch = { version = "=3.2.0", features = [
"tokio",
] } # Override the features of if-watch which is used by libp2p-quic
libp2p = { version = "0.52.4", features = ["tokio", "serde"] }
libp2p-quic = { version = "0.9.3", features = ["tokio"] }
mdns-sd = "0.9.3"
libp2p = { version = "0.53.2", features = ["tokio", "serde"] }
libp2p-quic = { version = "0.10.2", features = ["tokio"] }
mdns-sd = "0.10.3"
rand_core = { version = "0.6.4" }
streamunordered = "0.5.3"
zeroize = { version = "1.7.0", features = ["derive"]}
zeroize = { version = "1.7.0", features = ["derive"] }
base91 = "0.1.0"
sha256 = "1.5.0"
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread"] }

View file

@ -35,6 +35,14 @@ pub struct Mdns {
next_mdns_advertisement: Pin<Box<Sleep>>,
// This is an ugly workaround for: https://github.com/keepsimple1/mdns-sd/issues/145
mdns_rx: StreamUnordered<MdnsRecv>,
// This is hacky but it lets us go from service name back to `RemoteIdentity` when removing the service.
// During service removal we only have the service name (not metadata) but during service discovery we insert into this map.
tracked_services: HashMap<String /* Service FQDN */, TrackedService>,
}
struct TrackedService {
service_name: String,
identity: RemoteIdentity,
}
impl Mdns {
@ -53,6 +61,7 @@ impl Mdns {
mdns_daemon,
next_mdns_advertisement: Box::pin(sleep_until(Instant::now())), // Trigger an advertisement immediately
mdns_rx: StreamUnordered::new(),
tracked_services: HashMap::new(),
})
}
@ -80,19 +89,24 @@ impl Mdns {
continue;
};
let service_domain =
// TODO: Use "Selective Instance Enumeration" instead in future but right now it is causing `TMeta` to get garbled.
// format!("{service_name}._sub._{}", self.service_name)
format!("{service_name}._sub._{service_name}{}", self.service_name);
let mut meta = metadata.clone();
meta.insert("__peer_id".into(), self.peer_id.to_string());
meta.insert("__service".into(), service_name.to_string());
meta.insert("__identity".into(), self.identity.to_string());
// The max length of an MDNS record is painful so we just hash the data to come up with a pseudo-random but deterministic value.
// The full values are stored within TXT records.
let my_name = String::from_utf8_lossy(&base91::slice_encode(
&sha256::digest(format!("{}_{}", service_name, self.identity)).as_bytes(),
))[..63]
.to_string();
let service_domain = format!("_{service_name}._sub.{}", self.service_name);
let service = match ServiceInfo::new(
&service_domain,
&self.identity.to_string(), // TODO: This shows up in `fullname` without sub service. Is that a problem???
&my_name[..63], // 63 as long as the mDNS spec will allow us
&format!("{}.{}.", service_name, self.identity), // TODO: Should this change???
&*ips, // TODO: &[] as &[Ipv4Addr],
&*ips,
port,
Some(meta.clone()), // TODO: Prevent the user defining a value that overflows a DNS record
) {
@ -183,35 +197,49 @@ impl Mdns {
ServiceEvent::SearchStarted(_) => {}
ServiceEvent::ServiceFound(_, _) => {}
ServiceEvent::ServiceResolved(info) => {
let Some(subdomain) = info.get_subtype() else {
warn!("resolved mDNS peer advertising itself with missing subservice");
let Some(service_name) = info.get_properties().get("__service") else {
warn!(
"resolved mDNS peer advertising itself with missing '__service' metadata"
);
return;
};
let service_name = service_name.val_str();
let service_name = match subdomain.split("._sub.").next() {
Some(service_name) => service_name,
None => {
warn!("resolved mDNS peer advertising itself with invalid subservice '{subdomain}'");
return;
}
}; // TODO: .replace(&format!("._sub.{}", self.service_name), "");
let raw_remote_identity = info
.get_fullname()
.replace(&format!("._{service_name}{}", self.service_name), "");
let Ok(identity) = RemoteIdentity::from_str(&raw_remote_identity) else {
let Some(identity) = info.get_properties().get("__identity") else {
warn!(
"resolved peer advertising itself with an invalid RemoteIdentity('{}')",
raw_remote_identity
"resolved mDNS peer advertising itself with missing '__identity' metadata"
);
return;
};
let identity = identity.val_str();
println!("\t {:?} {:?}", info.get_fullname(), self.service_name); // TODO
// if !service_type.ends_with(&self.service_name) {
// warn!(
// "resolved mDNS peer advertising itself with invalid service type '{service_type}'"
// );
// return;
// }
let Ok(identity) = RemoteIdentity::from_str(identity) else {
warn!("resolved peer advertising itself with an invalid RemoteIdentity('{identity}')");
return;
};
// Prevent discovery of the current peer.
if identity == self.identity {
return;
}
self.tracked_services.insert(
info.get_fullname().to_string(),
TrackedService {
service_name: service_name.to_string(),
identity: identity.clone(),
},
);
let mut meta = info
.get_properties()
.iter()
@ -268,33 +296,20 @@ impl Mdns {
warn!("mDNS service '{service_name}' is missing from 'state.discovered'. This is likely a bug!");
}
}
ServiceEvent::ServiceRemoved(service_type, fullname) => {
let service_name = match service_type.split("._sub.").next() {
Some(service_name) => service_name,
None => {
warn!("resolved mDNS peer deadvertising itself with missing subservice '{service_type}'");
return;
}
};
let raw_remote_identity =
fullname.replace(&format!("._{service_name}{}", self.service_name), "");
let Ok(identity) = RemoteIdentity::from_str(&raw_remote_identity) else {
ServiceEvent::ServiceRemoved(_, fullname) => {
let Some(TrackedService {
service_name,
identity,
}) = self.tracked_services.remove(&fullname)
else {
warn!(
"resolved peer deadvertising itself with an invalid RemoteIdentity('{}')",
raw_remote_identity
"resolved mDNS peer deadvertising itself without having been discovered!"
);
return;
};
// Prevent discovery of the current peer.
if identity == self.identity {
return;
}
let mut state = state.write().unwrap_or_else(PoisonError::into_inner);
if let Some((tx, _)) = state.services.get_mut(service_name) {
if let Some((tx, _)) = state.services.get_mut(&service_name) {
if let Err(err) = tx.send((
service_name.to_string(),
ServiceEventInternal::Expired { identity },
@ -307,7 +322,7 @@ impl Mdns {
);
}
if let Some(discovered) = state.discovered.get_mut(service_name) {
if let Some(discovered) = state.discovered.get_mut(&service_name) {
discovered.remove(&identity);
} else {
warn!("mDNS service '{service_name}' is missing from 'state.discovered'. This is likely a bug!");
@ -330,9 +345,14 @@ impl Mdns {
// TODO: Without this mDNS is not sending it goodbye packets without a timeout. Try and remove this cause it makes shutdown slow.
sleep(Duration::from_millis(100));
self.mdns_daemon.shutdown().unwrap_or_else(|err| {
error!("error shutting down mdns daemon: {err}");
});
match self.mdns_daemon.shutdown() {
Ok(chan) => {
let _ = chan.recv();
}
Err(err) => {
error!("error shutting down mdns daemon: {err}");
}
}
}
}

View file

@ -299,6 +299,7 @@ impl ManagerStream {
}
SwarmEvent::ListenerError { listener_id, error } => warn!("listener '{:?}' reported a non-fatal error: {}", listener_id, error),
SwarmEvent::Dialing { .. } => {},
_ => {}
}
}
}

View file

@ -8,8 +8,8 @@ use libp2p::{
core::{ConnectedPoint, Endpoint},
swarm::{
derive_prelude::{ConnectionEstablished, ConnectionId, FromSwarm},
ConnectionClosed, ConnectionDenied, NetworkBehaviour, PollParameters, THandler,
THandlerInEvent, THandlerOutEvent, ToSwarm,
ConnectionClosed, ConnectionDenied, NetworkBehaviour, THandler, THandlerInEvent,
THandlerOutEvent, ToSwarm,
},
Multiaddr,
};
@ -83,7 +83,7 @@ impl NetworkBehaviour for SpaceTime {
Ok(SpaceTimeConnection::new(peer_id, self.manager.clone()))
}
fn on_swarm_event(&mut self, event: FromSwarm<Self::ConnectionHandler>) {
fn on_swarm_event(&mut self, event: FromSwarm) {
match event {
FromSwarm::ConnectionEstablished(ConnectionEstablished {
peer_id,
@ -160,15 +160,7 @@ impl NetworkBehaviour for SpaceTime {
// }
}
}
FromSwarm::ListenFailure(_)
| FromSwarm::NewListener(_)
| FromSwarm::NewListenAddr(_)
| FromSwarm::ExpiredListenAddr(_)
| FromSwarm::ListenerError(_)
| FromSwarm::ListenerClosed(_)
| FromSwarm::NewExternalAddrCandidate(_)
| FromSwarm::ExternalAddrConfirmed(_)
| FromSwarm::ExternalAddrExpired(_) => {}
_ => {}
}
}
@ -181,11 +173,7 @@ impl NetworkBehaviour for SpaceTime {
self.pending_events.push_back(ToSwarm::GenerateEvent(event));
}
fn poll(
&mut self,
_: &mut Context<'_>,
_: &mut impl PollParameters,
) -> Poll<ToSwarm<Self::ToSwarm, THandlerInEvent<Self>>> {
fn poll(&mut self, _: &mut Context<'_>) -> Poll<ToSwarm<Self::ToSwarm, THandlerInEvent<Self>>> {
if let Some(ev) = self.pending_events.pop_front() {
return Poll::Ready(ev);
} else if self.pending_events.capacity() > EMPTY_QUEUE_SHRINK_THRESHOLD {

View file

@ -2,15 +2,13 @@ use libp2p::{
swarm::{
handler::{
ConnectionEvent, ConnectionHandler, ConnectionHandlerEvent, FullyNegotiatedInbound,
KeepAlive,
},
StreamUpgradeError, SubstreamProtocol,
SubstreamProtocol,
},
PeerId,
};
use std::{
collections::VecDeque,
io,
sync::Arc,
task::{Context, Poll},
time::Duration,
@ -33,7 +31,7 @@ pub struct SpaceTimeConnection {
OutboundProtocol,
<Self as ConnectionHandler>::OutboundOpenInfo,
<Self as ConnectionHandler>::ToBehaviour,
StreamUpgradeError<io::Error>,
// StreamUpgradeError<io::Error>,
>,
>,
}
@ -51,7 +49,6 @@ impl SpaceTimeConnection {
impl ConnectionHandler for SpaceTimeConnection {
type FromBehaviour = OutboundRequest;
type ToBehaviour = ManagerStreamAction2;
type Error = StreamUpgradeError<io::Error>;
type InboundProtocol = InboundProtocol;
type OutboundProtocol = OutboundProtocol;
type OutboundOpenInfo = ();
@ -87,20 +84,15 @@ impl ConnectionHandler for SpaceTimeConnection {
});
}
fn connection_keep_alive(&self) -> KeepAlive {
KeepAlive::Yes // TODO: Make this work how the old one did with storing it on `self` and updating on events
fn connection_keep_alive(&self) -> bool {
true // TODO: Make this work how the old one did with storing it on `self` and updating on events
}
fn poll(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<
ConnectionHandlerEvent<
Self::OutboundProtocol,
Self::OutboundOpenInfo,
Self::ToBehaviour,
StreamUpgradeError<io::Error>,
>,
ConnectionHandlerEvent<Self::OutboundProtocol, Self::OutboundOpenInfo, Self::ToBehaviour>,
> {
if let Some(event) = self.pending_events.pop_front() {
return Poll::Ready(event);
@ -142,6 +134,7 @@ impl ConnectionHandler for SpaceTimeConnection {
}
ConnectionEvent::LocalProtocolsChange(_) => {}
ConnectionEvent::RemoteProtocolsChange(_) => {}
_ => {}
}
}
}

View file

@ -168,3 +168,48 @@ impl From<ed25519_dalek::SigningKey> for Identity {
Self(value)
}
}
#[derive(Debug, Error)]
pub enum IdentityOrRemoteIdentityErr {
#[error("IdentityErr({0})")]
IdentityErr(#[from] IdentityErr),
#[error("InvalidFormat")]
InvalidFormat,
}
/// TODO
#[derive(Debug, PartialEq)]
pub enum IdentityOrRemoteIdentity {
Identity(Identity),
RemoteIdentity(RemoteIdentity),
}
impl IdentityOrRemoteIdentity {
pub fn remote_identity(&self) -> RemoteIdentity {
match self {
Self::Identity(identity) => identity.to_remote_identity(),
Self::RemoteIdentity(identity) => {
RemoteIdentity::from_bytes(identity.get_bytes().as_slice()).expect("unreachable")
}
}
}
}
impl IdentityOrRemoteIdentity {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityOrRemoteIdentityErr> {
match bytes[0] {
b'I' => Ok(Self::Identity(Identity::from_bytes(&bytes[1..])?)),
b'R' => Ok(Self::RemoteIdentity(RemoteIdentity::from_bytes(
&bytes[1..],
)?)),
_ => Err(IdentityOrRemoteIdentityErr::InvalidFormat),
}
}
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Identity(identity) => [&[b'I'], &*identity.to_bytes()].concat(),
Self::RemoteIdentity(identity) => [[b'R'].as_slice(), &identity.get_bytes()].concat(),
}
}
}

View file

@ -6,6 +6,7 @@ import { createRoutes } from './app';
export const RoutingContext = createContext<{
visible: boolean;
currentIndex: number;
tabId: string;
maxIndex: number;
routes: ReturnType<typeof createRoutes>;
} | null>(null);

View file

@ -19,7 +19,7 @@ import { useExplorerItemData } from '../util';
import { Image, ImageProps } from './Image';
import LayeredFileIcon from './LayeredFileIcon';
import { Original } from './Original';
import classes from './Thumb.module.scss';
import { useFrame } from './useFrame';
import { useBlackBars, useSize } from './utils';
export interface ThumbProps {
@ -47,6 +47,7 @@ type ThumbType = { variant: 'original' } | { variant: 'thumbnail' } | { variant:
export const FileThumb = forwardRef<HTMLImageElement, ThumbProps>((props, ref) => {
const isDark = useIsDark();
const platform = usePlatform();
const frame = useFrame();
const itemData = useExplorerItemData(props.data);
const filePath = getItemFilePath(props.data);
@ -58,11 +59,7 @@ export const FileThumb = forwardRef<HTMLImageElement, ThumbProps>((props, ref) =
}>({ original: 'notLoaded', thumbnail: 'notLoaded', icon: 'notLoaded' });
const childClassName = 'max-h-full max-w-full object-contain';
const frameClassName = clsx(
'rounded-sm border-2 border-app-line bg-app-darkBox',
props.frameClassName,
isDark ? classes.checkers : classes.checkersLight
);
const frameClassName = clsx(frame.className, props.frameClassName);
const thumbType = useMemo<ThumbType>(() => {
const thumbType = 'thumbnail';
@ -94,7 +91,7 @@ export const FileThumb = forwardRef<HTMLImageElement, ThumbProps>((props, ref) =
break;
case 'icon':
if (itemData.customIcon) return getIconByName(itemData.customIcon as any);
if (itemData.customIcon) return getIconByName(itemData.customIcon as any, isDark);
return getIcon(
// itemData.isDir || parent?.type === 'Node' ? 'Folder' :

View file

@ -0,0 +1,15 @@
import clsx from 'clsx';
import { useIsDark } from '~/hooks';
import classes from './Thumb.module.scss';
export const useFrame = () => {
const isDark = useIsDark();
const className = clsx(
'rounded-sm border-2 border-app-line bg-app-darkBox',
isDark ? classes.checkers : classes.checkersLight
);
return { className };
};

View file

@ -79,12 +79,11 @@ const Component = memo(({ children }: { children: RenderItem }) => {
const getElementById = useCallback(
(id: string) => {
if (!explorer.parent) return;
const itemId =
realOS === 'windows' && explorer.parent.type === 'Ephemeral'
? id.replaceAll('\\', '\\\\')
: id;
return document.querySelector(`[data-selectable-id="${itemId}"]`);
if (realOS === 'windows' && explorer.parent?.type === 'Ephemeral') {
id = id.replaceAll('\\', '\\\\');
}
return document.querySelector(`[data-selectable-id="${id}"]`);
},
[explorer.parent, realOS]
);

View file

@ -1,11 +1,13 @@
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { byteSize, getItemFilePath, useSelector, type ExplorerItem } from '@sd/client';
import { byteSize, getItemFilePath, useSelector, type ExplorerItem, useLibraryQuery } from '@sd/client';
import { useLocale } from '~/hooks';
import { useExplorerContext } from '../../../Context';
import { ExplorerDraggable } from '../../../ExplorerDraggable';
import { ExplorerDroppable, useExplorerDroppableContext } from '../../../ExplorerDroppable';
import { FileThumb } from '../../../FilePath/Thumb';
import { useFrame } from '../../../FilePath/useFrame';
import { explorerStore } from '../../../store';
import { useExplorerDraggable } from '../../../useExplorerDraggable';
import { RenamableItemText } from '../../RenamableItemText';
@ -49,7 +51,7 @@ const InnerDroppable = () => {
<>
<div
className={clsx(
'mb-1 aspect-square rounded-lg',
'mb-1 flex aspect-square items-center justify-center rounded-lg',
(item.selected || isDroppable) && 'bg-app-selectedItem'
)}
>
@ -62,7 +64,10 @@ const InnerDroppable = () => {
};
const ItemFileThumb = () => {
const frame = useFrame();
const item = useGridViewItemContext();
const isLabel = item.data.type === 'Label';
const { attributes, listeners, style, setDraggableRef } = useExplorerDraggable({
data: item.data
@ -71,12 +76,15 @@ const ItemFileThumb = () => {
return (
<FileThumb
data={item.data}
frame
frame={!isLabel}
cover={isLabel}
blackBars
extension
className={clsx('px-2 py-1', item.cut && 'opacity-60')}
className={clsx(
isLabel ? [frame.className, '!size-[90%] !rounded-md'] : 'px-2 py-1',
item.cut && 'opacity-60'
)}
ref={setDraggableRef}
frameClassName={clsx(item.data.type === "Label" && "!rounded-2xl")}
childProps={{
style,
...attributes,
@ -102,6 +110,7 @@ const ItemMetadata = () => {
selected={item.selected}
/>
<ItemSize />
{item.data.type === "Label" && <LabelItemCount data={item.data} />}
</ExplorerDraggable>
);
};
@ -138,3 +147,24 @@ const ItemSize = () => {
</div>
);
};
function LabelItemCount({data}: {data: Extract<ExplorerItem, {type: "Label"}>}) {
const { t } = useLocale();
const count = useLibraryQuery(["search.objectsCount", {
filters: [{
object: {
labels: {
in: [data.item.id]
}
}
}]
}])
if(count.data === undefined) return
return <div className="truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull">
{t("item_with_count", {count: count.data})}
</div>
}

View file

@ -11,6 +11,7 @@ import {
import { dialogManager } from '@sd/ui';
import { Loader } from '~/components';
import { useKeyCopyCutPaste, useKeyMatcher, useShortcut } from '~/hooks';
import { useRoutingContext } from '~/RoutingContext';
import { isNonEmpty } from '~/util';
import CreateDialog from '../../settings/library/tags/CreateDialog';
@ -47,6 +48,8 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
const [{ path }] = useExplorerSearchParams();
const { visible } = useRoutingContext();
const ref = useRef<HTMLDivElement | null>(null);
const [showLoading, setShowLoading] = useState(false);
@ -81,11 +84,12 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
useShortcuts();
useEffect(() => {
if (!isContextMenuOpen || explorer.selectedItems.size !== 0) return;
if (!visible || !isContextMenuOpen || explorer.selectedItems.size !== 0) return;
// Close context menu when no items are selected
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
explorerStore.isContextMenuOpen = false;
}, [explorer.selectedItems, isContextMenuOpen]);
}, [explorer.selectedItems, isContextMenuOpen, visible]);
useEffect(() => {
if (explorer.isFetchingNextPage) {

View file

@ -1,4 +1,4 @@
import { ArrowsClockwise, Cloud, Database, Factory } from '@phosphor-icons/react';
import { ArrowsClockwise, Cloud, Database, Factory, ShareNetwork } from '@phosphor-icons/react';
import { useFeatureFlag } from '@sd/client';
import Icon from '../../SidebarLayout/Icon';
@ -29,6 +29,10 @@ export default function DebugSection() {
<Icon component={Factory} />
Actors
</SidebarLink>
<SidebarLink to="debug/p2p">
<Icon component={ShareNetwork} />
P2P
</SidebarLink>
</div>
</Section>
);

View file

@ -1,7 +1,6 @@
import { Link } from 'react-router-dom';
import { useBridgeQuery, useFeatureFlag } from '@sd/client';
import { useBridgeQuery } from '@sd/client';
import { Button, Tooltip } from '@sd/ui';
import { Icon, SubtleButton } from '~/components';
import { Icon } from '~/components';
import { useLocale } from '~/hooks';
import SidebarLink from '../../SidebarLayout/Link';
@ -9,21 +8,11 @@ import Section from '../../SidebarLayout/Section';
export default function DevicesSection() {
const { data: node } = useBridgeQuery(['nodeState']);
const isPairingEnabled = useFeatureFlag('p2pPairing');
const { t } = useLocale();
return (
<Section
name="Devices"
actionArea={
isPairingEnabled && (
<Link to="settings/library/nodes">
<SubtleButton />
</Link>
)
}
>
<Section name={t('devices')}>
{node && (
<SidebarLink className="group relative w-full" to={`node/${node.id}`} key={node.id}>
<Icon name="Laptop" className="mr-1 h-5 w-5" />

View file

@ -1,5 +1,6 @@
import { Clock, Heart, Planet, Tag } from '@phosphor-icons/react';
import { useLibraryQuery } from '@sd/client';
import { useLocale } from '~/hooks';
import Icon from '../../SidebarLayout/Icon';
import SidebarLink from '../../SidebarLayout/Link';
@ -9,25 +10,27 @@ export const COUNT_STYLE = `absolute right-1 min-w-[20px] top-1 flex h-[19px] px
export default function LibrarySection() {
const labelCount = useLibraryQuery(['labels.count']);
const { t } = useLocale();
return (
<div className="space-y-0.5">
<SidebarLink to="overview">
<Icon component={Planet} />
Overview
{t('overview')}
</SidebarLink>
<SidebarLink to="recents">
<Icon component={Clock} />
Recents
{t('recents')}
{/* <div className={COUNT_STYLE}>34</div> */}
</SidebarLink>
<SidebarLink to="favorites">
<Icon component={Heart} />
Favorites
{t('favorites')}
{/* <div className={COUNT_STYLE}>2</div> */}
</SidebarLink>
<SidebarLink to="labels">
<Icon component={Tag} />
Labels
{t('labels')}
<div className={COUNT_STYLE}>{labelCount.data || 0}</div>
</SidebarLink>
</div>

View file

@ -4,6 +4,7 @@ import { PropsWithChildren, useMemo } from 'react';
import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { Button, toast, tw } from '@sd/ui';
import { Icon, IconName } from '~/components';
import { useLocale } from '~/hooks';
import { useHomeDir } from '~/hooks/useHomeDir';
import { useExplorerDroppable } from '../../../../Explorer/useExplorerDroppable';
@ -39,6 +40,8 @@ export default function LocalSection() {
useNodes(result.data?.nodes);
const volumes = useCache(result.data?.items);
const { t } = useLocale();
// this will return an array of location ids that are also volumes
// { "/Mount/Point": 1, "/Mount/Point2": 2"}
const locationIdsForVolumes = useMemo(() => {
@ -74,11 +77,11 @@ export default function LocalSection() {
);
return (
<Section name="Local">
<Section name={t('local')}>
<SeeMore>
<SidebarLink className="group relative w-full" to="network">
<SidebarIcon name="Globe" />
<Name>Network</Name>
<Name>{t('network')}</Name>
</SidebarLink>
{homeDir.data && (
@ -87,7 +90,7 @@ export default function LocalSection() {
path={homeDir.data ?? ''}
>
<SidebarIcon name="Home" />
<Name>Home</Name>
<Name>{t('home')}</Name>
</EphemeralLocation>
)}

View file

@ -12,6 +12,7 @@ import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDropp
import { useExplorerSearchParams } from '~/app/$libraryId/Explorer/util';
import { AddLocationButton } from '~/app/$libraryId/settings/library/locations/AddLocationButton';
import { Icon, SubtleButton } from '~/components';
import { useLocale } from '~/hooks';
import SidebarLink from '../../SidebarLayout/Link';
import Section from '../../SidebarLayout/Section';
@ -24,9 +25,11 @@ export default function Locations() {
const locations = useCache(locationsQuery.data?.items);
const onlineLocations = useOnlineLocations();
const { t } = useLocale();
return (
<Section
name="Locations"
name={t('locations')}
actionArea={
<Link to="settings/library/locations">
<SubtleButton />

View file

@ -5,6 +5,7 @@ import { useLibraryMutation, useLibraryQuery, type SavedSearch } from '@sd/clien
import { Button } from '@sd/ui';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { Folder } from '~/components';
import { useLocale } from '~/hooks';
import SidebarLink from '../../SidebarLayout/Link';
import Section from '../../SidebarLayout/Section';
@ -23,6 +24,8 @@ export default function SavedSearches() {
const navigate = useNavigate();
const { t } = useLocale();
const deleteSavedSearch = useLibraryMutation(['search.saved.delete'], {
onSuccess() {
if (currentIndex !== undefined && savedSearches.data) {
@ -40,7 +43,7 @@ export default function SavedSearches() {
return (
<Section
name="Saved Searches"
name={t('saved_searches')}
// actionArea={
// <Link to="settings/library/saved-searches">
// <SubtleButton />

View file

@ -1,8 +1,10 @@
import clsx from 'clsx';
import { t } from 'i18next';
import { NavLink, useMatch } from 'react-router-dom';
import { useCache, useLibraryQuery, useNodes, type Tag } from '@sd/client';
import { useExplorerDroppable } from '~/app/$libraryId/Explorer/useExplorerDroppable';
import { SubtleButton } from '~/components';
import { useLocale } from '~/hooks';
import SidebarLink from '../../SidebarLayout/Link';
import Section from '../../SidebarLayout/Section';
@ -14,11 +16,13 @@ export default function TagsSection() {
useNodes(result.data?.nodes);
const tags = useCache(result.data?.items);
const { t } = useLocale();
if (!tags?.length) return null;
return (
<Section
name="Tags"
name={t('tags')}
actionArea={
<NavLink to="settings/library/tags">
<SubtleButton />

View file

@ -1,14 +1,18 @@
import { Plus, X } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useLayoutEffect, useRef } from 'react';
import { useKey } from 'rooks';
import useResizeObserver from 'use-resize-observer';
import { useSelector } from '@sd/client';
import { Tooltip } from '@sd/ui';
import { useKeyMatcher, useLocale, useOperatingSystem, useShowControls } from '~/hooks';
import clsx from 'clsx';
import { useLayoutEffect, useRef } from 'react';
import useResizeObserver from 'use-resize-observer';
import { useRoutingContext } from '~/RoutingContext';
import { useTabsContext } from '~/TabsContext';
import { usePlatform } from '~/util/Platform';
import {
useKeyMatcher,
useLocale,
useOperatingSystem,
useShortcut,
useShowControls
} from '~/hooks';
import { explorerStore } from '../Explorer/store';
import { useTopBarContext } from './Layout';
@ -39,8 +43,6 @@ const TopBar = () => {
ctx.setTopBarHeight.call(undefined, height);
}, [ctx.setTopBarHeight]);
const platform = usePlatform();
return (
<div
ref={ref}
@ -155,34 +157,35 @@ function useTabKeybinds(props: { addTab(): void; removeTab(index: number): void
const os = useOperatingSystem();
const { visible } = useRoutingContext();
// these keybinds aren't part of the regular shortcuts system as they're desktop-only
useKey(['t'], (e) => {
useShortcut('newTab', (e) => {
if (!visible) return;
if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return;
e.stopPropagation();
props.addTab();
});
useKey(['w'], (e) => {
useShortcut('closeTab', (e) => {
if (!visible) return;
if ((os === 'macOS' && !e.metaKey) || (os !== 'macOS' && !e.ctrlKey)) return;
e.stopPropagation();
props.removeTab(ctx.tabIndex);
});
useKey(['ArrowLeft', 'ArrowRight'], (e) => {
useShortcut('nextTab', (e) => {
if (!visible) return;
// TODO: figure out non-macos keybind
if ((os === 'macOS' && !(e.metaKey && e.altKey)) || os !== 'macOS') return;
e.stopPropagation();
const delta = e.key === 'ArrowLeft' ? -1 : 1;
ctx.setTabIndex(Math.min(ctx.tabIndex + 1, ctx.tabs.length - 1));
});
ctx.setTabIndex(Math.min(Math.max(0, ctx.tabIndex + delta), ctx.tabs.length - 1));
useShortcut('previousTab', (e) => {
if (!visible) return;
e.stopPropagation();
ctx.setTabIndex(Math.max(ctx.tabIndex - 1, 0));
});
}

View file

@ -28,6 +28,7 @@ function Authenticated() {
const cloudLibrary = useLibraryQuery(['cloud.library.get'], { suspense: true, retry: false });
const createLibrary = useLibraryMutation(['cloud.library.create']);
const syncLibrary = useLibraryMutation(['cloud.library.sync']);
const thisInstance = cloudLibrary.data?.instances.find(
(instance) => instance.uuid === library.instance_id
@ -41,6 +42,16 @@ function Authenticated() {
<p>Library</p>
<p>Name: {cloudLibrary.data.name}</p>
</div>
<Button
disabled={syncLibrary.isLoading}
onClick={() => {
syncLibrary.mutateAsync(null);
}}
>
Sync Library
</Button>
{thisInstance && (
<div>
<p>This Instance</p>

View file

@ -4,5 +4,6 @@ export const debugRoutes = [
{ path: 'cache', lazy: () => import('./cache') },
{ path: 'cloud', lazy: () => import('./cloud') },
{ path: 'sync', lazy: () => import('./sync') },
{ path: 'actors', lazy: () => import('./actors') }
{ path: 'actors', lazy: () => import('./actors') },
{ path: 'p2p', lazy: () => import('./p2p') }
] satisfies RouteObject[];

View file

@ -0,0 +1,59 @@
import { useBridgeQuery, useCache, useConnectedPeers, useNodes } from '@sd/client';
export const Component = () => {
const node = useBridgeQuery(['nodeState']);
return (
<div className="p-4">
{node.data?.p2p_enabled === false ? (
<h1 className="text-red-500">P2P is disabled. Please enable it in settings!</h1>
) : (
<Page />
)}
</div>
);
};
function Page() {
const p2pState = useBridgeQuery(['p2p.state'], {
refetchInterval: 1000
});
const result = useBridgeQuery(['library.list']);
const connectedPeers = useConnectedPeers();
useNodes(result.data?.nodes);
const libraries = useCache(result.data?.items);
return (
<div className="flex flex-col space-y-8">
<div>
<h1 className="mt-4">Connected to:</h1>
{connectedPeers.size === 0 && <p className="pl-2">None</p>}
{[...connectedPeers.entries()].map(([id, node]) => (
<div key={id} className="flex space-x-2">
<p>{id}</p>
</div>
))}
</div>
<div>
<p>Current nodes libraries:</p>
{libraries.map((v) => (
<div key={v.uuid} className="pb-2 pl-3">
<p>
{v.config.name} - {v.uuid}
</p>
<div className="pl-8">
<p>Instance: {`${v.config.instance_id}/${v.instance_id}`}</p>
<p>Instance PK: {`${v.instance_public_key}`}</p>
</div>
</div>
))}
</div>
<div>
<p>NLM State:</p>
<pre>{JSON.stringify(p2pState.data || {}, undefined, 2)}</pre>
</div>
</div>
);
}

View file

@ -1,17 +1,10 @@
import { useMemo } from 'react';
import {
ObjectFilterArgs,
ObjectKindEnum,
ObjectOrder,
SearchFilterArgs,
useLibraryQuery
} from '@sd/client';
import { ObjectOrder, useLibraryQuery } from '@sd/client';
import { Icon } from '~/components';
import { useRouteTitle } from '~/hooks';
import Explorer from './Explorer';
import { ExplorerContextProvider } from './Explorer/Context';
import { useObjectsExplorerQuery } from './Explorer/queries/useObjectsExplorerQuery';
import { createDefaultExplorerSettings, objectOrderingKeysSchema } from './Explorer/store';
import { DefaultTopBarOptions } from './Explorer/TopBarOptions';
import { useExplorer, useExplorerSettings } from './Explorer/useExplorer';
@ -57,7 +50,7 @@ export function Component() {
items: labels.data || null,
settings: explorerSettings,
showPathBar: false,
layouts: { media: false }
layouts: { media: false, list: false }
});
return (

View file

@ -1,18 +1,42 @@
// import { X } from '@phosphor-icons/react';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import { Icon, IconName } from '~/components';
interface NewCardProps {
icons: IconName[];
text: string;
buttonText?: string;
}
type NewCardProps =
| {
icons: IconName[];
text: string;
className?: string;
button?: () => JSX.Element;
buttonText?: never;
buttonHandler?: never;
}
| {
icons: IconName[];
text: string;
className?: string;
buttonText: string;
buttonHandler: () => void;
button?: never;
};
const maskImage = `linear-gradient(90deg, transparent 0.1%, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1) 35%, transparent 99%)`;
const NewCard = ({ icons, text, buttonText }: NewCardProps) => {
export default function NewCard({
icons,
text,
buttonText,
buttonHandler,
button,
className
}: NewCardProps) {
return (
<div className="flex h-[170px] w-[280px] shrink-0 flex-col justify-between rounded border border-dashed border-app-line p-4">
<div
className={clsx(
'flex h-[170px] w-[280px] shrink-0 flex-col justify-between rounded border border-dashed border-app-line p-4',
className
)}
>
<div className="flex flex-row items-start justify-between">
<div
className="flex flex-row"
@ -27,16 +51,19 @@ const NewCard = ({ icons, text, buttonText }: NewCardProps) => {
</div>
))}
</div>
{/* <Button size="icon" variant="outline">
<X weight="bold" className="h-3 w-3 opacity-50" />
</Button> */}
</div>
<span className="text-sm text-ink-dull">{text}</span>
<Button disabled={!buttonText} variant="outline">
{buttonText ? buttonText : 'Coming Soon'}
</Button>
{button ? (
button()
) : (
<button
onClick={buttonHandler}
disabled={!buttonText}
className="text-sm font-medium text-ink-dull"
>
{buttonText ? buttonText : 'Coming Soon'}
</button>
)}
</div>
);
};
export default NewCard;
}

View file

@ -1,9 +1,12 @@
import { Link } from 'react-router-dom';
import { useBridgeQuery, useCache, useLibraryQuery, useNodes } from '@sd/client';
import { useLocale } from '~/hooks';
import { useRouteTitle } from '~/hooks/useRouteTitle';
import { hardwareModelToIcon } from '~/util/hardware';
import { SearchContextProvider, useSearch } from '../search';
import SearchBar from '../search/SearchBar';
import { AddLocationButton } from '../settings/library/locations/AddLocationButton';
import { TopBarPortal } from '../TopBar/Portal';
import FileKindStatistics from './FileKindStats';
import OverviewSection from './Layout/Section';
@ -14,15 +17,15 @@ import StatisticItem from './StatCard';
export const Component = () => {
useRouteTitle('Overview');
const { t } = useLocale();
const locationsQuery = useLibraryQuery(['locations.list'], { keepPreviousData: true });
useNodes(locationsQuery.data?.nodes);
const locations = useCache(locationsQuery.data?.items) ?? [];
const { data: node } = useBridgeQuery(['nodeState']);
const search = useSearch({
open: true
});
const search = useSearch();
const stats = useLibraryQuery(['library.statistics']);
@ -32,10 +35,12 @@ export const Component = () => {
<TopBarPortal
left={
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">Library Overview</span>
<span className="truncate text-sm font-medium">
{t('library_overview')}
</span>
</div>
}
center={<SearchBar />}
center={<SearchBar redirectToSearch />}
// right={
// <TopBarOptions
// options={[
@ -127,27 +132,29 @@ export const Component = () => {
<NewCard
icons={['Laptop', 'Server', 'SilverBox', 'Tablet']}
text="Spacedrive works best on all your devices."
className="h-auto"
// buttonText="Connect a device"
/>
{/**/}
</OverviewSection>
<OverviewSection count={locations.length} title="Locations">
<OverviewSection count={locations.length} title={t('locations')}>
{locations?.map((item) => (
<StatisticItem
key={item.id}
name={item.name || 'Unnamed Location'}
icon="Folder"
totalSpace={item.size_in_bytes || [0]}
color="#0362FF"
connectionType={null}
/>
<Link key={item.id} to={`../location/${item.id}`}>
<StatisticItem
name={item.name || t('unnamed_location')}
icon="Folder"
totalSpace={item.size_in_bytes || [0]}
color="#0362FF"
connectionType={null}
/>
</Link>
))}
{!locations?.length && (
<NewCard
icons={['HDD', 'Folder', 'Globe', 'SD']}
text="Connect a local path, volume or network location to Spacedrive."
buttonText="Add a Location"
button={() => <AddLocationButton variant="outline" />}
/>
)}
</OverviewSection>

View file

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { ObjectFilterArgs, ObjectKindEnum, ObjectOrder, SearchFilterArgs } from '@sd/client';
import { ObjectKindEnum, ObjectOrder, SearchFilterArgs } from '@sd/client';
import { Icon } from '~/components';
import { useRouteTitle } from '~/hooks';
@ -45,7 +45,7 @@ export function Component() {
take: 100,
filters: [
...search.allFilters,
// TODO: Add filter to search options
// TODO: Add fil ter to search options
{ object: { dateAccessed: { from: new Date(0).toISOString() } } }
]
},
@ -65,7 +65,7 @@ export function Component() {
center={<SearchBar />}
left={
<div className="flex flex-row items-center gap-2">
<span className="truncate text-sm font-medium">Recents</span>
<span className="text-sm font-medium truncate">Recents</span>
</div>
}
right={<DefaultTopBarOptions />}

View file

@ -1,4 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { createSearchParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { Input, ModifierKeys, Shortcut } from '@sd/ui';
import { useOperatingSystem } from '~/hooks';
@ -7,10 +9,14 @@ import { keybindForOs } from '~/util/keybinds';
import { useSearchContext } from './context';
import { useSearchStore } from './store';
export default () => {
interface Props {
redirectToSearch?: boolean;
}
export default ({ redirectToSearch }: Props) => {
const search = useSearchContext();
const searchRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const searchStore = useSearchStore();
const os = useOperatingSystem(true);
@ -55,6 +61,14 @@ export default () => {
const updateDebounce = useDebouncedCallback((value: string) => {
search.setSearch(value);
if (redirectToSearch) {
navigate({
pathname: '../search',
search: createSearchParams({
search: value
}).toString()
});
}
}, 300);
function updateValue(value: string) {
@ -70,10 +84,12 @@ export default () => {
<Input
ref={searchRef}
placeholder="Search"
className="mx-2 w-48 transition-all duration-200 focus-within:w-60"
className="w-48 mx-2 transition-all duration-200 focus-within:w-60"
size="sm"
value={value}
onChange={(e) => updateValue(e.target.value)}
onChange={(e) => {
updateValue(e.target.value);
}}
onBlur={() => {
if (search.rawSearch === '' && !searchStore.interactingWithSearchOptions) {
clearValue();
@ -82,7 +98,7 @@ export default () => {
}}
onFocus={() => search.setSearchBarFocused(true)}
right={
<div className="pointer-events-none flex h-7 items-center space-x-1 opacity-70 group-focus-within:hidden">
<div className="flex items-center space-x-1 pointer-events-none h-7 opacity-70 group-focus-within:hidden">
{
<Shortcut
chars={keybind([ModifierKeys.Control], ['F'])}

View file

@ -69,8 +69,8 @@ export function Component() {
<Explorer
emptyNotice={
<EmptyNotice
icon={<Icon name="Collection" size={128} />}
message="No recent items"
icon={<Icon name="Search" size={128} />}
message="No items found"
/>
}
/>

View file

@ -8,6 +8,7 @@ import {
useZodForm
} from '@sd/client';
import { Button, Card, Input, Select, SelectOption, Slider, Switch, tw, z } from '@sd/ui';
import i18n from '~/app/I18n';
import { Icon } from '~/components';
import { useDebouncedFormWatch, useLocale } from '~/hooks';
import { usePlatform } from '~/util/Platform';
@ -21,6 +22,16 @@ const NodeSettingLabel = tw.div`mb-1 text-xs font-medium`;
// https://doc.rust-lang.org/std/u16/index.html
const u16 = z.number().min(0).max(65_535);
const LANGUAGE_OPTIONS = [
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'zh-CN', label: '中文(简体)' },
{ value: 'zh-TW', label: '中文(繁體)' }
];
export const Component = () => {
const node = useBridgeQuery(['nodeState']);
const platform = usePlatform();
@ -96,6 +107,7 @@ export const Component = () => {
title={t('general_settings')}
description={t('general_settings_description')}
/>
{/* Node Card */}
<Card className="px-5">
<div className="my-2 flex w-full flex-col">
<div className="flex flex-row items-center justify-between">
@ -161,7 +173,7 @@ export const Component = () => {
}
}}
>
Open
{t('open')}
</Button>
{/* <Button size="sm" variant="outline">
Change
@ -183,7 +195,27 @@ export const Component = () => {
</div> */}
</div>
</Card>
{/* Language Settings */}
<Setting mini title={t('language')} description={t('language_description')}>
<div className="flex h-[30px] gap-2">
<Select
value={i18n.language}
onChange={(e) => {
// add "i18nextLng" key to localStorage and set it to the selected language
i18n.changeLanguage(e);
localStorage.setItem('i18nextLng', e);
}}
containerClassName="h-[30px] whitespace-nowrap"
>
{LANGUAGE_OPTIONS.map((lang, key) => (
<SelectOption key={key} value={lang.value}>
{lang.label}
</SelectOption>
))}
</Select>
</div>
</Setting>
{/* Debug Mode */}
<Setting mini title={t('debug_mode')} description={t('debug_mode_description')}>
<Switch
size="md"
@ -191,6 +223,7 @@ export const Component = () => {
onClick={() => (debugState.enabled = !debugState.enabled)}
/>
</Setting>
{/* Background Processing */}
<Setting
mini
registerName="background_processing_percentage"
@ -223,13 +256,14 @@ export const Component = () => {
/>
</div>
</Setting>
{/* Image Labeler */}
<Setting
mini
title={t('image_labeler_ai_model')}
description={t('image_labeler_ai_model_description')}
registerName="image_labeler_version"
>
<div className="flex h-[30px] gap-2">
<div className="flex h-[30px]">
<Controller
name="image_labeler_version"
disabled={node.data?.image_labeler_version == null}

View file

@ -14,7 +14,6 @@ export default [
// { path: 'saved-searches', lazy: () => import('./saved-searches') },
//this is for edit in tags context menu
{ path: 'tags/:id', lazy: () => import('./tags') },
{ path: 'nodes', lazy: () => import('./nodes') },
{ path: 'locations', lazy: () => import('./locations') }
]
},

View file

@ -31,7 +31,7 @@ const FlexCol = tw.label`flex flex-col flex-1`;
const ToggleSection = tw.label`flex flex-row w-full`;
const schema = z.object({
name: z.string().nullable(),
name: z.string().min(1).nullable(),
path: z.string().min(1).nullable(),
hidden: z.boolean().nullable(),
indexerRulesIds: z.array(z.number()),

View file

@ -1,7 +1,7 @@
import { FolderSimplePlus } from '@phosphor-icons/react';
import clsx from 'clsx';
import { motion } from 'framer-motion';
import { useRef, useState } from 'react';
import { ComponentProps, useRef, useState } from 'react';
import { useLibraryContext } from '@sd/client';
import { Button, dialogManager, type ButtonProps } from '@sd/ui';
import { useCallbackToWatchResize } from '~/hooks';
@ -13,9 +13,16 @@ import { openDirectoryPickerDialog } from './openDirectoryPickerDialog';
interface AddLocationButton extends ButtonProps {
path?: string;
onClick?: () => void;
buttonVariant?: ComponentProps<typeof Button>['variant'];
}
export const AddLocationButton = ({ path, className, onClick, ...props }: AddLocationButton) => {
export const AddLocationButton = ({
path,
className,
onClick,
buttonVariant = 'dotted',
...props
}: AddLocationButton) => {
const platform = usePlatform();
const libraryId = useLibraryContext().library.uuid;
@ -53,7 +60,7 @@ export const AddLocationButton = ({ path, className, onClick, ...props }: AddLoc
return (
<>
<Button
variant="dotted"
variant={buttonVariant}
className={clsx('w-full', className)}
onClick={async () => {
await locationDialogHandler();

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