mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-02 11:13:29 +00:00
commit
201913a197
2
.github/actions/setup-rust/action.yaml
vendored
2
.github/actions/setup-rust/action.yaml
vendored
|
@ -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
527
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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" }
|
||||
|
|
|
@ -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" }
|
||||
|
|
44
apps/desktop/src-tauri/src/clear_localstorage.rs
Normal file
44
apps/desktop/src-tauri/src/clear_localstorage.rs
Normal 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`.")
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
})
|
||||
|
|
|
@ -44,7 +44,6 @@
|
|||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "",
|
||||
"wix": {
|
||||
"enableElevatedUpdateTask": true,
|
||||
"dialogImagePath": "icons/WindowsDialogImage.bmp",
|
||||
"bannerPath": "icons/WindowsBanner.bmp"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}}
|
||||
/>,
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
},
|
||||
"ios": {
|
||||
"useFrameworks": "static",
|
||||
"deploymentTarget": "13.0"
|
||||
"deploymentTarget": "14.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
BIN
apps/mobile/assets/rive/tabs.riv
Normal file
BIN
apps/mobile/assets/rive/tabs.riv
Normal file
Binary file not shown.
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -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`}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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: []
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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)}`}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -10,6 +10,3 @@ appId: com.spacedrive.app
|
|||
- tapOn:
|
||||
id: 'share-minimal'
|
||||
- tapOn: 'Continue'
|
||||
- tapOn:
|
||||
id: 'browse-tab'
|
||||
- assertVisible: 'TestLib'
|
||||
|
|
|
@ -112,6 +112,7 @@ function App() {
|
|||
<SpacedriveRouterProvider
|
||||
routing={{
|
||||
...router,
|
||||
tabId: '',
|
||||
routes,
|
||||
visible: true
|
||||
}}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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(())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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?)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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(())
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
@ -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>)>;
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -190,7 +190,7 @@ pub async fn shallow(
|
|||
invalidate_query!(library, "search.objects");
|
||||
}
|
||||
|
||||
library.orphan_remover.invoke().await;
|
||||
// library.orphan_remover.invoke().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -54,3 +54,9 @@ impl TryFrom<u8> for Platform {
|
|||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Platform> for u8 {
|
||||
fn from(platform: Platform) -> Self {
|
||||
platform as u8
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 })))
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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::*;
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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| {
|
||||
|
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
12
crates/actors/Cargo.toml
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -299,6 +299,7 @@ impl ManagerStream {
|
|||
}
|
||||
SwarmEvent::ListenerError { listener_id, error } => warn!("listener '{:?}' reported a non-fatal error: {}", listener_id, error),
|
||||
SwarmEvent::Dialing { .. } => {},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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' :
|
||||
|
|
15
interface/app/$libraryId/Explorer/FilePath/useFrame.tsx
Normal file
15
interface/app/$libraryId/Explorer/FilePath/useFrame.tsx
Normal 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 };
|
||||
};
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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[];
|
||||
|
|
59
interface/app/$libraryId/debug/p2p.tsx
Normal file
59
interface/app/$libraryId/debug/p2p.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />}
|
||||
|
|
|
@ -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'])}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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') }
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue