diff --git a/.github/actions/setup-rust/action.yaml b/.github/actions/setup-rust/action.yaml index 04636be62..53b2222b1 100644 --- a/.github/actions/setup-rust/action.yaml +++ b/.github/actions/setup-rust/action.yaml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index ad46612d9..2755b8af2 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/Cargo.toml b/Cargo.toml index 3a5fa505e..6a3396275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 56cb9cd62..3625b469c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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 "] 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" } diff --git a/apps/desktop/src-tauri/src/clear_localstorage.rs b/apps/desktop/src-tauri/src/clear_localstorage.rs new file mode 100644 index 000000000..c70afe362 --- /dev/null +++ b/apps/desktop/src-tauri/src/clear_localstorage.rs @@ -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`.") + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2f87f8224..791feff7d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -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 { diff --git a/apps/desktop/src-tauri/src/updater.rs b/apps/desktop/src-tauri/src/updater.rs index 696492177..0e039e752 100644 --- a/apps/desktop/src-tauri/src/updater.rs +++ b/apps/desktop/src-tauri/src/updater.rs @@ -108,7 +108,7 @@ pub fn plugin() -> TauriPlugin { if updater_available { window - .eval("window.__SD_UPDATER__ = true") + .eval("window.__SD_UPDATER__ = true;") .expect("Failed to inject updater JS"); } }) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a137398de..a7132539d 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -44,7 +44,6 @@ "digestAlgorithm": "sha256", "timestampUrl": "", "wix": { - "enableElevatedUpdateTask": true, "dialogImagePath": "icons/WindowsDialogImage.bmp", "bannerPath": "icons/WindowsBanner.bmp" } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2fcd935d1..57e45a480 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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(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 ( ({ - 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] + [] )} > ({ 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() { }} > - {tabs.map((tab) => + {tabs.map((tab, index) => createPortal( , diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 5831cb3c4..209484775 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -54,7 +54,7 @@ }, "ios": { "useFrameworks": "static", - "deploymentTarget": "13.0" + "deploymentTarget": "14.0" } } ] diff --git a/apps/mobile/assets/rive/tabs.riv b/apps/mobile/assets/rive/tabs.riv new file mode 100644 index 000000000..1db36b7bc Binary files /dev/null and b/apps/mobile/assets/rive/tabs.riv differ diff --git a/apps/mobile/modules/sd-core/ios/SDCore.podspec b/apps/mobile/modules/sd-core/ios/SDCore.podspec index 9f8ba7c26..02dc90a62 100644 --- a/apps/mobile/modules/sd-core/ios/SDCore.podspec +++ b/apps/mobile/modules/sd-core/ios/SDCore.podspec @@ -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 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 88db05897..f1655ac32 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/src/components/browse/BrowseLocations.tsx b/apps/mobile/src/components/browse/BrowseLocations.tsx index 34fb9513b..f2dfe0da0 100644 --- a/apps/mobile/src/components/browse/BrowseLocations.tsx +++ b/apps/mobile/src/components/browse/BrowseLocations.tsx @@ -39,9 +39,9 @@ const BrowseLocationItem: React.FC = ({ return ( - + @@ -97,7 +97,7 @@ const BrowseLocations = () => { return ( - + Locations { navigation.navigate('Locations'); }} > - + modalRef.current?.present()}> diff --git a/apps/mobile/src/components/browse/BrowseTags.tsx b/apps/mobile/src/components/browse/BrowseTags.tsx index d5b3553af..47410ebf4 100644 --- a/apps/mobile/src/components/browse/BrowseTags.tsx +++ b/apps/mobile/src/components/browse/BrowseTags.tsx @@ -22,7 +22,7 @@ const BrowseTagItem: React.FC = ({ tag, onPress }) => { return ( { - + @@ -83,7 +83,7 @@ const BrowseLibraryManager = ({ style }: Props) => { { - modalRef.current?.present() + modalRef.current?.present(); setDropdownClosed(true); }} > diff --git a/apps/mobile/src/components/browse/Jobs.tsx b/apps/mobile/src/components/browse/Jobs.tsx index 627e5cd35..8d2e791fa 100644 --- a/apps/mobile/src/components/browse/Jobs.tsx +++ b/apps/mobile/src/components/browse/Jobs.tsx @@ -45,7 +45,7 @@ const Job = ({ progress, message, error }: JobProps) => { : tw.color('accent'); return ( - - + + + {navBack && ( 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 ( - - - {props.leftIcon && - props.leftIcon({ size: 20, color: tw.color('ink'), style: tw`mr-3` })} - {props.title} + + + {props.leftIcon && ( + + {props.leftIcon({ size: 20, color: tw.color('ink-dull') })} + + )} + + {props.title} + + - {props.rightArea ? ( - props.rightArea - ) : ( - - )} ); } - -export function SettingsItemDivider(props: { style?: ViewStyle }) { - return ( - - - - ); -} diff --git a/apps/mobile/src/constants/style/tailwind.js b/apps/mobile/src/constants/style/tailwind.js index 6794b0254..9642d67bc 100644 --- a/apps/mobile/src/constants/style/tailwind.js +++ b/apps/mobile/src/constants/style/tailwind.js @@ -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: [] }; }; diff --git a/apps/mobile/src/navigation/TabNavigator.tsx b/apps/mobile/src/navigation/TabNavigator.tsx index 5118fc8fd..a4eddde2f 100644 --- a/apps/mobile/src/navigation/TabNavigator.tsx +++ b/apps/mobile/src/navigation/TabNavigator.tsx @@ -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(); +//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: ( + + ), + label: 'Overview', + labelStyle: tw`text-[10px] font-semibold`, + testID: 'overview-tab' + }, + { + name: 'NetworkStack', + component: NetworkStack, + icon: ( + + ), + label: 'Network', + labelStyle: tw`text-[10px] font-semibold`, + testID: 'network-tab' + }, + { + name: 'BrowseStack', + component: BrowseStack, + icon: ( + + ), + label: 'Browse', + labelStyle: tw`text-[10px] font-semibold`, + testID: 'browse-tab' + }, + { + name: 'SettingsStack', + component: SettingsStack, + icon: ( + + ), + label: 'Settings', + labelStyle: tw`text-[10px] font-semibold`, + testID: 'settings-tab' + } + ]; return ( - ( - - ), - tabBarLabel: 'Overview', - tabBarLabelStyle: tw`text-[10px] font-semibold` - }} - /> - ( - - ), - tabBarLabel: 'Network', - tabBarLabelStyle: tw`text-[10px] font-semibold` - }} - /> - ( - - ), - tabBarTestID: 'browse-tab', - tabBarLabel: 'Browse', - tabBarLabelStyle: tw`text-[10px] font-semibold` - }} - /> - ( - - ), - tabBarTestID: 'settings-tab', - tabBarLabel: 'Settings', - tabBarLabelStyle: tw`text-[10px] font-semibold` - }} - /> + {TabScreens.map((screen, index) => ( + ( + {screen.icon} + ), + tabBarTestID: screen.testID + }} + listeners={() => ({ + tabPress: () => { + setActiveIndex(index); + } + })} + /> + ))} ); } +interface TabBarButtonProps { + active: boolean; + resourceName: string; + animationName: string; + artboardName: string; + style?: any; +} + +const TabBarButton = ({ + active, + resourceName, + animationName, + artboardName, + style +}: TabBarButtonProps) => { + const ref = useRef(null); + useEffect(() => { + if (active && ref.current) { + ref.current?.play(); + } else ref.current?.stop(); + }, [active]); + return ( + + ); +}; + export type TabParamList = { OverviewStack: NavigatorScreenParams; NetworkStack: NavigatorScreenParams; diff --git a/apps/mobile/src/screens/Locations.tsx b/apps/mobile/src/screens/Locations.tsx index 1f1a2c977..08ecd371f 100644 --- a/apps/mobile/src/screens/Locations.tsx +++ b/apps/mobile/src/screens/Locations.tsx @@ -92,7 +92,7 @@ const LocationItem: React.FC = ({ return ( @@ -105,7 +105,7 @@ const LocationItem: React.FC = ({ /> {location.name} @@ -114,7 +114,7 @@ const LocationItem: React.FC = ({ {`${byteSize(location.size_in_bytes)}`} diff --git a/apps/mobile/src/screens/p2p/index.tsx b/apps/mobile/src/screens/p2p/index.tsx index 150df0835..b2ab37689 100644 --- a/apps/mobile/src/screens/p2p/index.tsx +++ b/apps/mobile/src/screens/p2p/index.tsx @@ -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; } diff --git a/apps/mobile/src/screens/settings/Settings.tsx b/apps/mobile/src/screens/settings/Settings.tsx index c7ff17038..f74fb55fc 100644 --- a/apps/mobile/src/screens/settings/Settings.tsx +++ b/apps/mobile/src/screens/settings/Settings.tsx @@ -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 ( @@ -132,16 +140,16 @@ export default function SettingsScreen({ navigation }: SettingsStackScreenProps< const debugState = useDebugState(); return ( - + ( navigation.navigate(item.navigateTo as any)} + rounded={item.rounded} /> )} renderSectionHeader={renderSectionHeader} diff --git a/apps/mobile/src/screens/settings/info/Debug.tsx b/apps/mobile/src/screens/settings/info/Debug.tsx index 376e8b63b..83a5f5973 100644 --- a/apps/mobile/src/screens/settings/info/Debug.tsx +++ b/apps/mobile/src/screens/settings/info/Debug.tsx @@ -14,9 +14,6 @@ const DebugScreen = ({ navigation }: SettingsStackScreenProps<'Debug'>) => { Debug - diff --git a/apps/mobile/src/screens/settings/library/NodesSettings.tsx b/apps/mobile/src/screens/settings/library/NodesSettings.tsx index 15563bb96..dfc07b0e2 100644 --- a/apps/mobile/src/screens/settings/library/NodesSettings.tsx +++ b/apps/mobile/src/screens/settings/library/NodesSettings.tsx @@ -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 ( @@ -20,25 +15,6 @@ const NodesSettingsScreen = ({ navigation }: SettingsStackScreenProps<'NodesSett {[...onlineNodes.entries()].map(([id, node]) => ( {node.name} - - ))} diff --git a/apps/mobile/tests/add-tag.yml b/apps/mobile/tests/add-tag.yml index 537564a3e..0e7edfcfc 100644 --- a/apps/mobile/tests/add-tag.yml +++ b/apps/mobile/tests/add-tag.yml @@ -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' diff --git a/apps/mobile/tests/onboarding.yml b/apps/mobile/tests/onboarding.yml index d0141c6d4..257e4e842 100644 --- a/apps/mobile/tests/onboarding.yml +++ b/apps/mobile/tests/onboarding.yml @@ -10,6 +10,3 @@ appId: com.spacedrive.app - tapOn: id: 'share-minimal' - tapOn: 'Continue' -- tapOn: - id: 'browse-tab' -- assertVisible: 'TestLib' diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0f9176b1d..14ceddc46 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -112,6 +112,7 @@ function App() { 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 = { diff --git a/core/src/api/cloud.rs b/core/src/api/cloud.rs index bd1f19ecb..851a99e9b 100644 --- a/core/src/api/cloud.rs +++ b/core/src/api/cloud.rs @@ -28,9 +28,15 @@ pub(crate) fn mount() -> AlphaRouter { .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 { } mod library { + use crate::{node::Platform, util::MaybeUndefined}; + use super::*; pub fn mount() -> AlphaRouter { @@ -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(()) + }) + }) } } diff --git a/core/src/api/labels.rs b/core/src/api/labels.rs index 6b2817b62..caf4d104c 100644 --- a/core/src/api/labels.rs +++ b/core/src/api/labels.rs @@ -24,7 +24,6 @@ pub(crate) fn mount() -> AlphaRouter { Ok(library.db.label().find_many(vec![]).exec().await?) }) }) - // .procedure("listWithThumbnails", { R.with2(library()) .query(|(_, library), cursor: label::name::Type| async move { diff --git a/core/src/api/libraries.rs b/core/src/api/libraries.rs index f255ce292..f98f73b53 100644 --- a/core/src/api/libraries.rs +++ b/core/src/api/libraries.rs @@ -336,7 +336,10 @@ pub(crate) fn mount() -> AlphaRouter { name, description, }: EditLibraryArgs| async move { - Ok(node.libraries.edit(id, name, description).await?) + Ok(node + .libraries + .edit(id, name, description, MaybeUndefined::Undefined) + .await?) }, ) }) diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 5c71d4aec..11aa8f2cf 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -393,7 +393,7 @@ pub(crate) fn mount() -> AlphaRouter { debug!("Disconnected {count} file paths from objects"); - library.orphan_remover.invoke().await; + // library.orphan_remover.invoke().await; } // rescan location diff --git a/core/src/api/models.rs b/core/src/api/models.rs index 3671d529d..ba065fbad 100644 --- a/core/src/api/models.rs +++ b/core/src/api/models.rs @@ -9,7 +9,7 @@ pub(crate) fn mount() -> AlphaRouter { #[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")] diff --git a/core/src/api/p2p.rs b/core/src/api/p2p.rs index 8a9fba17d..ad994fc3e 100644 --- a/core/src/api/p2p.rs +++ b/core/src/api/p2p.rs @@ -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 { }) }) }) - // 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 { 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(()) }) }) diff --git a/core/src/cloud/sync/ingest.rs b/core/src/cloud/sync/ingest.rs index ab59d2377..5e96b274e 100644 --- a/core/src/cloud/sync/ingest.rs +++ b/core/src/cloud/sync/ingest.rs @@ -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, Arc)) { - let Library { sync, .. } = library.as_ref(); - +pub async fn run_actor(sync: Arc, notify: Arc) { loop { { let mut rx = sync.ingest.req_rx.lock().await; @@ -21,11 +17,11 @@ pub async fn run_actor((library, notify): (Arc, Arc)) { .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, Arc)) { }; 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, Arc)) { 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, })) diff --git a/core/src/cloud/sync/mod.rs b/core/src/cloud/sync/mod.rs index dac42b6c3..9cf45d3b3 100644 --- a/core/src/cloud/sync/mod.rs +++ b/core/src/cloud/sync/mod.rs @@ -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, node: &Arc) { let ingest_notify = Arc::new(Notify::new()); @@ -16,25 +17,58 @@ pub async fn declare_actors(library: &Arc, node: &Arc) { 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)>; diff --git a/core/src/cloud/sync/receive.rs b/core/src/cloud/sync/receive.rs index 68c5f2dd6..99263b056 100644 --- a/core/src/cloud/sync/receive.rs +++ b/core/src/cloud/sync/receive.rs @@ -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, Arc, Arc)) { - 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::>(); - - 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::>() - }; - - info!( - "Fetched timestamps for {} local instances", - cloud_timestamps.len() - ); - +pub async fn run_actor( + library: Arc, + libraries: Arc, + db: Arc, + library_id: Uuid, + instance_uuid: Uuid, + sync: Arc, + cloud_api_config_provider: Arc, + ingest_notify: Arc, +) { 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::>() + ) + .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::>() + }; + + 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::>(), + 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> = None; for collection in collections { @@ -108,8 +112,8 @@ pub async fn run_actor((library, node, ingest_notify): (Arc, Arc, 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, Arc, 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, Arc, &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, + libraries: &Libraries, uuid: Uuid, - identity: Vec, + 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(()) } diff --git a/core/src/cloud/sync/send.rs b/core/src/cloud/sync/send.rs index c542f20cd..e47c65fcb 100644 --- a/core/src/cloud/sync/send.rs +++ b/core/src/cloud/sync/send.rs @@ -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, Arc)) { - let db = &library.db; - let library_id = library.id; +use super::err_break; +pub async fn run_actor( + db: Arc, + library_id: Uuid, + sync: Arc, + cloud_api_config_provider: Arc, +) { loop { loop { let instances = err_break!( @@ -29,7 +33,7 @@ pub async fn run_actor((library, node): (Arc, Arc)) { 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, Arc)) { 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, Arc)) { 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 { diff --git a/core/src/custom_uri/mod.rs b/core/src/custom_uri/mod.rs index ee8e01783..481980af3 100644 --- a/core/src/custom_uri/mod.rs +++ b/core/src/custom_uri/mod.rs @@ -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; diff --git a/core/src/lib.rs b/core/src/lib.rs index 552a61ef8..912ade1e8 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -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) -> sd_cloud_api::RequestConfig { + Node::cloud_api_config(self).await + } +} + /// Error type for Node related errors. #[derive(Error, Debug)] pub enum NodeError { diff --git a/core/src/library/config.rs b/core/src/library/config.rs index 43cf6c0b1..1441b94aa 100644 --- a/core/src/library/config.rs +++ b/core/src/library/config.rs @@ -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, /// 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, version: LibraryConfigVersion, } @@ -83,6 +85,7 @@ impl LibraryConfig { description, instance_id, version: Self::LATEST_VERSION, + cloud_id: None, }; this.save(path).await.map(|()| this) diff --git a/core/src/library/library.rs b/core/src/library/library.rs index 6bf8d2c3d..c474aee2a 100644 --- a/core/src/library/library.rs +++ b/core/src/library/library.rs @@ -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, /// p2p identity pub identity: Arc, - 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, // 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, - pub actors: Arc, + pub actors: Arc, } impl Debug for Library { @@ -78,6 +75,7 @@ impl Library { db: Arc, node: &Arc, sync: Arc, + do_cloud_sync: broadcast::Sender<()>, ) -> Arc { 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:?}"); + } + } } diff --git a/core/src/library/manager/error.rs b/core/src/library/manager/error.rs index f6be136d1..3d00c5990 100644 --- a/core/src/library/manager/error.rs +++ b/core/src/library/manager/error.rs @@ -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}, diff --git a/core/src/library/manager/mod.rs b/core/src/library/manager/mod.rs index 37d4713e8..0fad2fa57 100644 --- a/core/src/library/manager/mod.rs +++ b/core/src/library/manager/mod.rs @@ -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, description: MaybeUndefined, + cloud_id: MaybeUndefined, ) -> 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::>(), ) @@ -463,10 +472,10 @@ impl Libraries { sd_sync::NTP64(op.map(|o| o.timestamp).unwrap_or_default() as u64), ) }) - .collect::>(); - - 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) } diff --git a/core/src/library/mod.rs b/core/src/library/mod.rs index 944f40410..49057177f 100644 --- a/core/src/library/mod.rs +++ b/core/src/library/mod.rs @@ -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::*; diff --git a/core/src/location/indexer/indexer_job.rs b/core/src/location/indexer/indexer_job.rs index b04de044f..5ba06656b 100644 --- a/core/src/location/indexer/indexer_job.rs +++ b/core/src/location/indexer/indexer_job.rs @@ -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 diff --git a/core/src/location/indexer/shallow.rs b/core/src/location/indexer/shallow.rs index d7cc3dfcc..8b29d6657 100644 --- a/core/src/location/indexer/shallow.rs +++ b/core/src/location/indexer/shallow.rs @@ -190,7 +190,7 @@ pub async fn shallow( invalidate_query!(library, "search.objects"); } - library.orphan_remover.invoke().await; + // library.orphan_remover.invoke().await; Ok(()) } diff --git a/core/src/location/mod.rs b/core/src/location/mod.rs index b47d074a3..c11bb6006 100644 --- a/core/src/location/mod.rs +++ b/core/src/location/mod.rs @@ -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"); diff --git a/core/src/node/config.rs b/core/src/node/config.rs index 6d43caeb2..379b90ab4 100644 --- a/core/src/node/config.rs +++ b/core/src/node/config.rs @@ -49,6 +49,9 @@ pub struct NodeConfig { pub features: Vec, /// Authentication for Spacedrive Accounts pub auth_token: Option, + /// URL of the Spacedrive API + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sd_api_origin: Option, /// The aggreagation of many different preferences for the node pub preferences: NodePreferences, // Model version for the image labeler @@ -102,6 +105,7 @@ impl ManagedVersion for NodeConfig { features: vec![], notifications: vec![], auth_token: None, + sd_api_origin: None, preferences: NodePreferences::default(), image_labeler_version, }) diff --git a/core/src/node/platform.rs b/core/src/node/platform.rs index 0835237ed..b1566900e 100644 --- a/core/src/node/platform.rs +++ b/core/src/node/platform.rs @@ -54,3 +54,9 @@ impl TryFrom for Platform { Ok(s) } } + +impl From for u8 { + fn from(platform: Platform) -> Self { + platform as u8 + } +} diff --git a/core/src/object/fs/delete.rs b/core/src/object/fs/delete.rs index 9bafa93fd..23ba73ed2 100644 --- a/core/src/object/fs/delete.rs +++ b/core/src/object/fs/delete.rs @@ -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 }))) } diff --git a/core/src/p2p/identity_or_remote_identity.rs b/core/src/p2p/identity_or_remote_identity.rs deleted file mode 100644 index 5007fe588..000000000 --- a/core/src/p2p/identity_or_remote_identity.rs +++ /dev/null @@ -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 { - 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 { - match self { - Self::Identity(identity) => [&[b'I'], &*identity.to_bytes()].concat(), - Self::RemoteIdentity(identity) => [[b'R'].as_slice(), &identity.get_bytes()].concat(), - } - } -} diff --git a/core/src/p2p/libraries.rs b/core/src/p2p/libraries.rs index d560e3d2e..f71e086c8 100644 --- a/core/src/p2p/libraries.rs +++ b/core/src/p2p/libraries.rs @@ -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>>>, @@ -44,36 +44,35 @@ impl LibraryServices { } } - pub(crate) async fn start(_manager: Arc, _libraries: Arc) { - 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, libraries: Arc) { + 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>> { @@ -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); diff --git a/core/src/p2p/mod.rs b/core/src/p2p/mod.rs index 375840699..b1ad6eec3 100644 --- a/core/src/p2p/mod.rs +++ b/core/src/p2p/mod.rs @@ -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::*; diff --git a/core/src/p2p/p2p_events.rs b/core/src/p2p/p2p_events.rs index f9bf18510..bbbed8da0 100644 --- a/core/src/p2p/p2p_events.rs +++ b/core/src/p2p/p2p_events.rs @@ -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 } diff --git a/core/src/p2p/p2p_manager.rs b/core/src/p2p/p2p_manager.rs index 48eb41000..bac0b2ee4 100644 --- a/core/src/p2p/p2p_manager.rs +++ b/core/src/p2p/p2p_manager.rs @@ -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, @@ -30,7 +28,6 @@ pub struct P2PManager { pub manager: Arc, pub(super) spacedrop_pairing_reqs: Arc>>>>, pub(super) spacedrop_cancelations: Arc>>>, - pub pairing: Arc, node_config_manager: Arc, } @@ -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(), diff --git a/core/src/p2p/p2p_manager_actor.rs b/core/src/p2p/p2p_manager_actor.rs index 3fc20b254..b7bea6ee3 100644 --- a/core/src/p2p/p2p_manager_actor.rs +++ b/core/src/p2p/p2p_manager_actor.rs @@ -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| { diff --git a/core/src/p2p/pairing/mod.rs b/core/src/p2p/pairing/mod.rs deleted file mode 100644 index 2a6c0f506..000000000 --- a/core/src/p2p/pairing/mod.rs +++ /dev/null @@ -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, - pairing_response: RwLock>>, - manager: Arc, -} - -impl PairingManager { - pub fn new(manager: Arc, events_tx: broadcast::Sender) -> Arc { - 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, identity: RemoteIdentity, node: Arc) -> 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, - identity: RemoteIdentity, - mut stream: impl AsyncRead + AsyncWrite + Unpin, - library_manager: &Libraries, - node: Arc, - ) -> 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, - }, - InitialSyncProgress(u8), - PairingComplete(Uuid), - PairingRejected, -} - -// TODO: Unit tests diff --git a/core/src/p2p/pairing/proto.rs b/core/src/p2p/pairing/proto.rs deleted file mode 100644 index 821eccf9d..000000000 --- a/core/src/p2p/pairing/proto.rs +++ /dev/null @@ -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, - pub date_created: DateTime, -} - -/// 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, - - // 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, - }, - // 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 { - 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::::from_str( - &decode::string(stream).await.map_err(|e| ("last_seen", e))?, - ) - .unwrap(), // TODO: Error handling - date_created: DateTime::::from_str( - &decode::string(stream) - .await - .map_err(|e| ("date_created", e))?, - ) - .unwrap(), // TODO: Error handling - }) - } - - pub fn to_bytes(&self) -> Vec { - 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 { - Ok(Self(Instance::from_stream(stream).await?)) - } - - pub fn to_bytes(&self) -> Vec { - let Self(instance) = self; - Instance::to_bytes(instance) - } -} - -impl PairingResponse { - pub async fn from_stream( - stream: &mut (impl AsyncRead + Unpin), - ) -> Result { - // 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 { - 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 { - // 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 { - 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); - } - } -} diff --git a/core/src/p2p/protocol.rs b/core/src/p2p/protocol.rs index 2d8a46f72..492542968 100644 --- a/core/src/p2p/protocol.rs +++ b/core/src/p2p/protocol.rs @@ -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); diff --git a/crates/actors/Cargo.toml b/crates/actors/Cargo.toml new file mode 100644 index 000000000..bdcf2401e --- /dev/null +++ b/crates/actors/Cargo.toml @@ -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 diff --git a/core/src/library/actors.rs b/crates/actors/src/lib.rs similarity index 100% rename from core/src/library/actors.rs rename to crates/actors/src/lib.rs diff --git a/crates/cloud-api/Cargo.toml b/crates/cloud-api/Cargo.toml index 164a4d4d7..76378cf60 100644 --- a/crates/cloud-api/Cargo.toml +++ b/crates/cloud-api/Cargo.toml @@ -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 diff --git a/crates/cloud-api/src/lib.rs b/crates/cloud-api/src/lib.rs index eae0f61c5..eac509bd7 100644 --- a/crates/cloud-api/src/lib.rs +++ b/crates/cloud-api/src/lib.rs @@ -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, } +pub trait RequestConfigProvider { + fn get_request_config(self: &Arc) -> impl Future + Send; +} + #[derive(thiserror::Error, Debug)] #[error("{0}")] pub struct Error(String); @@ -26,6 +33,7 @@ impl From 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, @@ -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 { 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, + ) -> 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, + node_name: Option, + node_platform: Option, + ) -> 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, 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())) } } diff --git a/crates/p2p/Cargo.toml b/crates/p2p/Cargo.toml index c5281bf08..884d8ee0f 100644 --- a/crates/p2p/Cargo.toml +++ b/crates/p2p/Cargo.toml @@ -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"] } diff --git a/crates/p2p/src/discovery/mdns.rs b/crates/p2p/src/discovery/mdns.rs index d4c71db79..2e7c4508d 100644 --- a/crates/p2p/src/discovery/mdns.rs +++ b/crates/p2p/src/discovery/mdns.rs @@ -35,6 +35,14 @@ pub struct Mdns { next_mdns_advertisement: Pin>, // This is an ugly workaround for: https://github.com/keepsimple1/mdns-sd/issues/145 mdns_rx: StreamUnordered, + // 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, +} + +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}"); + } + } } } diff --git a/crates/p2p/src/manager_stream.rs b/crates/p2p/src/manager_stream.rs index 1fa7ac16b..37ad1a9d3 100644 --- a/crates/p2p/src/manager_stream.rs +++ b/crates/p2p/src/manager_stream.rs @@ -299,6 +299,7 @@ impl ManagerStream { } SwarmEvent::ListenerError { listener_id, error } => warn!("listener '{:?}' reported a non-fatal error: {}", listener_id, error), SwarmEvent::Dialing { .. } => {}, + _ => {} } } } diff --git a/crates/p2p/src/spacetime/behaviour.rs b/crates/p2p/src/spacetime/behaviour.rs index 4678d4067..ffa636454 100644 --- a/crates/p2p/src/spacetime/behaviour.rs +++ b/crates/p2p/src/spacetime/behaviour.rs @@ -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) { + 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>> { + fn poll(&mut self, _: &mut Context<'_>) -> Poll>> { if let Some(ev) = self.pending_events.pop_front() { return Poll::Ready(ev); } else if self.pending_events.capacity() > EMPTY_QUEUE_SHRINK_THRESHOLD { diff --git a/crates/p2p/src/spacetime/connection.rs b/crates/p2p/src/spacetime/connection.rs index 6f35a2e50..7afa0861c 100644 --- a/crates/p2p/src/spacetime/connection.rs +++ b/crates/p2p/src/spacetime/connection.rs @@ -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, ::OutboundOpenInfo, ::ToBehaviour, - StreamUpgradeError, + // StreamUpgradeError, >, >, } @@ -51,7 +49,6 @@ impl SpaceTimeConnection { impl ConnectionHandler for SpaceTimeConnection { type FromBehaviour = OutboundRequest; type ToBehaviour = ManagerStreamAction2; - type Error = StreamUpgradeError; 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, - >, + ConnectionHandlerEvent, > { 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(_) => {} + _ => {} } } } diff --git a/crates/p2p/src/spacetunnel/identity.rs b/crates/p2p/src/spacetunnel/identity.rs index f35401b57..400830b14 100644 --- a/crates/p2p/src/spacetunnel/identity.rs +++ b/crates/p2p/src/spacetunnel/identity.rs @@ -168,3 +168,48 @@ impl From 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 { + 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 { + match self { + Self::Identity(identity) => [&[b'I'], &*identity.to_bytes()].concat(), + Self::RemoteIdentity(identity) => [[b'R'].as_slice(), &identity.get_bytes()].concat(), + } + } +} diff --git a/interface/RoutingContext.tsx b/interface/RoutingContext.tsx index 83c113a4f..54547ef58 100644 --- a/interface/RoutingContext.tsx +++ b/interface/RoutingContext.tsx @@ -6,6 +6,7 @@ import { createRoutes } from './app'; export const RoutingContext = createContext<{ visible: boolean; currentIndex: number; + tabId: string; maxIndex: number; routes: ReturnType; } | null>(null); diff --git a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx index 051c7aa36..898b7e7b7 100644 --- a/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/Thumb.tsx @@ -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((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((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(() => { const thumbType = 'thumbnail'; @@ -94,7 +91,7 @@ export const FileThumb = forwardRef((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' : diff --git a/interface/app/$libraryId/Explorer/FilePath/useFrame.tsx b/interface/app/$libraryId/Explorer/FilePath/useFrame.tsx new file mode 100644 index 000000000..df410979d --- /dev/null +++ b/interface/app/$libraryId/Explorer/FilePath/useFrame.tsx @@ -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 }; +}; diff --git a/interface/app/$libraryId/Explorer/View/Grid/index.tsx b/interface/app/$libraryId/Explorer/View/Grid/index.tsx index 5b7bf976d..ec71be2af 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/index.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/index.tsx @@ -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] ); diff --git a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx index ba76c4e21..d14dbfb7d 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx @@ -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 = () => { <>
@@ -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 ( { selected={item.selected} /> + {item.data.type === "Label" && } ); }; @@ -138,3 +147,24 @@ const ItemSize = () => {
); }; + +function LabelItemCount({data}: {data: Extract}) { + const { t } = useLocale(); + + const count = useLibraryQuery(["search.objectsCount", { + filters: [{ + object: { + labels: { + in: [data.item.id] + } + } + }] + }]) + + if(count.data === undefined) return + + return
+ {t("item_with_count", {count: count.data})} +
+ +} diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx index 52ab6baff..4a86e8afa 100644 --- a/interface/app/$libraryId/Explorer/View/index.tsx +++ b/interface/app/$libraryId/Explorer/View/index.tsx @@ -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(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) { diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Debug/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Debug/index.tsx index 83804b9e5..80437b187 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Debug/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Debug/index.tsx @@ -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() { Actors + + + P2P + ); diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Devices/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Devices/index.tsx index 73ecc162b..e85c9b7a3 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Devices/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Devices/index.tsx @@ -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 ( -
- - - ) - } - > +
{node && ( diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Library/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Library/index.tsx index b7af4ec91..de94d475b 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Library/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Library/index.tsx @@ -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 (
- Overview + {t('overview')} - Recents + {t('recents')} {/*
34
*/}
- Favorites + {t('favorites')} {/*
2
*/}
- Labels + {t('labels')}
{labelCount.data || 0}
diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx index 5e014fe0e..a8f0b7071 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Local/index.tsx @@ -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 ( -
+
- Network + {t('network')} {homeDir.data && ( @@ -87,7 +90,7 @@ export default function LocalSection() { path={homeDir.data ?? ''} > - Home + {t('home')} )} diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx index 39fa4c837..a99c7756d 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Locations/index.tsx @@ -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 (
diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/SavedSearches/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/SavedSearches/index.tsx index 3a0aa9076..3a1c3e3a8 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/SavedSearches/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/SavedSearches/index.tsx @@ -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 (
// diff --git a/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx b/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx index 96553fad3..06f390f54 100644 --- a/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/sections/Tags/index.tsx @@ -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 (
diff --git a/interface/app/$libraryId/TopBar/index.tsx b/interface/app/$libraryId/TopBar/index.tsx index d8b0b96a4..e1edeb976 100644 --- a/interface/app/$libraryId/TopBar/index.tsx +++ b/interface/app/$libraryId/TopBar/index.tsx @@ -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 (
{ + 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)); }); } diff --git a/interface/app/$libraryId/debug/cloud.tsx b/interface/app/$libraryId/debug/cloud.tsx index 66b2010c2..23bbde82e 100644 --- a/interface/app/$libraryId/debug/cloud.tsx +++ b/interface/app/$libraryId/debug/cloud.tsx @@ -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() {

Library

Name: {cloudLibrary.data.name}

+ + + {thisInstance && (

This Instance

diff --git a/interface/app/$libraryId/debug/index.ts b/interface/app/$libraryId/debug/index.ts index 1eaf21984..37c7c39e5 100644 --- a/interface/app/$libraryId/debug/index.ts +++ b/interface/app/$libraryId/debug/index.ts @@ -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[]; diff --git a/interface/app/$libraryId/debug/p2p.tsx b/interface/app/$libraryId/debug/p2p.tsx new file mode 100644 index 000000000..ccd8a7ff0 --- /dev/null +++ b/interface/app/$libraryId/debug/p2p.tsx @@ -0,0 +1,59 @@ +import { useBridgeQuery, useCache, useConnectedPeers, useNodes } from '@sd/client'; + +export const Component = () => { + const node = useBridgeQuery(['nodeState']); + + return ( +
+ {node.data?.p2p_enabled === false ? ( +

P2P is disabled. Please enable it in settings!

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

Connected to:

+ {connectedPeers.size === 0 &&

None

} + {[...connectedPeers.entries()].map(([id, node]) => ( +
+

{id}

+
+ ))} +
+ +
+

Current nodes libraries:

+ {libraries.map((v) => ( +
+

+ {v.config.name} - {v.uuid} +

+
+

Instance: {`${v.config.instance_id}/${v.instance_id}`}

+

Instance PK: {`${v.instance_public_key}`}

+
+
+ ))} +
+ +
+

NLM State:

+
{JSON.stringify(p2pState.data || {}, undefined, 2)}
+
+
+ ); +} diff --git a/interface/app/$libraryId/labels.tsx b/interface/app/$libraryId/labels.tsx index 27f7d659e..a834ca1ed 100644 --- a/interface/app/$libraryId/labels.tsx +++ b/interface/app/$libraryId/labels.tsx @@ -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 ( diff --git a/interface/app/$libraryId/overview/NewCard.tsx b/interface/app/$libraryId/overview/NewCard.tsx index 45e73d6e5..fd708490e 100644 --- a/interface/app/$libraryId/overview/NewCard.tsx +++ b/interface/app/$libraryId/overview/NewCard.tsx @@ -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 ( -
+
{
))}
- {/* */}
{text} - + {button ? ( + button() + ) : ( + + )}
); -}; - -export default NewCard; +} diff --git a/interface/app/$libraryId/overview/index.tsx b/interface/app/$libraryId/overview/index.tsx index f479274fd..631189843 100644 --- a/interface/app/$libraryId/overview/index.tsx +++ b/interface/app/$libraryId/overview/index.tsx @@ -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 = () => { - Library Overview + + {t('library_overview')} +
} - center={} + center={} // right={ // { {/**/} - + {locations?.map((item) => ( - + + + ))} {!locations?.length && ( } /> )} diff --git a/interface/app/$libraryId/recents.tsx b/interface/app/$libraryId/recents.tsx index 758f3f75b..0d89127ac 100644 --- a/interface/app/$libraryId/recents.tsx +++ b/interface/app/$libraryId/recents.tsx @@ -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={} left={
- Recents + Recents
} right={} diff --git a/interface/app/$libraryId/search/SearchBar.tsx b/interface/app/$libraryId/search/SearchBar.tsx index c4f7bae57..286340f5b 100644 --- a/interface/app/$libraryId/search/SearchBar.tsx +++ b/interface/app/$libraryId/search/SearchBar.tsx @@ -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(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 () => { 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={ -
+
{ } - message="No recent items" + icon={} + message="No items found" /> } /> diff --git a/interface/app/$libraryId/settings/client/general.tsx b/interface/app/$libraryId/settings/client/general.tsx index 54e7c1861..9fa11e554 100644 --- a/interface/app/$libraryId/settings/client/general.tsx +++ b/interface/app/$libraryId/settings/client/general.tsx @@ -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 */}
@@ -161,7 +173,7 @@ export const Component = () => { } }} > - Open + {t('open')} {/*
*/}
- + {/* Language Settings */} + +
+ +
+
+ {/* Debug Mode */} { onClick={() => (debugState.enabled = !debugState.enabled)} /> + {/* Background Processing */} { />
+ {/* Image Labeler */} -
+
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') } ] }, diff --git a/interface/app/$libraryId/settings/library/locations/$id.tsx b/interface/app/$libraryId/settings/library/locations/$id.tsx index 19177a889..fe34aee30 100644 --- a/interface/app/$libraryId/settings/library/locations/$id.tsx +++ b/interface/app/$libraryId/settings/library/locations/$id.tsx @@ -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()), diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx index e454df9e2..1cc350211 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx @@ -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['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 ( <> -
- ))} -
-
-

{t('connected')}

- {[...connectedNodes.entries()].map(([id, node]) => ( -
-

{id}

-
- ))} -
-
-
-

NLM State:

-
{JSON.stringify(nlmState.data || {}, undefined, 2)}
-
-
-

Libraries:

- {libraries?.map((v) => ( -
-

- {v.config.name} - {v.uuid} -

-
-

Instance: {`${v.config.instance_id}/${v.instance_id}`}

-

Instance PK: {`${v.instance_public_key}`}

-
-
- ))} -
- - ); -} diff --git a/interface/app/$libraryId/settings/node/libraries/index.tsx b/interface/app/$libraryId/settings/node/libraries/index.tsx index 72c184672..013000968 100644 --- a/interface/app/$libraryId/settings/node/libraries/index.tsx +++ b/interface/app/$libraryId/settings/node/libraries/index.tsx @@ -1,6 +1,6 @@ -import { t } from 'i18next'; import { useBridgeQuery, useCache, useLibraryContext, useNodes } from '@sd/client'; import { Button, dialogManager } from '@sd/ui'; +import { useLocale } from '~/hooks'; import { Heading } from '../../Layout'; import CreateDialog from './CreateDialog'; @@ -13,11 +13,13 @@ export const Component = () => { const { library } = useLibraryContext(); + const { t } = useLocale(); + return ( <> } diff --git a/interface/app/I18n.ts b/interface/app/I18n.ts index 5dd1839a2..99c9bcb39 100644 --- a/interface/app/I18n.ts +++ b/interface/app/I18n.ts @@ -1,12 +1,12 @@ import i18n from 'i18next'; -// import LanguageDetector from 'i18next-browser-languagedetector'; +import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; import * as resources from 'virtual:i18next-loader'; i18n - // // detect user language - // // learn more: https://github.com/i18next/i18next-browser-languageDetector - // .use(LanguageDetector) + // detect user language + // learn more: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) // pass the i18n instance to react-i18next. .use(initReactI18next) // init i18next diff --git a/interface/app/index.tsx b/interface/app/index.tsx index 4fc40a6d1..9af1bdb6e 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -40,7 +40,7 @@ export const createRoutes = (platform: Platform, cache: NormalisedCache) => {useFeatureFlag('solidJsDemo') ? : null} - + ); }, diff --git a/interface/app/onboarding/context.tsx b/interface/app/onboarding/context.tsx index 3a4fbd103..393e568bb 100644 --- a/interface/app/onboarding/context.tsx +++ b/interface/app/onboarding/context.tsx @@ -18,6 +18,8 @@ import { import { RadioGroupField, z } from '@sd/ui'; import { usePlatform } from '~/util/Platform'; +import i18n from '../I18n'; + export const OnboardingContext = createContext | null>(null); // Hook for generating the value to put into `OnboardingContext.Provider`, @@ -41,13 +43,12 @@ export const shareTelemetry = RadioGroupField.options([ z.literal('minimal-telemetry') ]).details({ 'share-telemetry': { - heading: 'Share anonymous usage', - description: - 'Share completely anonymous telemetry data to help the developers improve the app' + heading: i18n.t('share_anonymous_usage'), + description: i18n.t('share_anonymous_usage_description') }, 'minimal-telemetry': { - heading: 'Share the bare minimum', - description: 'Only share that I am an active user of Spacedrive and a few technical bits' + heading: i18n.t('share_bare_minimum'), + description: i18n.t('share_bare_minimum_description') } }); diff --git a/interface/app/onboarding/new-library.tsx b/interface/app/onboarding/new-library.tsx index 66247b4cb..6242a2d1e 100644 --- a/interface/app/onboarding/new-library.tsx +++ b/interface/app/onboarding/new-library.tsx @@ -40,7 +40,7 @@ export default function OnboardingNewLibrary() { - OR + {t('or')} diff --git a/interface/app/p2p/index.tsx b/interface/app/p2p/index.tsx index aa9c2d56b..aa7189e59 100644 --- a/interface/app/p2p/index.tsx +++ b/interface/app/p2p/index.tsx @@ -2,23 +2,6 @@ import { useEffect, useState } from 'react'; import { useBridgeQuery, useFeatureFlag, useP2PEvents, withFeatureFlag } from '@sd/client'; import { toast } from '@sd/ui'; -import { startPairing } from './pairing'; - -// Entrypoint of P2P UI -export function P2P() { - const pairingEnabled = useFeatureFlag('p2pPairing'); - useP2PEvents((data) => { - if (data.type === 'PairingRequest' && pairingEnabled) { - startPairing(data.id, { - name: data.name, - os: data.os - }); - } - }); - - return null; -} - export function useP2PErrorToast() { const nodeState = useBridgeQuery(['nodeState']); const [didShowError, setDidShowError] = useState({ diff --git a/interface/app/p2p/pairing.tsx b/interface/app/p2p/pairing.tsx deleted file mode 100644 index aef026eb6..000000000 --- a/interface/app/p2p/pairing.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useState } from 'react'; -import { match, P } from 'ts-pattern'; -import { - OperatingSystem, - useBridgeMutation, - useCachedLibraries, - usePairingStatus, - useZodForm -} from '@sd/client'; -import { - Button, - Dialog, - dialogManager, - Loader, - Select, - SelectOption, - useDialog, - UseDialogProps, - z -} from '@sd/ui'; -import { useLocale } from '~/hooks'; - -type Node = { - name: string; - os: OperatingSystem | null; -}; - -export function startPairing(pairing_id: number, node: Node) { - dialogManager.create((dp) => ); -} - -function OriginatorDialog({ - pairingId, - node, - ...props -}: { pairingId: number; node: Node } & UseDialogProps) { - const pairingStatus = usePairingStatus(pairingId); - - const { t } = useLocale(); - - // TODO: If dialog closes before finished, cancel pairing - - return ( - { - // TODO: Change into the new library - }} - // onCancelled={() => acceptSpacedrop.mutate([props.dropId, null])} - > -
- {match(pairingStatus) - .with({ type: 'EstablishingConnection' }, () => ( - - )) - .with({ type: 'PairingRequested' }, () => ( - - )) - .with({ type: 'LibraryAlreadyExists' }, () => ( - - )) - .with({ type: 'PairingDecisionRequest' }, () => ( - - )) - .with({ type: 'PairingInProgress', data: P.select() }, (data) => ( - - )) - .with({ type: 'InitialSyncProgress', data: P.select() }, (data) => ( - - )) - .with({ type: 'PairingComplete' }, () => ) - .with({ type: 'PairingRejected' }, () => ) - .with(undefined, () => <>) - .exhaustive()} -
-
- ); -} - -function PairingResponder({ pairingId }: { pairingId: number }) { - const libraries = useCachedLibraries(); - const [selectedLibrary, setSelectedLibrary] = useState( - libraries.data?.[0]?.uuid - ); - const pairingResponse = useBridgeMutation('p2p.pairingResponse'); - - const { t } = useLocale(); - - return ( - <> - {selectedLibrary ? ( - - ) : ( -

No libraries. Uh oh!

- )} -
- - -
- - ); -} - -function PairingLoading({ msg }: { msg?: string }) { - return ( -
- - {msg &&

{msg}

} -
- ); -} - -function CompletePairing() { - return ( -
-

Pairing Complete!

-
- ); -} - -function PairingRejected() { - return ( -
-

Pairing Rejected By Remote!

-
- ); -} diff --git a/interface/hooks/useRouteTitle.ts b/interface/hooks/useRouteTitle.ts index f975755cf..4c1b2a5ea 100644 --- a/interface/hooks/useRouteTitle.ts +++ b/interface/hooks/useRouteTitle.ts @@ -1,17 +1,19 @@ import { createContext, useContext, useLayoutEffect } from 'react'; +import { useRoutingContext } from '~/RoutingContext'; export function useRouteTitle(title: string) { + const routingCtx = useRoutingContext(); const ctx = useContext(RouteTitleContext); // layout effect avoids 'New Tab' showing up briefly useLayoutEffect(() => { document.title = title; - if (ctx) ctx.setTitle(title); - }, [title, ctx]); + if (ctx) ctx.setTitle(routingCtx.tabId, title); + }, [routingCtx.tabId, title, ctx]); return title; } export const RouteTitleContext = createContext<{ - setTitle: (title: string) => void; + setTitle: (id: string, title: string) => void; } | null>(null); diff --git a/interface/hooks/useShortcut.ts b/interface/hooks/useShortcut.ts index 4d1e28ab5..e64f31861 100644 --- a/interface/hooks/useShortcut.ts +++ b/interface/hooks/useShortcut.ts @@ -20,6 +20,63 @@ export type ShortcutCategory = { }; export const shortcutCategories = { + General: { + description: 'General usage shortcuts', + shortcuts: { + newTab: { + action: 'Open new tab', + keys: { + macOS: ['Meta', 'KeyT'], + all: ['Control', 'KeyT'] + }, + icons: { + macOS: [modifierSymbols.Meta.macOS as string, 'T'], + all: [modifierSymbols.Control.Other, 'T'] + } + }, + closeTab: { + action: 'Close current tab', + keys: { + macOS: ['Meta', 'KeyW'], + all: ['Control', 'KeyW'] + }, + icons: { + macOS: [modifierSymbols.Meta.macOS as string, 'W'], + all: [modifierSymbols.Control.Other, 'W'] + } + }, + nextTab: { + action: 'Switch to next tab', + keys: { + macOS: ['Meta', 'Alt', 'ArrowRight'], + all: ['Control', 'Alt', 'ArrowRight'] + }, + icons: { + macOS: [ + modifierSymbols.Meta.macOS as string, + modifierSymbols.Alt.macOS as string, + 'ArrowRight' + ], + all: [modifierSymbols.Control.Other, modifierSymbols.Alt.Other, 'ArrowRight'] + } + }, + previousTab: { + action: 'Switch to previous tab', + keys: { + macOS: ['Meta', 'Alt', 'ArrowLeft'], + all: ['Control', 'Alt', 'ArrowLeft'] + }, + icons: { + macOS: [ + modifierSymbols.Meta.macOS as string, + modifierSymbols.Alt.macOS as string, + 'ArrowLeft' + ], + all: [modifierSymbols.Control.Other, modifierSymbols.Alt.Other, 'ArrowLeft'] + } + } + } + }, Dialogs: { description: 'To perform actions and operations', shortcuts: { @@ -382,9 +439,8 @@ export const useShortcut = (shortcut: ShortcutName, func: (e: KeyboardEvent) => if (!visible) return []; const category = Object.values(categories).find((category) => - Object.hasOwn(category.shortcuts, shortcut) + Object.prototype.hasOwnProperty.call(category.shortcuts, shortcut) ) as ShortcutCategory | undefined; - const categoryShortcut = category?.shortcuts[shortcut]; return categoryShortcut?.keys[os] ?? categoryShortcut?.keys.all ?? []; diff --git a/interface/index.tsx b/interface/index.tsx index 19c329021..fc92a356d 100644 --- a/interface/index.tsx +++ b/interface/index.tsx @@ -19,7 +19,7 @@ import { toast, TooltipProvider } from '@sd/ui'; import { createRoutes } from './app'; import { SpacedropProvider } from './app/$libraryId/Spacedrop'; import i18n from './app/I18n'; -import { P2P, useP2PErrorToast } from './app/p2p'; +import { useP2PErrorToast } from './app/p2p'; import { Devtools } from './components/Devtools'; import { WithPrismTheme } from './components/TextViewer/prism'; import ErrorFallback, { BetterErrorBoundary } from './ErrorFallback'; @@ -53,6 +53,7 @@ export function SpacedriveRouterProvider(props: { visible: boolean; router: Router; currentIndex: number; + tabId: string; maxIndex: number; }; }) { @@ -63,6 +64,7 @@ export function SpacedriveRouterProvider(props: { routes: props.routing.routes, visible: props.routing.visible, currentIndex: props.routing.currentIndex, + tabId: props.routing.tabId, maxIndex: props.routing.maxIndex }} > @@ -96,7 +98,6 @@ export function SpacedriveInterfaceRoot({ children }: PropsWithChildren) { - diff --git a/interface/locales/de/common.json b/interface/locales/de/common.json index b02657a07..935b3b6a3 100644 --- a/interface/locales/de/common.json +++ b/interface/locales/de/common.json @@ -1,5 +1,4 @@ { - "meet_title": "Treffen Sie {{title}}", "about": "Über", "about_vision_text": "Viele von uns haben mehrere Cloud-Konten, Laufwerke, die nicht gesichert sind, und Daten, die von Verlust bedroht sind. Wir verlassen uns auf Cloud-Dienste wie Google Fotos und iCloud, sind aber mit begrenzter Kapazität eingesperrt und haben fast keine Interoperabilität zwischen Diensten und Betriebssystemen. Fotoalben sollten nicht in einem Geräte-Ökosystem feststecken oder für Werbedaten geerntet werden. Sie sollten betriebssystemunabhängig, dauerhaft und persönlich besessen sein. Daten, die wir erstellen, sind unser Erbe, das uns lange überleben wird - Open-Source-Technologie ist der einzige Weg, um sicherzustellen, dass wir absolute Kontrolle über die Daten behalten, die unser Leben definieren, in unbegrenztem Maßstab.", "about_vision_title": "Vision", @@ -91,6 +90,7 @@ "description": "Beschreibung", "deselect": "Abwählen", "details": "Details", + "devices": "Geräte", "devices_coming_soon_tooltip": "Demnächst verfügbar! Diese Alpha-Version beinhaltet noch keinen Bibliothekssync, dieser wird aber sehr bald bereit sein.", "direction": "Richtung", "disabled": "Deaktiviert", @@ -142,6 +142,7 @@ "failed_to_resume_job": "Aufgabe konnte nicht fortgesetzt werden.", "failed_to_update_location_settings": "Standorteinstellungen konnten nicht aktualisiert werden", "favorite": "Favorit", + "favorites": "Favoriten", "file_indexing_rules": "Dateiindizierungsregeln", "filters": "Filter", "forward": "Vorwärts", @@ -165,6 +166,7 @@ "hide_in_sidebar": "In der Seitenleiste verstecken", "hide_in_sidebar_description": "Verhindern, dass dieser Tag in der Seitenleiste der App angezeigt wird.", "hide_location_from_view": "Standort und Inhalte aus der Ansicht ausblenden", + "home": "Startseite", "image_labeler_ai_model": "KI-Modell zur Bildetikettierung", "image_labeler_ai_model_description": "Das Modell, das zur Erkennung von Objekten in Bildern verwendet wird. Größere Modelle sind genauer, aber langsamer.", "import": "Importieren", @@ -176,6 +178,8 @@ "install_update": "Update installieren", "installed": "Installiert", "item_size": "Elementgröße", + "item_with_count_one": "{{count}} artikel", + "item_with_count_other": "{{count}} artikel", "job_has_been_canceled": "Der Job wurde abgebrochen.", "job_has_been_paused": "Der Job wurde pausiert.", "job_has_been_removed": "Der Job wurde entfernt.", @@ -190,16 +194,21 @@ "keybinds_description": "Client-Tastenkombinationen anzeigen und verwalten", "keys": "Schlüssel", "kilometers": "Kilometer", + "labels": "Labels", + "language": "Sprache", + "language_description": "Ändern Sie die Sprache der Spacedrive-Benutzeroberfläche", "learn_more_about_telemetry": "Mehr über Telemetrie erfahren", "libraries": "Bibliotheken", "libraries_description": "Die Datenbank enthält alle Bibliotheksdaten und Dateimetadaten.", "library": "Bibliothek", "library_name": "Bibliotheksname", + "library_overview": "Bibliotheksübersicht", "library_settings": "Bibliothekseinstellungen", "library_settings_description": "Allgemeine Einstellungen in Bezug auf die aktuell aktive Bibliothek.", "list_view": "Listenansicht", "list_view_notice_description": "Navigieren Sie einfach durch Ihre Dateien und Ordner mit der Listenansicht. Diese Ansicht zeigt Ihre Dateien in einem einfachen, organisierten Listenformat an, sodass Sie schnell die benötigten Dateien finden und darauf zugreifen können.", "loading": "Laden", + "local": "Lokal", "local_locations": "Lokale Standorte", "local_node": "Lokaler Knoten", "location_display_name_info": "Der Name dieses Standorts, so wird er in der Seitenleiste angezeigt. Wird den eigentlichen Ordner auf der Festplatte nicht umbenennen.", @@ -221,6 +230,7 @@ "media_view_context": "Medienansichtskontext", "media_view_notice_description": "Entdecken Sie Fotos und Videos leicht, die Medienansicht zeigt Resultate beginnend am aktuellen Standort einschließlich Unterordnern.", "meet_contributors_behind_spacedrive": "Treffen Sie die Mitwirkenden hinter Spacedrive", + "meet_title": "Treffen Sie {{title}}", "miles": "Meilen", "mode": "Modus", "modified": "Geändert", @@ -231,6 +241,7 @@ "name": "Name", "navigate_back": "Zurück navigieren", "navigate_forward": "Vorwärts navigieren", + "network": "Netzwerk", "network_page_description": "Andere Spacedrive-Knoten in Ihrem LAN werden hier angezeigt, zusammen mit Ihren standardmäßigen Betriebssystem-Netzwerklaufwerken.", "networking": "Netzwerk", "networking_port": "Netzwerk-Port", @@ -261,6 +272,7 @@ "open_settings": "Einstellungen öffnen", "open_with": "Öffnen mit", "or": "ODER", + "overview": "Übersicht", "pair": "Verbinden", "pairing_with_node": "Koppeln mit {{node}}", "paste": "Einfügen", @@ -275,6 +287,7 @@ "quick_preview": "Schnellvorschau", "quick_view": "Schnellansicht", "recent_jobs": "Aktuelle Aufgaben", + "recents": "Zuletzt verwendet", "regen_labels": "Labels erneuern", "regen_thumbnails": "Vorschaubilder erneuern", "regenerate_thumbs": "Vorschaubilder neu generieren", @@ -296,6 +309,7 @@ "running": "Läuft", "save": "Speichern", "save_changes": "Änderungen speichern", + "saved_searches": "Gespeicherte Suchen", "search_extensions": "Erweiterungen suchen", "secure_delete": "Sicheres Löschen", "security": "Sicherheit", @@ -304,6 +318,10 @@ "settings": "Einstellungen", "setup": "Einrichten", "share": "Teilen", + "share_anonymous_usage": "Anonyme Nutzung teilen", + "share_anonymous_usage_description": "Teilen Sie völlig anonyme Telemetriedaten, um den Entwicklern bei der Verbesserung der App zu helfen", + "share_bare_minimum": "Mindestinformationen teilen", + "share_bare_minimum_description": "Nur teilen, dass ich ein aktiver Benutzer von Spacedrive bin und einige technische Details", "sharing": "Teilen", "sharing_description": "Verwalten Sie, wer Zugriff auf Ihre Bibliotheken hat.", "show_details": "Details anzeigen", @@ -336,6 +354,7 @@ "type": "Typ", "ui_animations": "UI-Animationen", "ui_animations_description": "Dialoge und andere UI-Elemente werden animiert, wenn sie geöffnet und geschlossen werden.", + "unnamed_location": "Unbenannter Standort", "usage": "Verwendung", "usage_description": "Ihre Bibliotheksnutzung und Hardwareinformationen", "value": "Wert", diff --git a/interface/locales/en/common.json b/interface/locales/en/common.json index 508909f90..45ce4ed2f 100644 --- a/interface/locales/en/common.json +++ b/interface/locales/en/common.json @@ -1,5 +1,4 @@ { - "meet_title": "Meet {{title}}", "about": "About", "about_vision_text": "Many of us have multiple cloud accounts, drives that aren’t backed up and data at risk of loss. We depend on cloud services like Google Photos and iCloud, but are locked in with limited capacity and almost zero interoperability between services and operating systems. Photo albums shouldn’t be stuck in a device ecosystem, or harvested for advertising data. They should be OS agnostic, permanent and personally owned. Data we create is our legacy, that will long outlive us—open source technology is the only way to ensure we retain absolute control over the data that defines our lives, at unlimited scale.", "about_vision_title": "Vision", @@ -91,6 +90,7 @@ "description": "Description", "deselect": "Deselect", "details": "Details", + "devices": "Devices", "devices_coming_soon_tooltip": "Coming soon! This alpha release doesn't include library sync, it will be ready very soon.", "direction": "Direction", "disabled": "Disabled", @@ -165,6 +165,7 @@ "hide_in_sidebar": "Hide in sidebar", "hide_in_sidebar_description": "Prevent this tag from showing in the sidebar of the app.", "hide_location_from_view": "Hide location and contents from view", + "home": "Home", "image_labeler_ai_model": "Image label recognition AI model", "image_labeler_ai_model_description": "The model used to recognize objects in images. Larger models are more accurate but slower.", "import": "Import", @@ -176,6 +177,8 @@ "install_update": "Install Update", "installed": "Installed", "item_size": "Item size", + "item_with_count_one": "{{count}} item", + "item_with_count_other": "{{count}} items", "job_has_been_canceled": "Job has been canceled.", "job_has_been_paused": "Job has been paused.", "job_has_been_removed": "Job has been removed.", @@ -195,11 +198,13 @@ "libraries_description": "The database contains all library data and file metadata.", "library": "Library", "library_name": "Library name", + "library_overview": "Library Overview", "library_settings": "Library Settings", "library_settings_description": "General settings related to the currently active library.", "list_view": "List View", "list_view_notice_description": "Easily navigate through your files and folders with List View. This view displays your files in a simple, organized list format, allowing you to quickly locate and access the files you need.", "loading": "Loading", + "local": "Local", "local_locations": "Local Locations", "local_node": "Local Node", "location_display_name_info": "The name of this Location, this is what will be displayed in the sidebar. Will not rename the actual folder on disk.", @@ -221,6 +226,7 @@ "media_view_context": "Media View Context", "media_view_notice_description": "Discover photos and videos easily, Media View will show results starting at the current location including sub directories.", "meet_contributors_behind_spacedrive": "Meet the contributors behind Spacedrive", + "meet_title": "Meet {{title}}", "miles": "Miles", "mode": "Mode", "modified": "Modified", @@ -231,6 +237,7 @@ "name": "Name", "navigate_back": "Navigate back", "navigate_forward": "Navigate forward", + "network": "Network", "network_page_description": "Other Spacedrive nodes on your LAN will appear here, along with your default OS network mounts.", "networking": "Networking", "networking_port": "Networking Port", @@ -261,6 +268,7 @@ "open_settings": "Open Settings", "open_with": "Open with", "or": "OR", + "overview": "Overview", "pair": "Pair", "pairing_with_node": "Pairing with {{node}}", "paste": "Paste", @@ -296,6 +304,7 @@ "running": "Running", "save": "Save", "save_changes": "Save Changes", + "saved_searches": "Saved Searches", "search_extensions": "Search extensions", "secure_delete": "Secure delete", "security": "Security", @@ -304,6 +313,10 @@ "settings": "Settings", "setup": "Set up", "share": "Share", + "share_anonymous_usage": "Share anonymous usage", + "share_anonymous_usage_description": "Share completely anonymous telemetry data to help the developers improve the app", + "share_bare_minimum": "Share the bare minimum", + "share_bare_minimum_description": "Only share that I am an active user of Spacedrive and a few technical bits", "sharing": "Sharing", "sharing_description": "Manage who has access to your libraries.", "show_details": "Show details", @@ -336,14 +349,20 @@ "type": "Type", "ui_animations": "UI Animations", "ui_animations_description": "Dialogs and other UI elements will animate when opening and closing.", + "unnamed_location": "Unnamed Location", "usage": "Usage", "usage_description": "Your library usage and hardware information", "value": "Value", "video_preview_not_supported": "Video preview is not supported.", "want_to_do_this_later": "Want to do this later?", "website": "Website", - "your_account": "Your account\"", + "your_account": "Your account", "your_account_description": "Spacedrive account and information.", "your_local_network": "Your Local Network", - "your_privacy": "Your Privacy" + "your_privacy": "Your Privacy", + "recents": "Recents", + "favorites": "Favorites", + "labels": "Labels", + "language": "Language", + "language_description": "Change the language of the Spacedrive interface" } diff --git a/interface/locales/es/common.json b/interface/locales/es/common.json index e240bb4b4..18eaec669 100644 --- a/interface/locales/es/common.json +++ b/interface/locales/es/common.json @@ -1,7 +1,6 @@ { - "meet_title": "Conoce a {{title}}", "about": "Acerca de", - "about_vision_text": "Muchos de nosotros tenemos varias cuentas en la nube, discos que no tienen copias de seguridad y datos en riesgo de pérdida. Dependemos de servicios en la nube como Google Photos e iCloud, pero estamos limitados con una capacidad reducida y casi cero interoperabilidad entre servicios y sistemas operativos. Los álbumes de fotos no deberían estar atascados en un ecosistema de dispositivos o ser utilizados para datos publicitarios. Deberían ser independientes del SO, permanentes y de propiedad personal. Los datos que creamos son nuestro legado, que nos sobrevivirá mucho tiempo: la tecnología de código abierto es la única forma de asegurarnos de mantener el control absoluto sobre los datos que definen nuestras vidas, a una escala ilimitada.", + "about_vision_text": "Muchos de nosotros tenemos múltiples cuentas en la nube, discos que no tienen copias de seguridad y datos en riesgo de ser perdidos. Dependemos de servicios en la nube como Google Photos e iCloud, pero estamos limitados con una capacidad reducida y casi cero interoperabilidad entre servicios y sistemas operativos. Los álbumes de fotos no deberían estar atascados en un ecosistema de dispositivos o ser utilizados para datos publicitarios. Deberían ser independientes del SO, permanentes y de propiedad personal. Los datos que creamos son nuestro legado, que nos sobrevivirá mucho tiempo: la tecnología de código abierto es la única forma de asegurarnos de mantener el control absoluto sobre los datos que definen nuestras vidas, en una escala ilimitada.", "about_vision_title": "Visión", "accept": "Aceptar", "accessed": "Accedido", @@ -16,7 +15,7 @@ "add_tag": "Agregar Etiqueta", "advanced_settings": "Configuración avanzada", "all_jobs_have_been_cleared": "Todos los trabajos han sido eliminados.", - "alpha_release_description": "Estamos encantados de que pruebes Spacedrive, ahora en lanzamiento Alpha, mostrando nuevas funciones emocionantes. Como con cualquier lanzamiento inicial, esta versión puede contener algunos errores. Amablemente solicitamos tu ayuda para informar cualquier problema que encuentres en nuestro canal de Discord. Tu valiosa retroalimentación contribuirá en gran medida a mejorar la experiencia del usuario.", + "alpha_release_description": "Estamos encantados de que pruebes Spacedrive, ahora en lanzamiento Alpha, mostrando nuevas funcionalidades emocionantes. Como con cualquier lanzamiento inicial, esta versión puede contener algunos errores. Amablemente solicitamos tu ayuda para informar cualquier problema que encuentres en nuestro canal de Discord. Tu valiosa retroalimentación contribuirá en gran medida a mejorar la experiencia de usuario.", "alpha_release_title": "Lanzamiento Alpha", "appearance": "Apariencia", "appearance_description": "Cambia la apariencia de tu cliente.", @@ -25,7 +24,7 @@ "archive_info": "Extrae datos de la Biblioteca como un archivo, útil para preservar la estructura de carpetas de la Ubicación.", "are_you_sure": "¿Estás seguro?", "assign_tag": "Asignar etiqueta", - "audio_preview_not_supported": "La vista previa de audio no está soportada.", + "audio_preview_not_supported": "La previsualización de audio no está soportada.", "back": "Atrás", "backups": "Copias de seguridad", "backups_description": "Administra tus copias de seguridad de la base de datos de Spacedrive.", @@ -91,6 +90,7 @@ "description": "Descripción", "deselect": "Deseleccionar", "details": "Detalles", + "devices": "Dispositivos", "devices_coming_soon_tooltip": "¡Próximamente! Esta versión alfa no incluye la sincronización de bibliotecas, estará lista muy pronto.", "direction": "Dirección", "disabled": "Deshabilitado", @@ -142,6 +142,7 @@ "failed_to_resume_job": "Error al reanudar el trabajo.", "failed_to_update_location_settings": "Error al actualizar configuraciones de ubicación", "favorite": "Favorito", + "favorites": "Favoritos", "file_indexing_rules": "Reglas de indexación de archivos", "filters": "Filtros", "forward": "Adelante", @@ -158,13 +159,14 @@ "got_it": "Entendido", "grid_gap": "Espaciado", "grid_view": "Vista de Cuadrícula", - "grid_view_notice_description": "Obtén una visión general visual de tus archivos con la Vista de Cuadrícula. Esta vista muestra tus archivos y carpetas como imágenes en miniatura, facilitando la identificación rápida del archivo que buscas.", + "grid_view_notice_description": "Obtén una visión general de tus archivos con la Vista de Cuadrícula. Esta vista muestra tus archivos y carpetas como imágenes en miniatura, facilitando la identificación rápida del archivo que buscas.", "hidden_label": "Evita que la ubicación y su contenido aparezcan en categorías resumen, búsqueda y etiquetas a menos que \"Mostrar elementos ocultos\" esté habilitado.", "hide_in_library_search": "Ocultar en la búsqueda de la Biblioteca", "hide_in_library_search_description": "Oculta archivos con esta etiqueta de los resultados al buscar en toda la biblioteca.", "hide_in_sidebar": "Ocultar en la barra lateral", "hide_in_sidebar_description": "Prevenir que esta etiqueta se muestre en la barra lateral de la aplicación.", "hide_location_from_view": "Ocultar ubicación y contenido de la vista", + "home": "Inicio", "image_labeler_ai_model": "Modelo AI de reconocimiento de etiquetas de imagen", "image_labeler_ai_model_description": "El modelo utilizado para reconocer objetos en imágenes. Los modelos más grandes son más precisos pero más lentos.", "import": "Importar", @@ -176,6 +178,8 @@ "install_update": "Instalar Actualización", "installed": "Instalado", "item_size": "Tamaño de elemento", + "item_with_count_one": "{{count}} artículo", + "item_with_count_other": "{{count}} artículos", "job_has_been_canceled": "El trabajo ha sido cancelado.", "job_has_been_paused": "El trabajo ha sido pausado.", "job_has_been_removed": "El trabajo ha sido eliminado.", @@ -190,16 +194,21 @@ "keybinds_description": "Ver y administrar atajos de teclado del cliente", "keys": "Claves", "kilometers": "Kilómetros", + "labels": "Etiquetas", + "language": "Idioma", + "language_description": "Cambiar el idioma de la interfaz de Spacedrive", "learn_more_about_telemetry": "Aprende más sobre la telemetría", "libraries": "Bibliotecas", "libraries_description": "La base de datos contiene todos los datos de la biblioteca y metadatos de archivos.", "library": "Biblioteca", "library_name": "Nombre de la Biblioteca", + "library_overview": "Resumen de la Biblioteca", "library_settings": "Configuraciones de la Biblioteca", "library_settings_description": "Configuraciones generales relacionadas con la biblioteca activa actualmente.", "list_view": "Vista de Lista", "list_view_notice_description": "Navega fácilmente a través de tus archivos y carpetas con la Vista de Lista. Esta vista muestra tus archivos en un formato de lista simple y organizado, permitiéndote localizar y acceder rápidamente a los archivos que necesitas.", "loading": "Cargando", + "local": "Local", "local_locations": "Ubicaciones Locales", "local_node": "Nodo Local", "location_display_name_info": "El nombre de esta Ubicación, esto es lo que se mostrará en la barra lateral. No renombrará la carpeta real en el disco.", @@ -221,6 +230,7 @@ "media_view_context": "Contexto de Vista de Medios", "media_view_notice_description": "Descubre fotos y videos fácilmente, la Vista de Medios mostrará resultados comenzando en la ubicación actual incluyendo subdirectorios.", "meet_contributors_behind_spacedrive": "Conoce a los colaboradores detrás de Spacedrive", + "meet_title": "Conoce: {{title}}", "miles": "Millas", "mode": "Modo", "modified": "Modificado", @@ -231,6 +241,7 @@ "name": "Nombre", "navigate_back": "Navegar hacia atrás", "navigate_forward": "Navegar hacia adelante", + "network": "Red", "network_page_description": "Otros nodos de Spacedrive en tu LAN aparecerán aquí, junto con tus montajes de red del sistema operativo por defecto.", "networking": "Redes", "networking_port": "Puerto de Redes", @@ -261,6 +272,7 @@ "open_settings": "Abrir Configuraciones", "open_with": "Abrir con", "or": "O", + "overview": "Resumen", "pair": "Emparejar", "pairing_with_node": "Emparejando con {{node}}", "paste": "Pegar", @@ -275,6 +287,7 @@ "quick_preview": "Vista rápida", "quick_view": "Vista rápida", "recent_jobs": "Trabajos recientes", + "recents": "Recientes", "regen_labels": "Regenerar Etiquetas", "regen_thumbnails": "Regenerar Miniaturas", "regenerate_thumbs": "Regenerar Miniaturas", @@ -296,6 +309,7 @@ "running": "Ejecutando", "save": "Guardar", "save_changes": "Guardar Cambios", + "saved_searches": "Búsquedas Guardadas", "search_extensions": "Buscar extensiones", "secure_delete": "Borrado seguro", "security": "Seguridad", @@ -304,6 +318,10 @@ "settings": "Configuraciones", "setup": "Configurar", "share": "Compartir", + "share_anonymous_usage": "Compartir uso anónimo", + "share_anonymous_usage_description": "Compartir datos de telemetría completamente anónimos para ayudar a los desarrolladores a mejorar la aplicación", + "share_bare_minimum": "Compartir lo mínimo indispensable", + "share_bare_minimum_description": "Solo compartir que soy un usuario activo de Spacedrive y algunos detalles técnicos", "sharing": "Compartiendo", "sharing_description": "Administra quién tiene acceso a tus bibliotecas.", "show_details": "Mostrar detalles", @@ -332,10 +350,11 @@ "temperature": "Temperatura", "thumbnailer_cpu_usage": "Uso de CPU del generador de miniaturas", "thumbnailer_cpu_usage_description": "Limita cuánto CPU puede usar el generador de miniaturas para el procesamiento en segundo plano.", - "toggle_all": "Alternar todos", + "toggle_all": "Seleccionar todo", "type": "Tipo", "ui_animations": "Animaciones de la UI", "ui_animations_description": "Los diálogos y otros elementos de la UI se animarán al abrirse y cerrarse.", + "unnamed_location": "Ubicación sin nombre", "usage": "Uso", "usage_description": "Tu uso de la biblioteca e información del hardware", "value": "Valor", diff --git a/interface/locales/fr/common.json b/interface/locales/fr/common.json index 2dbb429d4..cf0eb86a5 100644 --- a/interface/locales/fr/common.json +++ b/interface/locales/fr/common.json @@ -1,5 +1,4 @@ { - "meet_title": "Rencontrer {{title}}", "about": "À propos", "about_vision_text": "Beaucoup d'entre nous ont plusieurs comptes cloud, des disques qui ne sont pas sauvegardés et des données à risque de perte. Nous dépendons de services cloud comme Google Photos et iCloud, mais sommes enfermés avec une capacité limitée et presque zéro interopérabilité entre les services et les systèmes d'exploitation. Les albums photo ne devraient pas être coincés dans un écosystème d'appareils, ou récoltés pour des données publicitaires. Ils devraient être indépendants du système d'exploitation, permanents et personnels. Les données que nous créons sont notre héritage, qui nous survivra longtemps - la technologie open source est le seul moyen d'assurer que nous conservons un contrôle absolu sur les données qui définissent nos vies, à une échelle illimitée.", "about_vision_title": "Vision", @@ -91,6 +90,7 @@ "description": "Description", "deselect": "Désélectionner", "details": "Détails", + "devices": "Appareils", "devices_coming_soon_tooltip": "Bientôt disponible ! Cette version alpha n'inclut pas la synchronisation des bibliothèques, cela sera prêt très prochainement.", "direction": "Direction", "disabled": "Désactivé", @@ -141,6 +141,7 @@ "failed_to_resume_job": "Échec de la reprise du travail.", "failed_to_update_location_settings": "Échec de la mise à jour des paramètres de l'emplacement", "favorite": "Favori", + "favorites": "Favoris", "file_indexing_rules": "Règles d'indexation des fichiers", "filters": "Filtres", "forward": "Avancer", @@ -164,6 +165,7 @@ "hide_in_sidebar": "Masquer dans la barre latérale", "hide_in_sidebar_description": "Empêcher cette étiquette de s'afficher dans la barre latérale de l'application.", "hide_location_from_view": "Masquer l'emplacement et le contenu de la vue", + "home": "Accueil", "image_labeler_ai_model": "Modèle d'IA de reconnaissance d'étiquettes d'image", "image_labeler_ai_model_description": "Le modèle utilisé pour reconnaître les objets dans les images. Les modèles plus grands sont plus précis mais plus lents.", "import": "Importer", @@ -175,6 +177,8 @@ "install_update": "Installer la mise à jour", "installed": "Installé", "item_size": "Taille de l'élément", + "item_with_count_one": "{{count}} article", + "item_with_count_other": "{{count}} articles", "job_has_been_canceled": "Le travail a été annulé.", "job_has_been_paused": "Le travail a été mis en pause.", "job_has_been_removed": "Le travail a été supprimé.", @@ -189,16 +193,21 @@ "keybinds_description": "Afficher et gérer les raccourcis clavier du client", "keys": "Clés", "kilometers": "Kilomètres", + "labels": "Étiquettes", + "language": "Langue", + "language_description": "Changer la langue de l'interface Spacedrive", "learn_more_about_telemetry": "En savoir plus sur la télémesure", "libraries": "Bibliothèques", "libraries_description": "La base de données contient toutes les données de la bibliothèque et les métadonnées des fichiers.", "library": "Bibliothèque", "library_name": "Nom de la bibliothèque", + "library_overview": "Aperçu de la bibliothèque", "library_settings": "Paramètres de la bibliothèque", "library_settings_description": "Paramètres généraux liés à la bibliothèque actuellement active.", "list_view": "Vue en liste", "list_view_notice_description": "Naviguez facilement à travers vos fichiers et dossiers avec la vue en liste. Cette vue affiche vos fichiers dans un format de liste simple et organisé, vous permettant de localiser et d'accéder rapidement aux fichiers dont vous avez besoin.", "loading": "Chargement", + "local": "Local", "local_locations": "Emplacements locaux", "local_node": "Nœud local", "location_display_name_info": "Le nom de cet emplacement, c'est ce qui sera affiché dans la barre latérale. Ne renommera pas le dossier réel sur le disque.", @@ -220,6 +229,7 @@ "media_view_context": "Contexte de vue média", "media_view_notice_description": "Découvrez facilement les photos et vidéos, la vue média affichera les résultats en commençant par l'emplacement actuel, y compris les sous-répertoires.", "meet_contributors_behind_spacedrive": "Rencontrez les contributeurs derrière Spacedrive", + "meet_title": "Rencontrer {{title}}", "miles": "Miles", "mode": "Mode", "modified": "Modifié", @@ -230,6 +240,7 @@ "name": "Nom", "navigate_back": "Naviguer en arrière", "navigate_forward": "Naviguer en avant", + "network": "Réseau", "network_page_description": "Les autres nœuds Spacedrive de votre LAN apparaîtront ici, ainsi que vos montages réseau par défaut du système d'exploitation.", "networking": "Réseautage", "networking_port": "Port réseau", @@ -260,6 +271,7 @@ "open_settings": "Ouvrir les paramètres", "open_with": "Ouvrir avec", "or": "OU", + "overview": "Aperçu", "pair": "Associer", "pairing_with_node": "Appairage avec {{node}}", "paste": "Coller", @@ -274,6 +286,7 @@ "quick_preview": "Aperçu rapide", "quick_view": "Vue rapide", "recent_jobs": "Travaux récents", + "recents": "Récents", "regen_labels": "Régénérer les étiquettes", "regen_thumbnails": "Régénérer les vignettes", "regenerate_thumbs": "Régénérer les miniatures", @@ -295,6 +308,7 @@ "running": "En cours", "save": "Sauvegarder", "save_changes": "Sauvegarder les modifications", + "saved_searches": "Recherches enregistrées", "search_extensions": "Rechercher des extensions", "secure_delete": "Suppression sécurisée", "security": "Sécurité", @@ -303,6 +317,10 @@ "settings": "Paramètres", "setup": "Configuration", "share": "Partager", + "share_anonymous_usage": "Partager l'utilisation anonyme", + "share_anonymous_usage_description": "Partager des données de télémétrie complètement anonymes pour aider les développeurs à améliorer l'application", + "share_bare_minimum": "Partager le strict minimum", + "share_bare_minimum_description": "Partager uniquement que je suis un utilisateur actif de Spacedrive et quelques détails techniques", "sharing": "Partage", "sharing_description": "Gérer qui a accès à vos bibliothèques.", "show_details": "Afficher les détails", @@ -335,6 +353,7 @@ "type": "Type", "ui_animations": "Animations de l'interface", "ui_animations_description": "Les dialogues et autres éléments d'interface animeront lors de l'ouverture et de la fermeture.", + "unnamed_location": "Emplacement sans nom", "usage": "Utilisation", "usage_description": "Votre utilisation de la bibliothèque et les informations matérielles", "value": "Valeur", diff --git a/interface/locales/tr/common.json b/interface/locales/tr/common.json index 66117a481..ab11ea411 100644 --- a/interface/locales/tr/common.json +++ b/interface/locales/tr/common.json @@ -1,5 +1,4 @@ { - "meet_title": "{{title}} ile Tanışın", "about": "Hakkında", "about_vision_text": "Birçoğumuzun birden fazla bulut hesabı, yedeklenmemiş sürücüleri ve kaybolma riski taşıyan verileri var. Google Fotoğraflar ve iCloud gibi bulut hizmetlerine bağımlıyız, ancak sınırlı kapasiteyle ve hizmetler ile işletim sistemleri arasında neredeyse sıfır geçiş yapabilirlikle kısıtlanmış durumdayız. Fotoğraf albümleri bir cihaz ekosisteminde sıkışıp kalmamalı veya reklam verileri için kullanılmamalıdır. OS bağımsız, kalıcı ve kişisel olarak sahip olunmalıdır. Oluşturduğumuz veriler, bizden uzun süre yaşayacak mirasımızdır - verilerimiz üzerinde mutlak kontrol sağlamak için açık kaynak teknolojisi tek yoludur, sınırsız ölçekte.", "about_vision_title": "Vizyon", @@ -91,6 +90,7 @@ "description": "Açıklama", "deselect": "Seçimi Kaldır", "details": "Detaylar", + "devices": "Cihazlar", "devices_coming_soon_tooltip": "Yakında geliyor! Bu alfa sürümü kütüphane senkronizasyonunu içermiyor, çok yakında hazır olacak.", "direction": "Yön", "disabled": "Devre Dışı", @@ -142,6 +142,7 @@ "failed_to_resume_job": "İş devam ettirilemedi.", "failed_to_update_location_settings": "Konum ayarları güncellenemedi", "favorite": "Favori", + "favorites": "Favoriler", "file_indexing_rules": "Dosya İndeksleme Kuralları", "filters": "Filtreler", "forward": "İleri", @@ -165,6 +166,7 @@ "hide_in_sidebar": "Kenar çubuğunda gizle", "hide_in_sidebar_description": "Bu etiketin uygulamanın kenar çubuğunda gösterilmesini engelle.", "hide_location_from_view": "Konumu ve içeriğini görünümden gizle", + "home": "Ev", "image_labeler_ai_model": "Resim etiket tanıma AI modeli", "image_labeler_ai_model_description": "Resimlerdeki nesneleri tanımak için kullanılan model. Daha büyük modeller daha doğru ancak daha yavaştır.", "import": "İçe Aktar", @@ -176,6 +178,8 @@ "install_update": "Güncellemeyi Yükle", "installed": "Yüklendi", "item_size": "Öğe Boyutu", + "item_with_count_one": "{{count}} madde", + "item_with_count_other": "{{count}} maddeler", "job_has_been_canceled": "İş iptal edildi.", "job_has_been_paused": "İş duraklatıldı.", "job_has_been_removed": "İş kaldırıldı.", @@ -190,16 +194,21 @@ "keybinds_description": "İstemci tuş bağlamalarını görüntüleyin ve yönetin", "keys": "Anahtarlar", "kilometers": "Kilometreler", + "labels": "Etiketler", + "language": "Dil", + "language_description": "Spacedrive arayüzünün dilini değiştirin", "learn_more_about_telemetry": "Telemetri hakkında daha fazla bilgi edinin", "libraries": "Kütüphaneler", "libraries_description": "Veritabanı tüm kütüphane verilerini ve dosya metaverilerini içerir.", "library": "Kütüphane", "library_name": "Kütüphane adı", + "library_overview": "Kütüphane Genel Bakışı", "library_settings": "Kütüphane Ayarları", "library_settings_description": "Şu anda aktif olan kütüphane ile ilgili genel ayarlar.", "list_view": "Liste Görünümü", "list_view_notice_description": "Dosyalarınızın ve klasörlerinizin arasında kolayca gezinmek için Liste Görünümünü kullanın. Bu görünüm dosyalarınızı basit, düzenli bir liste formatında gösterir, ihtiyacınız olan dosyalara hızla ulaşıp onlara erişmenizi sağlar.", "loading": "Yükleniyor", + "local": "Yerel", "local_locations": "Yerel Konumlar", "local_node": "Yerel Düğüm", "location_display_name_info": "Bu Konumun adı, kenar çubuğunda gösterilecek olan budur. Diskteki gerçek klasörü yeniden adlandırmaz.", @@ -221,6 +230,7 @@ "media_view_context": "Medya Görünümü Bağlamı", "media_view_notice_description": "Fotoğrafları ve videoları kolayca keşfedin, Medya Görünümü alt dizinler dahil olmak üzere mevcut konumdan itibaren sonuçları gösterecektir.", "meet_contributors_behind_spacedrive": "Spacedrive'ın arkasındaki katkıda bulunanlarla tanışın", + "meet_title": "{{title}} ile Tanışın", "miles": "Mil", "mode": "Mod", "modified": "Değiştirildi", @@ -231,6 +241,7 @@ "name": "Ad", "navigate_back": "Geri git", "navigate_forward": "İleri git", + "network": "Ağ", "network_page_description": "Yerel Ağınızda diğer Spacedrive düğümleri burada görünecek, varsayılan işletim sistemi ağ bağlantıları ile birlikte.", "networking": "Ağ", "networking_port": "Ağ Portu", @@ -261,6 +272,7 @@ "open_settings": "Ayarları Aç", "open_with": "İle aç", "or": "VEYA", + "overview": "Genel Bakış", "pair": "Eşle", "pairing_with_node": "{{node}} ile eşleşiyor", "paste": "Yapıştır", @@ -275,6 +287,7 @@ "quick_preview": "Hızlı Önizleme", "quick_view": "Hızlı bakış", "recent_jobs": "Son İşler", + "recents": "Son Kullanılanlar", "regen_labels": "Etiketleri Yeniden Oluştur", "regen_thumbnails": "Küçük Resimleri Yeniden Oluştur", "regenerate_thumbs": "Küçük Resimleri Yeniden Oluştur", @@ -296,6 +309,7 @@ "running": "Çalışıyor", "save": "Kaydet", "save_changes": "Değişiklikleri Kaydet", + "saved_searches": "Kaydedilen Aramalar", "search_extensions": "Arama uzantıları", "secure_delete": "Güvenli sil", "security": "Güvenlik", @@ -304,6 +318,10 @@ "settings": "Ayarlar", "setup": "Kurulum", "share": "Paylaş", + "share_anonymous_usage": "Anonim kullanımı paylaş", + "share_anonymous_usage_description": "Geliştiricilere uygulamayı iyileştirmelerine yardımcı olmak için tamamen anonim telemetri verilerini paylaşın", + "share_bare_minimum": "Sadece en azını paylaş", + "share_bare_minimum_description": "Yalnızca Spacedrive'ın aktif bir kullanıcısı olduğumu ve birkaç teknik ayrıntıyı paylaşın", "sharing": "Paylaşım", "sharing_description": "Kütüphanelerinize kimlerin erişim sağlayabileceğini yönetin.", "show_details": "Detayları Göster", @@ -336,6 +354,7 @@ "type": "Tip", "ui_animations": "UI Animasyonları", "ui_animations_description": "Diyaloglar ve diğer UI elementleri açılırken ve kapanırken animasyon gösterecek.", + "unnamed_location": "İsimsiz Konum", "usage": "Kullanım", "usage_description": "Kütüphanenizi kullanımı ve donanım bilgileri", "value": "Değer", diff --git a/interface/locales/zh-CN/common.json b/interface/locales/zh-CN/common.json index 5cd22f0c1..11d655779 100644 --- a/interface/locales/zh-CN/common.json +++ b/interface/locales/zh-CN/common.json @@ -1,5 +1,4 @@ { - "meet_title": "遇见 {{title}}", "about": "关于", "about_vision_text": "我们中的许多人拥有多个云账户,磁盘没有备份,数据有丢失的风险。我们依赖像Google照片和iCloud这样的云服务,但却受限于有限的容量和几乎零互操作性,服务和操作系统之间无法相互配合。相册不应该被困在一个设备生态系统中,或者被用来收割广告数据。它们应该是与操作系统无关,永久的,个人拥有的。我们创造的数据是我们的遗产,将比我们活得更久——开源技术是确保我们对定义我们生活的数据拥有绝对控制权的唯一方式,没有限制。", "about_vision_title": "愿景", @@ -91,6 +90,7 @@ "description": "描述", "deselect": "取消选择", "details": "详情", + "devices": "设备", "devices_coming_soon_tooltip": "即将推出!这个Alpha版本不包括库同步,很快就会准备好。", "direction": "方向", "disabled": "已禁用", @@ -142,6 +142,7 @@ "failed_to_resume_job": "恢复任务失败。", "failed_to_update_location_settings": "更新位置设置失败", "favorite": "收藏", + "favorites": "收藏夹", "file_indexing_rules": "文件索引规则", "filters": "过滤器", "forward": "前进", @@ -165,6 +166,7 @@ "hide_in_sidebar": "在侧边栏中隐藏", "hide_in_sidebar_description": "阻止此标签在应用的侧边栏中显示。", "hide_location_from_view": "隐藏位置和内容的视图", + "home": "主页", "image_labeler_ai_model": "图像标签识别AI模型", "image_labeler_ai_model_description": "用于识别图像中对象的模型。较大的模型更准确但速度较慢。", "import": "导入", @@ -176,6 +178,8 @@ "install_update": "安装更新", "installed": "已安装", "item_size": "项目大小", + "item_with_count_one": "{{count}} 项目", + "item_with_count_other": "{{count}} 项目", "job_has_been_canceled": "作业已取消。", "job_has_been_paused": "作业已暂停。", "job_has_been_removed": "作业已移除。", @@ -190,16 +194,21 @@ "keybinds_description": "查看和管理客户端键绑定", "keys": "密钥", "kilometers": "千米", + "labels": "标签", + "language": "语言", + "language_description": "更改Spacedrive界面的语言", "learn_more_about_telemetry": "了解更多有关遥测的信息", "libraries": "库", "libraries_description": "数据库包含所有库数据和文件元数据。", "library": "库", "library_name": "库名称", + "library_overview": "库概览", "library_settings": "库设置", "library_settings_description": "与当前活动库相关的一般设置。", "list_view": "列表视图", "list_view_notice_description": "通过列表视图轻松导航您的文件和文件夹。这种视图以简单、有组织的列表形式显示文件,让您能够快速定位和访问所需文件。", "loading": "正在加载", + "local": "本地", "local_locations": "本地位置", "local_node": "本地节点", "location_display_name_info": "此位置的名称,这是将显示在侧边栏的名称。不会重命名磁盘上的实际文件夹。", @@ -221,6 +230,7 @@ "media_view_context": "媒体视图上下文", "media_view_notice_description": "轻松发现照片和视频,媒体视图将从当前位置开始显示结果,包括子目录。", "meet_contributors_behind_spacedrive": "结识Spacedrive背后的贡献者", + "meet_title": "遇见 {{title}}", "miles": "英里", "mode": "模式", "modified": "已修改", @@ -231,6 +241,7 @@ "name": "名称", "navigate_back": "回退", "navigate_forward": "前进", + "network": "网络", "network_page_description": "您的局域网上的其他Spacedrive节点将显示在这里,以及您的默认操作系统网络挂载。", "networking": "网络", "networking_port": "网络端口", @@ -261,6 +272,7 @@ "open_settings": "打开设置", "open_with": "打开方式", "or": "或", + "overview": "概览", "pair": "配对", "pairing_with_node": "正在与{{node}}配对", "paste": "粘贴", @@ -275,6 +287,7 @@ "quick_preview": "快速预览", "quick_view": "快速查看", "recent_jobs": "最近的作业", + "recents": "最近使用", "regen_labels": "重新生成标签", "regen_thumbnails": "重新生成缩略图", "regenerate_thumbs": "重新生成缩略图", @@ -296,6 +309,7 @@ "running": "运行中", "save": "保存", "save_changes": "保存更改", + "saved_searches": "保存的搜索", "search_extensions": "搜索扩展", "secure_delete": "安全删除", "security": "安全", @@ -304,6 +318,10 @@ "settings": "设置", "setup": "设置", "share": "分享", + "share_anonymous_usage": "分享匿名使用情况", + "share_anonymous_usage_description": "分享完全匿名的遥测数据,帮助开发者改进应用程序", + "share_bare_minimum": "分享最基本信息", + "share_bare_minimum_description": "只分享我是Spacedrive的活跃用户和一些技术细节", "sharing": "共享", "sharing_description": "管理有权访问您的库的人。", "show_details": "显示详情", @@ -336,6 +354,7 @@ "type": "类型", "ui_animations": "用户界面动画", "ui_animations_description": "打开和关闭时对话框和其他用户界面元素将产生动画效果。", + "unnamed_location": "未命名位置", "usage": "使用情况", "usage_description": "您的库使用情况和硬件信息", "value": "值", diff --git a/interface/locales/zh-TW/common.json b/interface/locales/zh-TW/common.json index 381af5027..8dd9b6f80 100644 --- a/interface/locales/zh-TW/common.json +++ b/interface/locales/zh-TW/common.json @@ -1,5 +1,4 @@ { - "meet_title": "會見 {{title}}", "about": "關於", "about_vision_text": "我們中的許多人都擁有數個雲帳戶,這些雲帳戶中的硬碟未備份且資料面臨丟失的風險。我們依賴諸如Google照片和iCloud之類的雲服務,但這些服務容量有限且幾乎不能在不同的服務及作業系統間進行互通。相簿不應僅限於某個裝置生態系統內,或被用於收集廣告數據。它們應該是與作業系統無關,永久且屬於個人所有的。我們創建的數據是我們的遺產,它們將比我們存活得更久——開源技術是確保我們對定義我們生活的數據擁有絕對控制權的唯一方式,並且無限規模地延伸。", "about_vision_title": "遠景", @@ -91,6 +90,7 @@ "description": "描述", "deselect": "取消選擇", "details": "詳情", + "devices": "設備", "devices_coming_soon_tooltip": "即將推出!這個alpha版本不包括圖書館同步,它將很快準備好。", "direction": "方向", "disabled": "已禁用", @@ -141,6 +141,7 @@ "failed_to_resume_job": "恢復工作失敗。", "failed_to_update_location_settings": "更新位置設置失敗", "favorite": "最愛", + "favorites": "收藏夾", "file_indexing_rules": "文件索引規則", "filters": "篩選器", "forward": "前進", @@ -164,6 +165,7 @@ "hide_in_sidebar": "在側邊欄中隱藏", "hide_in_sidebar_description": "防止此標籤在應用的側欄中顯示。", "hide_location_from_view": "從視圖中隱藏位置和內容", + "home": "主頁", "image_labeler_ai_model": "圖像標籤識別AI模型", "image_labeler_ai_model_description": "用於識別圖像中對象的模型。模型越大,準確性越高,但速度越慢。", "import": "導入", @@ -175,6 +177,8 @@ "install_update": "安裝更新", "installed": "已安裝", "item_size": "項目大小", + "item_with_count_one": "{{count}} 项目", + "item_with_count_other": "{{count}} 项目", "job_has_been_canceled": "工作已取消。", "job_has_been_paused": "工作已暫停。", "job_has_been_removed": "工作已移除。", @@ -189,16 +193,21 @@ "keybinds_description": "查看和管理客戶端鍵綁定", "keys": "鍵", "kilometers": "公里", + "labels": "標籤", + "language": "語言", + "language_description": "更改Spacedrive界面的語言", "learn_more_about_telemetry": "了解更多關於遙測的信息", "libraries": "圖書館", "libraries_description": "數據庫包含所有圖書館數據和文件元數據。", "library": "圖書館", "library_name": "圖書館名稱", + "library_overview": "圖書館概覽", "library_settings": "圖書館設置", "library_settings_description": "與當前活躍圖書館相關的通用設置。", "list_view": "列表視圖", "list_view_notice_description": "使用列表視圖輕鬆導航您的文件和文件夾。這個視圖以簡單、有組織的列表格式顯示您的文件,幫助您快速定位和訪問所需文件。", "loading": "加載中", + "local": "本地", "local_locations": "本地位置", "local_node": "本地節點", "location_display_name_info": "這個位置的名稱,這是在側邊欄中顯示的內容。不會重命名磁碟上的實際文件夾。", @@ -220,6 +229,7 @@ "media_view_context": "媒體視圖上下文", "media_view_notice_description": "輕鬆發現照片和視頻,媒體視圖會從當前位置(包括子目錄)顯示結果。", "meet_contributors_behind_spacedrive": "認識Spacedrive背後的貢獻者", + "meet_title": "會見 {{title}}", "miles": "英里", "mode": "模式", "modified": "已修改", @@ -230,6 +240,7 @@ "name": "名稱", "navigate_back": "後退", "navigate_forward": "前進", + "network": "網絡", "network_page_description": "您局域網上的其他Spacedrive節點將顯示在這裡,以及您預設的OS網絡掛載。", "networking": "網絡", "networking_port": "網絡端口", @@ -260,6 +271,7 @@ "open_settings": "打開設置", "open_with": "打開方式", "or": "或者", + "overview": "概覽", "pair": "配對", "pairing_with_node": "正在與{{node}}配對", "paste": "貼上", @@ -274,6 +286,7 @@ "quick_preview": "快速預覽", "quick_view": "快速查看", "recent_jobs": "最近的工作", + "recents": "最近的文件", "regen_labels": "重新生成標籤", "regen_thumbnails": "重新生成縮略圖", "regenerate_thumbs": "重新生成縮略圖", @@ -295,6 +308,7 @@ "running": "運行", "save": "保存", "save_changes": "保存變更", + "saved_searches": "已保存的搜索", "search_extensions": "搜索擴展", "secure_delete": "安全刪除", "security": "安全", @@ -303,6 +317,10 @@ "settings": "設置", "setup": "設定", "share": "分享", + "share_anonymous_usage": "分享匿名使用情況", + "share_anonymous_usage_description": "分享完全匿名的遙測數據,以幫助開發人員改進應用程序", + "share_bare_minimum": "分享最低限度", + "share_bare_minimum_description": "僅分享我是Spacedrive的活躍用戶和一些技術細節", "sharing": "共享", "sharing_description": "管理誰可以訪問您的圖書館。", "show_details": "顯示詳情", @@ -335,6 +353,7 @@ "type": "類型", "ui_animations": "UI動畫", "ui_animations_description": "對話框和其它UI元素在打開和關閉時會有動畫效果。", + "unnamed_location": "未命名位置", "usage": "使用情況", "usage_description": "您的圖書館使用情況和硬體資訊。", "value": "值", diff --git a/interface/package.json b/interface/package.json index da2e2ea0b..d4fd68da6 100644 --- a/interface/package.json +++ b/interface/package.json @@ -39,6 +39,7 @@ "dayjs": "^1.11.10", "framer-motion": "^10.16.4", "i18next": "^23.7.10", + "i18next-browser-languagedetector": "^7.2.0", "immer": "^10.0.3", "prismjs": "^1.29.0", "react": "^18.2.0", diff --git a/packages/assets/util/index.ts b/packages/assets/util/index.ts index c216b5c52..6e214376b 100644 --- a/packages/assets/util/index.ts +++ b/packages/assets/util/index.ts @@ -13,8 +13,7 @@ export const iconNames = Object.fromEntries( ) as Record; export const getIconByName = (name: IconTypes, isDark?: boolean) => { - let _name = name; - if (!isDark) _name = (name + '_Light') as IconTypes; + if (!isDark) name = (name + '_Light') as IconTypes; return icons[name]; }; diff --git a/packages/client/src/cache.tsx b/packages/client/src/cache.tsx index 7ea6bdc6f..ec6dc3d64 100644 --- a/packages/client/src/cache.tsx +++ b/packages/client/src/cache.tsx @@ -143,7 +143,13 @@ function restore(cache: Store, subscribed: Map>, item: unkn subscribed.set(item.__type, new Set([item.__id])); } - return result; + // We call restore again for arrays and objects to deal with nested relations. + return Object.fromEntries( + Object.entries(result).map(([key, value]) => [ + key, + restore(cache, subscribed, value) + ]) + ); } return Object.fromEntries( @@ -186,12 +192,40 @@ function updateNodes(cache: Store, data: CacheNode[] | undefined) { delete copy.__type; delete copy.__id; + const original = cache.nodes?.[item.__type]?.[item.__id]; + specialMerge(copy, original); + if (!cache.nodes[item.__type]) cache.nodes[item.__type] = {}; // TODO: This should be a deepmerge but that would break stuff like `size_in_bytes` or `inode` as the arrays are joined. cache.nodes[item.__type]![item.__id] = copy; } } +// When using PCR's data structure if you don't fetch a relation `null` is returned. +// If two queries return a single entity but one fetches relations and the other doesn't that null might "win" over the actual data. +// Once it "wins" the normalised cache is updated causing all `useCache`'s to rerun. +// +// The `useCache` hook derives the type from the specific React Query operation. +// Due to this the result of a `useCache` might end up as `null` even when TS says it's `T` causing crashes due to no-null checks. +// +// So this merge function causes the `null` to be replaced with the original value. +function specialMerge(copy: Record, original: unknown) { + if ( + original && + typeof original === 'object' && + typeof copy === 'object' && + !Array.isArray(original) && + !Array.isArray(copy) + ) { + for (const [property, value] of Object.entries(original)) { + copy[property] = copy[property] || value; + + if (typeof copy[property] === 'object' && !Array.isArray(copy[property])) + specialMerge(copy[property], value); + } + } +} + export type UseCacheResult = T extends (infer A)[] ? UseCacheResult[] : T extends object diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 73593ea34..d532dacab 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -7,7 +7,7 @@ export type Procedures = { { key: "backups.getAll", input: never, result: GetAll } | { key: "buildInfo", input: never, result: BuildInfo } | { key: "cloud.getApiOrigin", input: never, result: string } | - { key: "cloud.library.get", input: LibraryArgs, result: { uuid: string; name: string; instances: CloudInstance[]; ownerId: string } | null } | + { key: "cloud.library.get", input: LibraryArgs, result: { id: string; uuid: string; name: string; instances: CloudInstance[]; ownerId: string } | null } | { key: "cloud.library.list", input: never, result: CloudLibrary[] } | { key: "cloud.locations.list", input: never, result: CloudLocation[] } | { key: "ephemeralFiles.getMediaData", input: string, result: ({ type: "Image" } & ImageMetadata) | ({ type: "Video" } & VideoMetadata) | ({ type: "Audio" } & AudioMetadata) | null } | @@ -40,6 +40,7 @@ export type Procedures = { { key: "notifications.dismiss", input: NotificationId, result: null } | { key: "notifications.dismissAll", input: never, result: null } | { key: "notifications.get", input: never, result: Notification[] } | + { key: "p2p.state", input: never, result: JsonValue } | { key: "preferences.get", input: LibraryArgs, result: LibraryPreferences } | { key: "search.objects", input: LibraryArgs, result: SearchData } | { key: "search.objectsCount", input: LibraryArgs<{ filters?: SearchFilterArgs[] }>, result: number } | @@ -61,6 +62,7 @@ export type Procedures = { { key: "backups.restore", input: string, result: null } | { key: "cloud.library.create", input: LibraryArgs, result: null } | { key: "cloud.library.join", input: string, result: LibraryConfigWrapped } | + { key: "cloud.library.sync", input: LibraryArgs, result: null } | { key: "cloud.locations.create", input: string, result: CloudLocation } | { key: "cloud.locations.remove", input: string, result: CloudLocation } | { key: "cloud.locations.testing", input: TestingParams, result: null } | @@ -110,8 +112,6 @@ export type Procedures = { { key: "nodes.updateThumbnailerPreferences", input: UpdateThumbnailerPreferences, result: null } | { key: "p2p.acceptSpacedrop", input: [string, string | null], result: null } | { key: "p2p.cancelSpacedrop", input: string, result: null } | - { key: "p2p.pair", input: RemoteIdentity, result: number } | - { key: "p2p.pairingResponse", input: [number, PairingDecision], result: null } | { key: "p2p.spacedrop", input: SpacedropArgs, result: string } | { key: "preferences.update", input: LibraryArgs, result: null } | { key: "search.saved.create", input: LibraryArgs<{ name: string; search?: string | null; filters?: string | null; description?: string | null; icon?: string | null }>, result: null } | @@ -161,9 +161,9 @@ export type CameraData = { device_make: string | null; device_model: string | nu export type ChangeNodeNameArgs = { name: string | null; p2p_port: MaybeUndefined; p2p_enabled: boolean | null; image_labeler_version: string | null } -export type CloudInstance = { id: string; uuid: string; identity: string } +export type CloudInstance = { id: string; uuid: string; identity: RemoteIdentity; nodeId: string; nodeName: string; nodePlatform: number } -export type CloudLibrary = { uuid: string; name: string; instances: CloudInstance[]; ownerId: string } +export type CloudLibrary = { id: string; uuid: string; name: string; instances: CloudInstance[]; ownerId: string } export type CloudLocation = { id: string; name: string } @@ -379,7 +379,12 @@ description: string | null; /** * id of the current instance so we know who this `.db` is. This can be looked up within the `Instance` table. */ -instance_id: number; version: LibraryConfigVersion } +instance_id: number; +/** + * 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. + */ +cloud_id?: string | null; version: LibraryConfigVersion } export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" @@ -504,14 +509,10 @@ export type Orientation = "Normal" | "CW90" | "CW180" | "CW270" | "MirroredVerti /** * TODO: P2P event for the frontend */ -export type P2PEvent = { type: "DiscoveredPeer"; identity: RemoteIdentity; metadata: PeerMetadata } | { type: "ExpiredPeer"; identity: RemoteIdentity } | { type: "ConnectedPeer"; identity: RemoteIdentity } | { type: "DisconnectedPeer"; identity: RemoteIdentity } | { type: "SpacedropRequest"; id: string; identity: RemoteIdentity; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedout"; id: string } | { type: "SpacedropRejected"; id: string } | { type: "PairingRequest"; id: number; name: string; os: OperatingSystem } | { type: "PairingProgress"; id: number; status: PairingStatus } +export type P2PEvent = { type: "DiscoveredPeer"; identity: RemoteIdentity; metadata: PeerMetadata } | { type: "ExpiredPeer"; identity: RemoteIdentity } | { type: "ConnectedPeer"; identity: RemoteIdentity } | { type: "DisconnectedPeer"; identity: RemoteIdentity } | { type: "SpacedropRequest"; id: string; identity: RemoteIdentity; peer_name: string; files: string[] } | { type: "SpacedropProgress"; id: string; percent: number } | { type: "SpacedropTimedout"; id: string } | { type: "SpacedropRejected"; id: string } export type P2PStatus = { ipv4: ListenerStatus; ipv6: ListenerStatus } -export type PairingDecision = { decision: "accept"; libraryId: string } | { decision: "reject" } - -export type PairingStatus = { type: "EstablishingConnection" } | { type: "PairingRequested" } | { type: "LibraryAlreadyExists" } | { type: "PairingDecisionRequest" } | { type: "PairingInProgress"; data: { library_name: string; library_description: string | null } } | { type: "InitialSyncProgress"; data: number } | { type: "PairingComplete"; data: string } | { type: "PairingRejected" } - export type PeerMetadata = { name: string; operating_system: OperatingSystem | null; device_model: HardwareModel | null; version: string | null } export type PlusCode = string diff --git a/packages/client/src/hooks/useP2PEvents.tsx b/packages/client/src/hooks/useP2PEvents.tsx index 4a01b5c17..e90816d32 100644 --- a/packages/client/src/hooks/useP2PEvents.tsx +++ b/packages/client/src/hooks/useP2PEvents.tsx @@ -8,13 +8,12 @@ import { useState } from 'react'; -import { P2PEvent, PairingStatus, PeerMetadata } from '../core'; +import { P2PEvent, PeerMetadata } from '../core'; import { useBridgeSubscription } from '../rspc'; type Context = { discoveredPeers: Map; connectedPeers: Map; - pairingStatus: Map; spacedropProgresses: Map; events: MutableRefObject; }; @@ -25,7 +24,6 @@ export function P2PContextProvider({ children }: PropsWithChildren) { const events = useRef(new EventTarget()); const [[discoveredPeers], setDiscoveredPeer] = useState([new Map()]); const [[connectedPeers], setConnectedPeers] = useState([new Map()]); - const [[pairingStatus], setPairingStatus] = useState([new Map()]); const [[spacedropProgresses], setSpacedropProgresses] = useState([new Map()]); useBridgeSubscription(['p2p.events'], { @@ -44,8 +42,6 @@ export function P2PContextProvider({ children }: PropsWithChildren) { } else if (data.type === 'DisconnectedPeer') { connectedPeers.delete(data.identity); setConnectedPeers([connectedPeers]); - } else if (data.type === 'PairingProgress') { - setPairingStatus([pairingStatus.set(data.id, data.status)]); } else if (data.type === 'SpacedropProgress') { spacedropProgresses.set(data.id, data.percent); setSpacedropProgresses([spacedropProgresses]); @@ -58,7 +54,6 @@ export function P2PContextProvider({ children }: PropsWithChildren) { value={{ discoveredPeers, connectedPeers, - pairingStatus, spacedropProgresses, events }} @@ -76,10 +71,6 @@ export function useConnectedPeers() { return useContext(Context).connectedPeers; } -export function usePairingStatus(pairing_id: number) { - return useContext(Context).pairingStatus.get(pairing_id); -} - export function useSpacedropProgress(id: string) { return useContext(Context).spacedropProgresses.get(id); } diff --git a/packages/client/src/lib/explorerItem.ts b/packages/client/src/lib/explorerItem.ts index 997aeb7d6..2fec020f9 100644 --- a/packages/client/src/lib/explorerItem.ts +++ b/packages/client/src/lib/explorerItem.ts @@ -39,6 +39,8 @@ export function getExplorerItemData(data?: ExplorerItem | null): ItemData { const object = getItemObject(data); if (object?.kind) itemData.kind = ObjectKind[object?.kind] ?? 'Unknown'; + else if(data.type === "NonIndexedPath") itemData.kind = ObjectKind[data.item.kind] ?? 'Unknown'; + // Objects only have dateCreated and dateAccessed itemData.dateCreated = object?.date_created ?? null; itemData.dateAccessed = object?.date_accessed ?? null; diff --git a/packages/client/src/stores/featureFlags.tsx b/packages/client/src/stores/featureFlags.tsx index c18be8e9d..b43ae9baf 100644 --- a/packages/client/src/stores/featureFlags.tsx +++ b/packages/client/src/stores/featureFlags.tsx @@ -6,7 +6,6 @@ import { nonLibraryClient, useBridgeQuery } from '../rspc'; import { createPersistedMutable, useObserver, useSolidStore } from '../solid'; export const features = [ - 'p2pPairing', 'backups', 'debugRoutes', 'solidJsDemo', @@ -90,10 +89,7 @@ export function toggleFeatureFlag(flags: FeatureFlag | FeatureFlag[]) { if (!featureFlagsStore.enabled.find((ff) => f === ff)) { let message: string | undefined; - if (f === 'p2pPairing') { - message = - 'This feature will render your database broken and it WILL need to be reset! Use at your own risk!'; - } else if (f === 'backups') { + if (f === 'backups') { message = 'Backups are done on your live DB without proper Sqlite snapshotting. This will work but it could result in unintended side so be careful!'; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6de4688cb..6b7cb5b12 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/project.inlang/settings.json b/project.inlang/settings.json index 2513eaf99..51c11bc2b 100644 --- a/project.inlang/settings.json +++ b/project.inlang/settings.json @@ -5,7 +5,6 @@ "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-i18next@latest/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js", - "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@1/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js", "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js" ], diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 50fa4928b..6d833ff50 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.73" +channel = "1.75"