Merge branch 'main' into eng-1896-add-date-range-to-search-options

This commit is contained in:
Jamie Pine
2025-01-03 17:55:23 -08:00
committed by GitHub
69 changed files with 3115 additions and 249 deletions

66
.zed/settings.json Normal file
View File

@@ -0,0 +1,66 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"inlay_hints": {
"enabled": false
},
"languages": {
"Rust": {
"enable_language_server": true,
"formatter": "language_server",
"inlay_hints": {
"enabled": true,
"show_type_hints": true,
"show_parameter_hints": true,
"show_other_hints": true,
"show_background": false,
"edit_debounce_ms": 700,
"scroll_debounce_ms": 50
}
},
"TOML": {
"formatter": "language_server"
}
},
"lsp": {
"rust-analyzer": {
"initialization_options": {
"procMacro": {
"enable": true
},
"diagnostics": {
"experimental": {
"enable": false
}
},
"showUnlinkedFileNotification": false
}
}
},
"file_scan_exclusions": [
"node_modules",
"**/node_modules",
"**/bower_components",
"**/*.code-search",
"**/*.contentlayer",
"**/*.next",
"**/dist",
"apps/mobile/ios/Pods",
"apps/mobile/android",
"apps/mobile/ios",
"**/.git",
"**/.svn",
"**/.hg",
"**/CVS",
"**/.DS_Store"
],
"format_on_save": "on",
"ensure_final_newline_on_save": true,
"remove_trailing_whitespace_on_save": true,
"tab_size": 4,
"hard_tabs": false,
"show_whitespaces": "selection",
"show_completion_documentation": true
}

View File

@@ -38,17 +38,18 @@ This project uses [Cargo](https://doc.rust-lang.org/cargo/getting-started/instal
To make changes locally, follow these steps:
1. Clone & enter the repository:
```
git clone https://github.com/spacedriveapp/spacedrive && cd spacedrive
```
Alternatively, if youve already cloned the repo locally, pull the latest changes with: `git pull`
` git clone https://github.com/spacedriveapp/spacedrive && cd spacedrive
`
Alternatively, if youve already cloned the repo locally, pull the latest changes with: `git pull`
> [!TIP]
> Consider running `pnpm clean` after pulling the repository if you're returning to it from previously to avoid old files conflicting.
2. Configure your system environment for Spacedrive development
- For Unix users (Linux / macOS), run: `./scripts/setup.sh`
- For Windows users, run: `.\scripts\setup.ps1` via PowerShell.
3. Configure your system environment for Spacedrive development
- For Unix users (Linux / macOS), run: `./scripts/setup.sh`
- For Windows users, run: `.\scripts\setup.ps1` via PowerShell.
> [!NOTE]
> This script ([Unix](https://github.com/spacedriveapp/spacedrive/blob/main/scripts/setup.sh) / [Windows](https://github.com/spacedriveapp/spacedrive/blob/main/scripts/setup.ps1)) will check for if Rust and pnpm are installed then proceed to install any other required dependencies for Spacedrive to build via your system's respective package manager.
3. Install NodeJS dependencies: `pnpm i`
4. Prepare the build: `pnpm prep`. This will run all necessary codegen and build required dependencies.
@@ -58,13 +59,16 @@ To make changes locally, follow these steps:
> The test files will be located in a directory called `test-data` in the root of the Spacedrive repository.
To run the **desktop** app, run:
```
pnpm tauri dev
```
> [!NOTE]
> The Tauri desktop app always runs its own instance of the backend and will not connect to a separately initiated `sd-server` instance.
To run the **backend server**, run:
```
cargo run -p sd-server
```
@@ -73,23 +77,29 @@ cargo run -p sd-server
> If necessary, [DevTools](https://tauri.app/v1/guides/debugging/application/#webview-console) for the WebView can be opened by pressing <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>I</kbd> (Linux and Windows) or <kbd>Command</kbd>+<kbd>Option</kbd>+<kbd>I</kbd> (macOS) in the desktop app.
>
> Also, React DevTools can be launched using `pnpx react-devtools`.
However, it must be executed before starting the desktop app for it to connect.
> However, it must be executed before starting the desktop app for it to connect.
To run the **web** app (requires the backend to be running), run:
```
pnpm web dev
```
> [!TIP]
> You can also quickly launch the web interface together with the backend with:
>
> ```
> pnpm dev:web
> ```
To run the **e2e tests** for the web app:
```
pnpm web test:e2e
```
If you are developing a new test, you can execute Cypress in interactive mode with:
```
pnpm web test:interactive
```
@@ -97,19 +107,21 @@ pnpm web test:interactive
#### Troubleshooting
- If you encounter any issues, ensure that you are using the following versions of Rust, Node.js and pnpm:
| tool | version |
| ---- | ------- |
| Rust | [`1.81`](rust-toolchain.toml) |
| tool | version |
| ---- | ------- |
| Rust | [`1.81`](rust-toolchain.toml) |
| Node.js | [`18.18`](.nvmrc) |
| pnpm | `9.4.0` |
| pnpm | `9.4.0` |
[`rustup`](https://rustup.rs/) & [`nvm`](https://github.com/nvm-sh/nvm) should both pick up on the appropriate versions of the Rust Toolchain & Node respectively from the project automatically.
> **Note**: If you get a local migration error in development, you might need to set the following environment variables: [database documentation](docs/developers/architecture/database.mdx#environment-variables).
- After cleaning out your build artifacts using `pnpm clean`, it's necessary to re-run `pnpm prep`.
[`rustup`](https://rustup.rs/) & [`nvm`](https://github.com/nvm-sh/nvm) should both pick up on the appropriate versions of the Rust Toolchain & Node respectively from the project automatically.
- Make sure to read the [guidelines](https://spacedrive.com/docs/developers/prerequisites/guidelines) to ensure that your code follows a similar style to ours.
- After cleaning out your build artifacts using `pnpm clean`, it's necessary to re-run `pnpm prep`.
- After you finish making your changes and committing them to your branch, make sure to execute `pnpm autoformat` to fix any style inconsistency in your code.
- Make sure to read the [guidelines](https://spacedrive.com/docs/developers/prerequisites/guidelines) to ensure that your code follows a similar style to ours.
- After you finish making your changes and committing them to your branch, make sure to execute `pnpm autoformat` to fix any style inconsistency in your code.
### Landing Page
@@ -136,53 +148,56 @@ To run the mobile app:
> Most modern phones use `arm64-v8a` while the Android Studio embedded emulator runs `x86_64`
If you wish to debug directly on a local Android device:
- Install [ADB](https://developer.android.com/tools/adb)
- On macOS use [homebrew](https://brew.sh/): `brew install adb`
- [Configure debugging on your device](https://developer.android.com/tools/adb#Enabling)
- Select "Remember this device" & "Trust" when connecting over USB.
- Run `pnpm mobile android` with your device connected via USB.
>[!TIP]
- Install [ADB](https://developer.android.com/tools/adb)
- On macOS use [homebrew](https://brew.sh/): `brew install adb`
- [Configure debugging on your device](https://developer.android.com/tools/adb#Enabling)
- Select "Remember this device" & "Trust" when connecting over USB.
- Run `pnpm mobile android` with your device connected via USB.
> [!TIP]
> To access the logs from `sd-core` when running on device, run the following command:
>
> ```
> adb logcat | grep -i com.spacedrive.app
> ```
#### iOS
- Install the latest version of [Xcode](https://apps.apple.com/au/app/xcode/id497799835) and Simulator if you wish to emulate an iOS device on your Mac.
- When running Xcode for the first time, make sure to select the latest version of iOS.
- Run `pnpm mobile ios` in the terminal to build & run the app on the Simulator.
- To run the app in debug mode with backend (`sd-core`) logging, comment out the following lines before running the above command:
https://github.com/spacedriveapp/spacedrive/blob/d180261ca5a93388486742e8f921e895e9ec26a4/apps/mobile/modules/sd-core/ios/build-rust.sh#L51-L54
#### iOS
- Install the latest version of [Xcode](https://apps.apple.com/au/app/xcode/id497799835) and Simulator if you wish to emulate an iOS device on your Mac.
- When running Xcode for the first time, make sure to select the latest version of iOS.
- Run `pnpm mobile ios` in the terminal to build & run the app on the Simulator.
- To run the app in debug mode with backend (`sd-core`) logging, comment out the following lines before running the above command:
https://github.com/spacedriveapp/spacedrive/blob/d180261ca5a93388486742e8f921e895e9ec26a4/apps/mobile/modules/sd-core/ios/build-rust.sh#L51-L54
You can now get backend (`sd-core`) logs from the Simulator by running the following command:
```
xcrun simctl launch --console booted com.spacedrive.app
```
- If you'd like to run the app on device, run:
```
pnpm mobile ios --device
```
```
xcrun simctl launch --console booted com.spacedrive.app
```
- If you'd like to run the app on device, run: `pnpm mobile ios --device`
> [!IMPORTANT]
> Note that you can only get `sd-core` logs from the app when running it on device by running the frontend and backend separately.
To run the backend (`sd-core`) separately, open up Xcode by running:
```
xed apps/mobile/ios
```
Select from the top if you wish to start on device or Simulator, and press play.
| Select Device | Run the App | Build & Core logs are found here |
| --- | --- | --- |
|![](./apps/landing/public/images/xcode-run-sd-core.01.png)|![](./apps/landing/public/images/xcode-run-sd-core.02.png)|![](./apps/landing/public/images/xcode-run-sd-core.03.png)|
| Select Device | Run the App | Build & Core logs are found here |
| ---------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- |
| ![](./apps/landing/public/images/xcode-run-sd-core.01.png) | ![](./apps/landing/public/images/xcode-run-sd-core.02.png) | ![](./apps/landing/public/images/xcode-run-sd-core.03.png) |
To run the frontend, run the following:
```
pnpm mobile start
```
> [!IMPORTANT]
> The frontend is not functional without the sd-core running as well.
### Pull Request
Once you have finished making your changes, create a pull request (PR) to submit them.

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -20,7 +20,7 @@ rust-version = "1.81"
[workspace.dependencies]
# First party dependencies
sd-cloud-schema = { git = "https://github.com/spacedriveapp/cloud-services-schema", rev = "74af2a727c" }
sd-cloud-schema = { git = "https://github.com/spacedriveapp/cloud-services-schema", rev = "4e4565bee4" }
# Third party dependencies used by one or more of our crates
async-channel = "2.3"

View File

@@ -3,16 +3,20 @@ import SwiftRs
@objc
public enum AppThemeType: Int {
case auto = -1
case light = 0
case dark = 1
case auto = -1
case light = 0
case dark = 1
}
var activity: NSObjectProtocol?
private let activityLock = NSLock()
private var activity: NSObjectProtocol?
private var isThemeUpdating = false
@_cdecl("disable_app_nap")
public func disableAppNap(reason: SRString) -> Bool {
// Check if App Nap is already disabled
activityLock.lock()
defer { activityLock.unlock() }
guard activity == nil else {
return false
}
@@ -26,37 +30,55 @@ public func disableAppNap(reason: SRString) -> Bool {
@_cdecl("enable_app_nap")
public func enableAppNap() -> Bool {
// Check if App Nap is already enabled
guard let pinfo = activity else {
activityLock.lock()
defer { activityLock.unlock() }
guard let currentActivity = activity else {
return false
}
ProcessInfo.processInfo.endActivity(pinfo)
ProcessInfo.processInfo.endActivity(currentActivity)
activity = nil
return true
}
@_cdecl("lock_app_theme")
public func lockAppTheme(themeType: AppThemeType) {
var theme: NSAppearance?
switch themeType {
case .auto:
theme = nil
case .dark:
theme = NSAppearance(named: .darkAqua)!
case .light:
theme = NSAppearance(named: .aqua)!
}
DispatchQueue.main.async {
NSApp.appearance = theme
// Trigger a repaint of the window
if let window = NSApplication.shared.mainWindow {
window.invalidateShadow()
window.displayIfNeeded()
// Prevent concurrent theme updates
guard !isThemeUpdating else {
return
}
isThemeUpdating = true
let theme: NSAppearance?
switch themeType {
case .auto:
theme = nil
case .dark:
theme = NSAppearance(named: .darkAqua)
case .light:
theme = NSAppearance(named: .aqua)
}
// Use sync to ensure completion before return
DispatchQueue.main.sync {
autoreleasepool {
NSApp.appearance = theme
if let window = NSApplication.shared.mainWindow {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0
window.invalidateShadow()
window.displayIfNeeded()
}, completionHandler: {
isThemeUpdating = false
})
} else {
isThemeUpdating = false
}
}
}
}
}
@_cdecl("set_titlebar_style")

View File

@@ -12,12 +12,13 @@
"lint": "eslint src --cache"
},
"dependencies": {
"@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495",
"@spacedrive/rspc-tauri": "github:spacedriveapp/rspc#path:packages/tauri&6a77167495",
"@crabnebula/tauri-plugin-drag": "^2.0.0",
"@remix-run/router": "=1.13.1",
"@sd/client": "workspace:*",
"@sd/interface": "workspace:*",
"@sd/ui": "workspace:*",
"@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495",
"@spacedrive/rspc-tauri": "github:spacedriveapp/rspc#path:packages/tauri&6a77167495",
"@t3-oss/env-core": "^0.7.1",
"@tanstack/react-query": "^5.59",
"@tauri-apps/api": "=2.0.3",
@@ -31,7 +32,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "=6.20.1",
"sonner": "^1.0.3",
"supertokens-web-js": "^0.13.0"
"supertokens-web-js": "=0.13.0"
},
"devDependencies": {
"@sd/config": "workspace:*",

View File

@@ -32,22 +32,25 @@ thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
base64 = { workspace = true }
# Specific Desktop dependencies
# WARNING: Do NOT enable default features, as that vendors dbus (see below)
opener = { version = "0.7.1", features = ["reveal"], default-features = false }
specta-typescript = "=0.0.7"
tauri-plugin-clipboard-manager = "=2.0.1"
tauri-plugin-cors-fetch = { path = "../../../crates/tauri-plugin-cors-fetch" }
tauri-plugin-deep-link = "=2.0.1"
tauri-plugin-dialog = "=2.0.3"
tauri-plugin-http = "=2.0.3"
tauri-plugin-os = "=2.0.1"
tauri-plugin-shell = "=2.0.2"
tauri-plugin-updater = "=2.0.2"
tauri-plugin-drag = "2.0.0"
drag = "2.0.0"
# memory allocator
mimalloc = { workspace = true }
tauri-plugin-cors-fetch = "2.1.1"
mimalloc = { workspace = true }
[dependencies.tauri]
features = ["linux-libxdo", "macos-private-api", "native-tls-vendored", "unstable"]

View File

@@ -27,6 +27,7 @@
"core:window:allow-start-dragging",
"core:webview:allow-internal-toggle-devtools",
"cors-fetch:default",
"drag:default",
{
"identifier": "http:default",
"allow": [

View File

@@ -0,0 +1,223 @@
// Import required dependencies for drag and drop operations, serialization, and async functionality
use drag::{DragItem, Image, Options};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tauri::{ipc::Channel, Manager, PhysicalPosition, State, WebviewWindow};
// DragState wraps a thread-safe boolean flag to track drag operation status
#[derive(Clone)]
pub struct DragState(pub Arc<Mutex<bool>>);
// Default implementation for DragState initializes with false
impl Default for DragState {
fn default() -> Self {
Self(Arc::new(Mutex::new(false)))
}
}
// Enum to represent the result of a drag operation (serializable for IPC)
#[derive(Serialize, Deserialize, Type, Clone)]
pub enum WrappedDragResult {
Dropped,
Cancel,
}
// Structure to hold cursor position coordinates (serializable for IPC)
#[derive(Serialize, Deserialize, Type, Clone)]
pub struct WrappedCursorPosition {
x: i32,
y: i32,
}
// Combined structure for drag operation results (serializable for IPC)
#[derive(Serialize, Deserialize, Type, Clone)]
pub struct CallbackResult {
result: WrappedDragResult,
#[serde(rename = "cursorPos")]
cursor_pos: WrappedCursorPosition,
}
// Conversion implementations for drag-rs types to our wrapped types
impl From<drag::DragResult> for WrappedDragResult {
fn from(result: drag::DragResult) -> Self {
match result {
drag::DragResult::Dropped => WrappedDragResult::Dropped,
drag::DragResult::Cancel => WrappedDragResult::Cancel,
}
}
}
impl From<drag::CursorPosition> for WrappedCursorPosition {
fn from(pos: drag::CursorPosition) -> Self {
WrappedCursorPosition { x: pos.x, y: pos.y }
}
}
// Global flag to track if position tracking is active
static TRACKING: AtomicBool = AtomicBool::new(false);
#[tauri::command(async)]
/// Initiates a drag and drop operation with cursor position tracking
///
/// # Arguments
/// * `window` - The Tauri window instance
/// * `_state` - Current drag state (unused)
/// * `files` - Vector of file paths to be dragged
/// * `icon_path` - Path to the preview icon for the drag operation
/// * `on_event` - Channel for communicating drag operation events back to the frontend
#[specta::specta]
pub async fn start_drag(
window: WebviewWindow,
_state: State<'_, DragState>,
files: Vec<String>,
icon_path: String,
on_event: Channel<CallbackResult>,
) -> Result<(), String> {
// Fast atomic swap for tracking state
match TRACKING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => {
println!("Starting position tracking");
}
Err(_) => {
// If already tracking, stop previous instance quickly
TRACKING.store(false, Ordering::SeqCst);
tokio::time::sleep(tokio::time::Duration::from_millis(16)).await;
TRACKING.store(true, Ordering::SeqCst);
println!("Restarting position tracking");
}
}
// Pre-allocate resources before spawning task
let window_handle = Arc::new(window);
let app_handle = window_handle.app_handle();
// Initialize control flags
let cancel_flag = Arc::new(AtomicBool::new(false));
let is_completed = Arc::new(AtomicBool::new(false));
// Prepare resources once with minimal cloning
let tracking_resources = Arc::new((files.clone(), icon_path.clone(), Arc::new(on_event)));
println!("Starting position tracking");
// Get handles for window and app management
let window_clone = window_handle.clone();
let app_handle_owned = app_handle.to_owned();
let window_owned = window_clone.to_owned();
// Control flags for operation state
let is_completed_clone = is_completed.clone();
// Spawn background task for cursor tracking
tokio::spawn(async move {
// Initialize tracking state
let mut last_position = (0.0, 0.0);
let mut last_message_time = Instant::now();
let threshold = 1.0; // Minimum movement threshold
let message_debounce = Duration::from_millis(32); // State update interval
let mut was_inside = false;
// Main tracking loop
while TRACKING.load(Ordering::SeqCst) && !is_completed.load(Ordering::SeqCst) {
let window_for_check = window_owned.clone();
// Skip if window is not focused
if !window_for_check.is_focused().unwrap_or(false) {
tokio::time::sleep(tokio::time::Duration::from_millis(8)).await;
continue;
}
// Get current cursor and window positions
if let (Ok(cursor_position), Ok(window_position), Ok(window_size)) = (
window_for_check.cursor_position(),
window_for_check.outer_position(),
window_for_check.inner_size(),
) {
// Calculate cursor position relative to window
let relative_position = PhysicalPosition::new(
cursor_position.x - window_position.x as f64,
cursor_position.y - window_position.y as f64,
);
// Check if cursor is inside window boundaries
let is_inside = relative_position.x >= 0.0
&& relative_position.y >= 0.0
&& relative_position.x <= window_size.width as f64
&& relative_position.y <= window_size.height as f64;
// Process state changes if cursor moved enough
if is_inside != was_inside
&& ((relative_position.x - last_position.0).abs() > threshold
|| (relative_position.y - last_position.1).abs() > threshold)
{
let now = Instant::now();
if now.duration_since(last_message_time) >= message_debounce {
// Prepare resources for drag operation
let files_for_drag = tracking_resources.0.clone();
let icon_path_for_drag = tracking_resources.1.clone();
let on_event_for_drag = tracking_resources.2.clone();
let is_completed = is_completed_clone.clone();
let cancel_flag_clone = cancel_flag.clone();
let window_for_drag = window_owned.clone();
// Execute drag operation on main thread
app_handle_owned
.run_on_main_thread(move || {
if !is_inside {
println!("Starting drag operation");
// Create drag items
let paths: Vec<PathBuf> =
files_for_drag.iter().map(PathBuf::from).collect();
let item = DragItem::Files(paths);
let preview_icon =
Image::File(PathBuf::from(&icon_path_for_drag));
// Start the drag operation
if let Ok(_) = drag::start_drag(
&window_for_drag,
item,
preview_icon,
move |result, cursor_pos| {
// Send result back to frontend
let _ = on_event_for_drag.send(CallbackResult {
result: result.into(),
cursor_pos: cursor_pos.into(),
});
// Mark operation as completed
is_completed.store(true, Ordering::SeqCst);
TRACKING.store(false, Ordering::SeqCst);
},
Options::default(),
) {
println!("Drag operation started");
}
} else {
println!("Cursor returned to window");
cancel_flag_clone.store(true, Ordering::SeqCst);
// We have this for now, but technically, it doesn't do anything.
// I'm still trying to figure out how to cancel mid-drag without the user having to cancel the dragging on the frontend too.
// - @Rocky43007
}
})
.unwrap_or_default();
// Update tracking state
last_message_time = now;
was_inside = is_inside;
last_position = (relative_position.x, relative_position.y);
}
}
}
// Prevent excessive CPU usage
tokio::time::sleep(tokio::time::Duration::from_millis(8)).await;
}
println!("Tracking instance stopped");
});
Ok(())
}

View File

@@ -22,6 +22,7 @@ use tokio::task::block_in_place;
use tokio::time::sleep;
use tracing::{debug, error};
mod drag;
mod file;
mod menu;
mod tauri_plugins;
@@ -200,6 +201,7 @@ async fn main() -> tauri::Result<()> {
set_menu_bar_item_state,
request_fda_macos,
open_trash_in_os_explorer,
drag::start_drag,
file::open_file_paths,
file::open_ephemeral_files,
file::get_file_path_open_with_apps,
@@ -362,6 +364,7 @@ async fn main() -> tauri::Result<()> {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(updater::plugin())
.manage(updater::State::default())
.manage(drag::DragState::default())
.build(tauri::generate_context!())?
.run(|_, _| {});

View File

@@ -3,7 +3,13 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { listen } from '@tauri-apps/api/event';
import { PropsWithChildren, startTransition, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { RspcProvider, useBridgeMutation } from '@sd/client';
import {
getItemFilePath,
libraryClient,
RspcProvider,
useBridgeMutation,
useSelector
} from '@sd/client';
import {
createRoutes,
DeeplinkEvent,
@@ -16,13 +22,15 @@ import {
} from '@sd/interface';
import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle';
import '@sd/ui/style/style.scss';
import '@sd/ui/style';
import { Channel, invoke } from '@tauri-apps/api/core';
import SuperTokens from 'supertokens-web-js';
import EmailPassword from 'supertokens-web-js/recipe/emailpassword';
import Passwordless from 'supertokens-web-js/recipe/passwordless';
import Session from 'supertokens-web-js/recipe/session';
import ThirdParty from 'supertokens-web-js/recipe/thirdparty';
import { explorerStore } from '@sd/interface/app/$libraryId/Explorer/store';
// TODO: Bring this back once upstream is fixed up.
// const client = hooks.createClient({
// links: [
@@ -37,6 +45,7 @@ import getWindowHandler from '@sd/interface/app/$libraryId/settings/client/accou
import { useLocale } from '@sd/interface/hooks';
import { AUTH_SERVER_URL, getTokens } from '@sd/interface/util';
import { Transparent } from '../../../packages/assets/images';
import { commands } from './commands';
import { platform } from './platform';
import { queryClient } from './query';
@@ -46,6 +55,7 @@ import { createUpdater } from './updater';
declare global {
interface Window {
enableCORSFetch: (enable: boolean) => void;
useDragAndDrop: () => void;
}
}
@@ -67,11 +77,89 @@ SuperTokens.init({
const startupError = (window as any).__SD_ERROR__ as string | undefined;
function useDragAndDrop() {
const dragState = useSelector(explorerStore, (s) => s.drag);
useEffect(() => {
console.log('Drag effect triggered:', {
dragStateType: dragState?.type,
itemCount: dragState?.type === 'dragging' ? dragState?.items?.length : undefined
});
(async () => {
if (dragState?.type === 'dragging' && dragState.items.length > 0) {
console.log('Starting drag operation with items:', dragState.items);
const items = await Promise.all(
dragState.items.map(async (item) => {
const data = getItemFilePath(item);
if (!data) {
console.log('No file path data for item:', item);
return;
}
const file_path =
'path' in data ? data.path : await libraryClient.query(['files.getPath', data.id]);
console.log('Resolved file path:', file_path);
return {
type: 'explorer-item',
file_path: file_path
};
})
);
const image = Transparent.split('/@fs')[1]!;
console.log('Using preview image:', image);
const validFiles = items.filter(Boolean).map((item) => item?.file_path);
console.log('Invoking start_drag with files:', validFiles);
try {
const channel = new Channel<{
result: 'Dropped' | 'Cancelled';
cursorPos: { x: number; y: number };
}>();
channel.onmessage = (payload) => {
console.log('Drag completed:', {
result: payload.result,
position: payload.cursorPos,
timestamp: new Date().toISOString()
});
if (payload.result === 'Dropped') {
console.log('Drop location:', {
x: payload.cursorPos.x,
y: payload.cursorPos.y,
screen: window.screen
});
}
explorerStore.drag = null;
};
await invoke('start_drag', {
files: validFiles,
iconPath: image,
onEvent: channel
});
console.log('start_drag invoked successfully');
} catch (error) {
console.error('Failed to start drag:', error);
explorerStore.drag = null;
}
}
})();
}, [dragState]);
}
export default function App() {
useEffect(() => {
// This tells Tauri to show the current window because it's finished loading
commands.appReady();
window.enableCORSFetch(true);
window.useDragAndDrop = useDragAndDrop;
// .then(() => {
// if (import.meta.env.PROD) window.fetch = fetch;
// });
@@ -126,13 +214,15 @@ type RedirectPath = { pathname: string; search: string | undefined };
function AppInner() {
const [tabs, setTabs] = useState(() => [createTab()]);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const tokens = getTokens();
const cloudBootstrap = useBridgeMutation('cloud.bootstrap');
useEffect(() => {
// If the access token and/or refresh token are missing, we need to skip the cloud bootstrap
if (tokens.accessToken.length === 0 || tokens.refreshToken.length === 0) return;
cloudBootstrap.mutate([tokens.accessToken, tokens.refreshToken]);
(async () => {
const tokens = await getTokens();
// If the access token and/or refresh token are missing, we need to skip the cloud bootstrap
if (tokens.accessToken.length === 0 || tokens.refreshToken.length === 0) return;
cloudBootstrap.mutate([tokens.accessToken, tokens.refreshToken]);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -204,6 +294,43 @@ function AppInner() {
};
}, [selectedTab.element]);
const SizeDisplay = () => {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div
style={{
position: 'fixed',
bottom: 10,
right: 10,
background: 'rgba(0,0,0,0.7)',
color: 'white',
padding: '5px 10px',
borderRadius: '5px',
fontSize: '12px',
zIndex: 9999
}}
>
{size.width} x {size.height}
</div>
);
};
return (
<RouteTitleContext.Provider
value={useMemo(
@@ -252,8 +379,7 @@ function AppInner() {
new Promise((res) => {
startTransition(() => {
setTabs((tabs) => {
const { pathname, search } =
selectedTab.router.state.location;
const { pathname, search } = selectedTab.router.state.location;
const newTab = createTab({ pathname, search });
const newTabs = [...tabs, newTab];
@@ -303,6 +429,7 @@ function AppInner() {
tab.element
)
)}
{/* <SizeDisplay /> */}
<div ref={ref} />
</SpacedriveInterfaceRoot>
</PlatformUpdaterProvider>

View File

@@ -49,6 +49,31 @@ export const commands = {
else return { status: 'error', error: e as any };
}
},
/**
* Initiates a drag and drop operation with cursor position tracking
*
* # Arguments
* * `window` - The Tauri window instance
* * `_state` - Current drag state (unused)
* * `files` - Vector of file paths to be dragged
* * `icon_path` - Path to the preview icon for the drag operation
* * `on_event` - Channel for communicating drag operation events back to the frontend
*/
async startDrag(
files: string[],
iconPath: string,
onEvent: TAURI_CHANNEL<CallbackResult>
): Promise<Result<null, string>> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('start_drag', { files, iconPath, onEvent })
};
} catch (e) {
if (e instanceof Error) throw e;
else return { status: 'error', error: e as any };
}
},
async openFilePaths(
library: string,
ids: number[]
@@ -162,6 +187,7 @@ export const events = __makeEvents__<{
/** user-defined types **/
export type AppThemeType = 'Auto' | 'Light' | 'Dark';
export type CallbackResult = { result: WrappedDragResult; cursorPos: WrappedCursorPosition };
export type DragAndDropEvent =
| { type: 'Hovered'; paths: string[]; x: number; y: number }
| { type: 'Dropped'; paths: string[]; x: number; y: number }
@@ -199,6 +225,8 @@ export type RevealItem =
| { FilePath: { id: number } }
| { Ephemeral: { path: string } };
export type Update = { version: string };
export type WrappedCursorPosition = { x: number; y: number };
export type WrappedDragResult = 'Dropped' | 'Cancel';
type __EventObj__<T> = {
listen: (cb: TAURI_API_EVENT.EventCallback<T>) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;

View File

@@ -1,6 +1,6 @@
import type { Preview } from '@storybook/react';
import '@sd/ui/style/style.scss';
import '@sd/ui/style';
const preview: Preview = {
parameters: {

View File

@@ -39,15 +39,15 @@ zeroize = { workspace = true }
# External dependencies
anyhow = "1.0.86"
dashmap = "6.1.0"
iroh-net = { version = "0.28.1", features = ["discovery-local-network", "iroh-relay"] }
iroh = { version = "0.29.0", features = ["discovery-local-network"] }
paste = "=1.0.15"
quic-rpc = { version = "0.15.1", features = ["iroh-net-transport", "quinn-transport"] }
quic-rpc = { version = "0.17.1", features = ["iroh-transport", "quinn-transport"] }
quinn = { package = "iroh-quinn", version = "0.12" }
# Using whatever version of reqwest that reqwest-middleware uses, just putting here to enable some features
reqwest = { version = "0.12", features = ["json", "native-tls-vendored", "stream"] }
reqwest-middleware = { version = "0.4", features = ["json"] }
reqwest-retry = "0.7"
rustls = { version = "=0.23.16", default-features = false, features = ["brotli", "ring", "std"] }
rustls = { version = "=0.23.19", default-features = false, features = ["brotli", "ring", "std"] }
rustls-platform-verifier = "0.4.0"

View File

@@ -5,7 +5,7 @@ use sd_cloud_schema::{Client, Request, Response, ServicesALPN};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use futures::Stream;
use iroh_net::relay::RelayUrl;
use iroh::relay::RelayUrl;
use quic_rpc::{transport::quinn::QuinnConnector, RpcClient, RpcMessage};
use quinn::{crypto::rustls::QuicClientConfig, ClientConfig, Endpoint};
use reqwest::{IntoUrl, Url};

View File

@@ -11,13 +11,12 @@ use sd_crypto::{CryptoRng, SeedableRng};
use std::{sync::Arc, time::Duration};
use iroh_net::{
use iroh::{
discovery::{
dns::DnsDiscovery, local_swarm_discovery::LocalSwarmDiscovery, pkarr::dht::DhtDiscovery,
ConcurrentDiscovery, Discovery,
},
relay::{RelayMap, RelayMode, RelayUrl},
Endpoint, NodeId,
Endpoint, NodeId, RelayMap, RelayMode, RelayUrl,
};
use reqwest::Url;
use serde::{Deserialize, Serialize};

View File

@@ -9,7 +9,7 @@ use sd_cloud_schema::{
use std::time::Duration;
use futures_concurrency::future::Join;
use iroh_net::{Endpoint, NodeId};
use iroh::{Endpoint, NodeId};
use quic_rpc::{transport::quinn::QuinnConnector, RpcClient};
use tokio::time::Instant;
use tracing::{debug, error, instrument, warn};

View File

@@ -27,7 +27,7 @@ use dashmap::DashMap;
use flume::SendError;
use futures::StreamExt;
use futures_concurrency::stream::Merge;
use iroh_net::{Endpoint, NodeId};
use iroh::{Endpoint, NodeId};
use quic_rpc::{
server::{Accepting, RpcChannel, RpcServerError},
transport::quinn::{QuinnConnector, QuinnListener},

346
core/src/api/keys.rs Normal file
View File

@@ -0,0 +1,346 @@
use super::utils::library;
use super::{Ctx, SanitizedNodeConfig, R};
use rspc::{alpha::AlphaRouter, ErrorCode};
use sd_crypto::cookie::CookieCipher;
use serde_json::{json, Map, Value};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::sync::RwLock;
use tracing::{debug, error};
#[derive(Clone)]
struct CipherCache {
uuid: String,
cipher: CookieCipher,
}
async fn get_cipher(
node: &Ctx,
cache: Arc<RwLock<Option<CipherCache>>>,
) -> Result<CookieCipher, rspc::Error> {
let config = SanitizedNodeConfig::from(node.config.get().await);
let uuid = config.id.to_string();
{
let cache_read = cache.read().await;
if let Some(ref cache) = *cache_read {
if cache.uuid == uuid {
return Ok(cache.cipher.clone());
}
}
}
let uuid_key = CookieCipher::generate_key_from_string(&uuid).map_err(|e| {
error!("Failed to generate key: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to generate key".to_string(),
)
})?;
let cipher = CookieCipher::new(&uuid_key).map_err(|e| {
error!("Failed to create cipher: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to create cipher".to_string(),
)
})?;
{
let mut cache_write = cache.write().await;
*cache_write = Some(CipherCache {
uuid,
cipher: cipher.clone(),
});
}
Ok(cipher)
}
async fn read_file(path: &Path) -> Result<Vec<u8>, rspc::Error> {
tokio::fs::read(path).await.map_err(|e| {
error!("Failed to read file: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to read file {:?}", path),
)
})
}
async fn write_file(path: &Path, data: &[u8]) -> Result<(), rspc::Error> {
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.await
.map_err(|e| {
error!("Failed to open file: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to open file {:?}", path),
)
})?;
file.write_all(data).await.map_err(|e| {
error!("Failed to write to file: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to write to file {:?}", path),
)
})
}
fn sanitize_path(base_dir: &Path, path: &Path) -> Result<PathBuf, rspc::Error> {
let abs_base = base_dir.canonicalize().map_err(|e| {
error!("Failed to canonicalize base directory: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to canonicalize base directory".to_string(),
)
})?;
let abs_path = abs_base.join(path).canonicalize().map_err(|e| {
error!("Failed to canonicalize path: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to canonicalize path".to_string(),
)
})?;
if abs_path.starts_with(&abs_base) {
Ok(abs_path)
} else {
error!("Path injection attempt detected: {:?}", abs_path);
Err(rspc::Error::new(
ErrorCode::InternalServerError,
"Invalid path".to_string(),
))
}
}
pub(crate) fn mount() -> AlphaRouter<Ctx> {
let cipher_cache = Arc::new(RwLock::new(None));
R.router()
.procedure("get", {
let cipher_cache = cipher_cache.clone();
R.query(move |node, _: ()| {
let cipher_cache = cipher_cache.clone();
async move {
let base_dir = node.config.data_directory();
let path = sanitize_path(&base_dir, Path::new(".sdks"))?;
let data = read_file(&path).await?;
let cipher = get_cipher(&node, cipher_cache).await?;
let data_str = String::from_utf8(data).map_err(|e| {
error!("Failed to convert data to string: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to convert data to string".to_string(),
)
})?;
let data = CookieCipher::base64_decode(&data_str).map_err(|e| {
error!("Failed to decode data: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to decode data".to_string(),
)
})?;
let de_data = cipher.decrypt(&data).map_err(|e| {
error!("Failed to decrypt data: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to decrypt data".to_string(),
)
})?;
let de_data = String::from_utf8(de_data).map_err(|e| {
error!("Failed to convert data to string: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to convert data to string".to_string(),
)
})?;
Ok(de_data)
}
})
})
.procedure("save", {
let cipher_cache = cipher_cache.clone();
R.mutation(move |node, args: String| {
let cipher_cache = cipher_cache.clone();
async move {
let cipher = get_cipher(&node, cipher_cache).await?;
let en_data = cipher.encrypt(args.as_bytes()).map_err(|e| {
error!("Failed to encrypt data: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to encrypt data".to_string(),
)
})?;
let en_data = CookieCipher::base64_encode(&en_data);
let base_dir = node.config.data_directory();
let path = sanitize_path(&base_dir, Path::new(".sdks"))?;
write_file(&path, en_data.as_bytes()).await?;
debug!("Saved data to {:?}", path);
Ok(())
}
})
})
.procedure("saveEmailAddress", {
R.with2(library())
.mutation(move |(node, library), args: String| async move {
let path = node
.libraries
.libraries_dir
.join(format!("{}.sdlibrary", library.id));
let mut config = serde_json::from_slice::<Map<String, Value>>(
&tokio::fs::read(path.clone()).await.map_err(|e| {
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to read library config: {:?}", e.to_string()),
)
})?,
)
.map_err(|e: serde_json::Error| {
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to parse library config: {:?}", e.to_string()),
)
})?;
// Encrypt the email address
// Create new cipher with the library id as the key
let uuid_key =
CookieCipher::generate_key_from_string(library.id.to_string().as_str())
.map_err(|e| {
error!("Failed to generate key: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to generate key".to_string(),
)
})?;
let cipher = CookieCipher::new(&uuid_key).map_err(|e| {
error!("Failed to create cipher: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to create cipher".to_string(),
)
})?;
let en_data = cipher.encrypt(args.as_bytes()).map_err(|e| {
error!("Failed to encrypt data: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to encrypt data".to_string(),
)
})?;
let en_data = CookieCipher::base64_encode(&en_data);
config.remove("cloud_email_address");
config.insert("cloud_email_address".to_string(), json!(en_data));
tokio::fs::write(
path,
serde_json::to_vec(&config).map_err(|e| {
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to serialize library config: {:?}", e.to_string()),
)
})?,
)
.await
.map_err(|e| {
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to write library config: {:?}", e.to_string()),
)
})?;
Ok(())
})
})
.procedure("getEmailAddress", {
R.with2(library())
.query(move |(node, library), _: ()| async move {
let path = node
.libraries
.libraries_dir
.join(format!("{}.sdlibrary", library.id));
let config = serde_json::from_slice::<Map<String, Value>>(
&tokio::fs::read(path.clone()).await.map_err(|e| {
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to read library config: {:?}", e.to_string()),
)
})?,
)
.map_err(|e: serde_json::Error| {
rspc::Error::new(
ErrorCode::InternalServerError,
format!("Failed to parse library config: {:?}", e.to_string()),
)
})?;
let en_data = config.get("cloud_email_address").ok_or_else(|| {
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to get cloud_email_address".to_string(),
)
})?;
let en_data = en_data.as_str().ok_or_else(|| {
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to get cloud_email_address".to_string(),
)
})?;
let en_data = CookieCipher::base64_decode(en_data).map_err(|e| {
error!("Failed to decode data: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to decode data".to_string(),
)
})?;
let uuid_key =
CookieCipher::generate_key_from_string(library.id.to_string().as_str())
.map_err(|e| {
error!("Failed to generate key: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to generate key".to_string(),
)
})?;
let cipher = CookieCipher::new(&uuid_key).map_err(|e| {
error!("Failed to create cipher: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to create cipher".to_string(),
)
})?;
let de_data = cipher.decrypt(&en_data).map_err(|e| {
error!("Failed to decrypt data: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to decrypt data".to_string(),
)
})?;
let de_data = String::from_utf8(de_data).map_err(|e| {
error!("Failed to convert data to string: {:?}", e.to_string());
rspc::Error::new(
ErrorCode::InternalServerError,
"Failed to convert data to string".to_string(),
)
})?;
Ok(de_data)
})
})
}

View File

@@ -29,6 +29,7 @@ mod cloud;
mod ephemeral_files;
mod files;
mod jobs;
mod keys;
mod labels;
mod libraries;
pub mod locations;
@@ -210,6 +211,7 @@ pub(crate) fn mount() -> Arc<Router> {
.merge("preferences.", preferences::mount())
.merge("notifications.", notifications::mount())
.merge("backups.", backups::mount())
.merge("keys.", keys::mount())
.merge("invalidation.", utils::mount_invalidate())
.sd_patch_types_dangerously(|type_map| {
let def =

View File

@@ -46,6 +46,8 @@ pub struct LibraryConfig {
#[serde(skip, default)]
pub config_path: PathBuf,
/// cloud_email_address is the email address of the user who owns the cloud library this library is linked to.
pub cloud_email_address: Option<String>,
}
#[derive(
@@ -74,10 +76,11 @@ pub enum LibraryConfigVersion {
V9 = 9,
V10 = 10,
V11 = 11,
V12 = 12,
}
impl ManagedVersion<LibraryConfigVersion> for LibraryConfig {
const LATEST_VERSION: LibraryConfigVersion = LibraryConfigVersion::V11;
const LATEST_VERSION: LibraryConfigVersion = LibraryConfigVersion::V12;
const KIND: Kind = Kind::Json("version");
@@ -99,6 +102,7 @@ impl LibraryConfig {
cloud_id: None,
generate_sync_operations: Arc::new(AtomicBool::new(false)),
config_path: path.as_ref().to_path_buf(),
cloud_email_address: None,
};
this.save(path).await.map(|()| this)
@@ -396,6 +400,25 @@ impl LibraryConfig {
.await?;
}
(LibraryConfigVersion::V11, LibraryConfigVersion::V12) => {
// Add the `cloud_email_address` field to the library config.
let mut config = serde_json::from_slice::<Map<String, Value>>(
&fs::read(path).await.map_err(|e| {
VersionManagerError::FileIO(FileIOError::from((path, e)))
})?,
)
.map_err(VersionManagerError::SerdeJson)?;
config.insert(String::from("cloud_email_address"), Value::Null);
fs::write(
path,
&serde_json::to_vec(&config).map_err(VersionManagerError::SerdeJson)?,
)
.await
.map_err(|e| VersionManagerError::FileIO(FileIOError::from((path, e))))?;
}
_ => {
error!(current_version = ?current, "Library config version is not handled;");

View File

@@ -401,13 +401,46 @@ impl NodeConfig {
config.remove("sd_api_origin");
config.remove("image_labeler_version");
config.remove("id");
config.insert(
String::from("id"),
serde_json::to_value(DevicePubId::from(Uuid::now_v7()))
.map_err(VersionManagerError::SerdeJson)?,
);
// Verify that the ID isn't already set to a UUID v7. If it is, we don't want to overwrite it.
// Get the current ID, if it's a string, parse it as a UUID and check if it's a UUID v7.
// If it's not a UUID v7, set it to a UUID v7.
let id = config
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
if let Some(id) = id {
if id.get_version() != Some(uuid::Version::Md5) {
config.remove("id");
config.insert(
String::from("id"),
serde_json::to_value(DevicePubId::from(Uuid::now_v7()))
.map_err(VersionManagerError::SerdeJson)?,
);
}
}
// config.remove("id");
// config.insert(
// String::from("id"),
// serde_json::to_value(DevicePubId::from(Uuid::now_v7()))
// .map_err(VersionManagerError::SerdeJson)?,
// );
// Create a .sdks file in the data directory if it doesn't exist
let data_directory = path
.parent()
.expect("Config path must have a parent directory");
let sdks_file = data_directory.join(".sdks");
if !sdks_file.exists() {
fs::write(&sdks_file, b"").await.map_err(|e| {
FileIOError::from((
sdks_file.clone(),
e,
"Failed to create .sdks file",
))
})?;
}
// Write the updated config back to disk
fs::write(
path,
serde_json::to_vec(&config).map_err(VersionManagerError::SerdeJson)?,

32
core/src/search/mod.rs Normal file
View File

@@ -0,0 +1,32 @@
pub enum SpacedrivePath {
Location(u64, PathBuf),
Virtual(PathBuf),
NonIndexed(PathBuf),
}
pub struct ExplorerItem {
pub id: u64,
pub pub_id: Bytes,
pub inode: Option<u64>,
// the unique Object for this item
pub object_id: Option<u64>,
// the path of this item in Spacedrive
pub path: SpacedrivePath,
// metadata about this item
pub name: String,
pub extension: Option<String>,
pub kind: ObjectKind,
pub size: Option<u64>,
pub date_created: Option<DateTime<Utc>>,
pub date_modified: Option<DateTime<Utc>>,
pub date_indexed: Option<DateTime<Utc>>,
pub is_dir: bool,
pub is_hidden: bool,
pub key_id: Option<u64>,
// computed properties
pub thumbnail: Option<ThumbKey>,
pub has_created_thumbnail: bool,
pub duplicate_paths: Vec<SpacedrivePath>,
}

View File

@@ -122,7 +122,7 @@ impl Volumes {
.await
.map_err(|_| VolumeError::Cancelled)?;
rx.await.map_err(|_| VolumeError::Cancelled)?;
let _ = rx.await.map_err(|_| VolumeError::Cancelled)?;
Ok(())
}

View File

@@ -2,7 +2,11 @@
name = "sd-crypto"
version = "0.0.1"
authors = ["Ericson Soares <ericson@spacedrive.com>", "Jake Robinson <jake@spacedrive.com>"]
authors = [
"Arnab Chakraborty <arnab@spacedrive.com>",
"Ericson Soares <ericson@spacedrive.com>",
"Jake Robinson <jake@spacedrive.com>"
]
description = """
A cryptographic library that provides safe and high-level
encryption, hashing, and encoding interfaces.
@@ -18,12 +22,15 @@ rust-version.workspace = true
[dependencies]
# Workspace dependencies
async-stream = { workspace = true }
base64 = { workspace = true }
blake3 = { workspace = true }
futures = { workspace = true }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "sync"] }
tracing = { workspace = true }
tracing-test = { workspace = true }
zeroize = { workspace = true, features = ["derive"] }
# External dependencies

306
crates/crypto/src/cookie.rs Normal file
View File

@@ -0,0 +1,306 @@
//! Encryption and decryption functionality for cookie strings using the `ChaCha20Poly1305` AEAD cipher.
//!
//! This module provides a secure way to encrypt and decrypt cookie data using the
//! `ChaCha20Poly1305` authenticated encryption algorithm. It includes functionality for:
//! - Key generation from UUIDs
//! - Encryption with random nonces
//! - Decryption with authentication
//! - Base64 encoding/decoding utilities
use base64::Engine;
use blake3;
use chacha20poly1305::{
aead::{Aead, AeadCore, KeyInit},
ChaCha20Poly1305, Key,
};
use std::convert::TryFrom;
use tracing::{debug, error};
/// Main struct for handling encryption and decryption operations.
/// Contains an initialized `ChaCha20Poly1305` cipher instance.
#[derive(Clone)]
pub struct CookieCipher {
cipher: ChaCha20Poly1305,
}
/// Possible errors that can occur during cryptographic operations.
#[derive(Debug, thiserror::Error)]
pub enum CryptoCookieError {
/// Errors that occur during encryption operations
#[error("Encryption failed: {0}")]
Encryption(String),
/// Errors that occur during decryption operations
#[error("Decryption failed: {0}")]
Decryption(String),
/// Errors that occur during key creation/initialization
#[error("Key creation failed: {0}")]
KeyCreation(String),
}
impl CookieCipher {
/// Creates a new `CookieCipher` instance with the provided 32-byte key.
///
/// # Arguments
/// * `key` - A 32-byte array used as the encryption/decryption key
///
/// # Returns
/// * `Result<Self, CryptoCookieError>` - A new `CookieCipher` instance or an error
pub fn new(key: &[u8; 32]) -> Result<Self, CryptoCookieError> {
debug!("Initializing CookieCipher with provided key");
let key = Key::try_from(key.as_slice()).map_err(|e| {
error!("Failed to create key: {}", e);
CryptoCookieError::KeyCreation(e.to_string())
})?;
let cipher = ChaCha20Poly1305::new(&key);
debug!("CookieCipher initialized successfully");
Ok(Self { cipher })
}
/// Encrypts the provided data using `ChaCha20Poly1305`.
///
/// # Arguments
/// * `data` - The data to encrypt
///
/// # Returns
/// * `Result<Vec<u8>, CryptoCookieError>` - The encrypted data with prepended nonce, or an error
pub fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, CryptoCookieError> {
debug!("Starting encryption of {} bytes", data.len());
let nonce = ChaCha20Poly1305::generate_nonce_with_rng(&mut aead::OsRng).map_err(|e| {
error!("Nonce generation failed: {}", e);
CryptoCookieError::Encryption(e.to_string())
})?;
debug!("Generated new nonce for encryption");
let ciphertext = self.cipher.encrypt(&nonce, data).map_err(|e| {
error!("Encryption failed: {}", e);
CryptoCookieError::Encryption(e.to_string())
})?;
let mut combined = nonce.to_vec();
combined.extend(ciphertext);
debug!("Successfully encrypted data to {} bytes", combined.len());
Ok(combined)
}
/// Validates that the encrypted data meets the minimum length requirement.
///
/// # Arguments
/// * `data` - The encrypted data to validate
///
/// # Returns
/// * `Result<(), CryptoCookieError>` - Ok if valid, Error if too short
fn validate_data_length(data: &[u8]) -> Result<(), CryptoCookieError> {
if data.len() < 12 {
error!("Encrypted data too short: {} bytes", data.len());
return Err(CryptoCookieError::Decryption("Data too short".into()));
}
Ok(())
}
/// Extracts and validates the nonce from the encrypted data.
///
/// # Arguments
/// * `nonce_bytes` - The bytes containing the nonce
///
/// # Returns
/// * `Result<chacha20poly1305::Nonce, CryptoCookieError>` - The extracted nonce or an error
fn extract_nonce(nonce_bytes: &[u8]) -> Result<chacha20poly1305::Nonce, CryptoCookieError> {
chacha20poly1305::Nonce::try_from(nonce_bytes).map_err(|e| {
error!("Failed to create nonce: {}", e);
CryptoCookieError::Decryption(e.to_string())
})
}
/// Performs the actual decryption operation.
///
/// # Arguments
/// * `nonce` - The nonce to use for decryption
/// * `ciphertext` - The encrypted data to decrypt
///
/// # Returns
/// * `Result<Vec<u8>, CryptoCookieError>` - The decrypted data or an error
fn perform_decryption(
&self,
nonce: &chacha20poly1305::Nonce,
ciphertext: &[u8],
) -> Result<Vec<u8>, CryptoCookieError> {
self.cipher.decrypt(nonce, ciphertext).map_err(|e| {
error!("Decryption failed: {}", e);
CryptoCookieError::Decryption(e.to_string())
})
}
/// Decrypts the provided encrypted data.
///
/// # Arguments
/// * `encrypted_data` - The data to decrypt (including nonce)
///
/// # Returns
/// * `Result<Vec<u8>, CryptoCookieError>` - The decrypted data or an error
pub fn decrypt(&self, encrypted_data: &[u8]) -> Result<Vec<u8>, CryptoCookieError> {
debug!("Starting decryption of {} bytes", encrypted_data.len());
Self::validate_data_length(encrypted_data)?;
let (nonce_bytes, ciphertext) = encrypted_data.split_at(12);
let nonce = Self::extract_nonce(nonce_bytes)?;
debug!("Extracted nonce and ciphertext for decryption");
let plaintext = self.perform_decryption(&nonce, ciphertext)?;
debug!("Successfully decrypted data to {} bytes", plaintext.len());
Ok(plaintext)
}
/// Generates a 32-byte key from a string input using BLAKE3 hashing.
///
/// # Arguments
/// * `string` - The input string (typically a UUID) to generate the key from
///
/// # Returns
/// * `Result<[u8; 32], CryptoCookieError>` - A 32-byte key or an error
pub fn generate_key_from_string(string: &str) -> Result<[u8; 32], CryptoCookieError> {
debug!("Generating key from string: {}", string);
if string.is_empty() {
error!("Input string is empty");
return Err(CryptoCookieError::KeyCreation(
"Input string is empty".into(),
));
}
// Hash the input string to get a fixed-size output
let hash = blake3::hash(string.as_bytes());
// Convert the hash bytes directly to an array
let key_array: [u8; 32] = *hash.as_bytes();
debug!("Key generated successfully");
Ok(key_array)
}
/// Encodes binary data to base64 string.
///
/// # Arguments
/// * `data` - The binary data to encode
///
/// # Returns
/// * `String` - The base64 encoded string
#[must_use]
pub fn base64_encode(data: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(data)
}
/// Decodes base64 string to binary data.
///
/// # Arguments
/// * `data` - The base64 string to decode
///
/// # Returns
/// * `Result<Vec<u8>, base64::DecodeError>` - The decoded binary data or an error
pub fn base64_decode(data: &str) -> Result<Vec<u8>, base64::DecodeError> {
base64::engine::general_purpose::STANDARD.decode(data)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_generation() {
let key = CookieCipher::generate_key_from_string("0193b34e-0ad9-70e0-a3dd-8ec30b73a90a")
.expect("Failed to generate key");
assert_eq!(key.len(), 32);
}
#[test]
fn test_encryption_decryption() {
let key = CookieCipher::generate_key_from_string("0193b34e-0ad9-70e0-a3dd-8ec30b73a90a")
.expect("Failed to generate key");
let cipher = CookieCipher::new(&key).expect("Failed to create cipher");
let data = b"Hello, world!";
let encrypted = cipher.encrypt(data).expect("Failed to encrypt data");
let decrypted = cipher.decrypt(&encrypted).expect("Failed to decrypt data");
assert_eq!(data, decrypted.as_slice());
}
#[test]
fn test_base64_encoding() {
let data = b"Hello, world!";
let encoded = CookieCipher::base64_encode(data);
let decoded = CookieCipher::base64_decode(&encoded).expect("Failed to decode base64");
assert_eq!(data, decoded.as_slice());
}
#[test]
fn test_invalid_data_length() {
let key = CookieCipher::generate_key_from_string("0193b34e-0ad9-70e0-a3dd-8ec30b73a90a")
.expect("Failed to generate key");
let cipher = CookieCipher::new(&key).expect("Failed to create cipher");
let encrypted = vec![0; 10];
let result = cipher.decrypt(&encrypted);
assert!(result.is_err());
}
#[test]
fn test_invalid_nonce() {
let key = CookieCipher::generate_key_from_string("0193b34e-0ad9-70e0-a3dd-8ec30b73a90a")
.expect("Failed to generate key");
let cipher = CookieCipher::new(&key).expect("Failed to create cipher");
let encrypted = vec![0; 12];
let result = cipher.decrypt(&encrypted);
assert!(result.is_err());
}
#[test]
fn test_invalid_decryption() {
let key = CookieCipher::generate_key_from_string("0193b34e-0ad9-70e0-a3dd-8ec30b73a90a")
.expect("Failed to generate key");
let cipher = CookieCipher::new(&key).expect("Failed to create cipher");
let encrypted = vec![0; 24];
let result = cipher.decrypt(&encrypted);
assert!(result.is_err());
}
#[test]
fn test_invalid_base64() {
let result = CookieCipher::base64_decode("invalid_base64");
assert!(result.is_err());
}
#[test]
fn test_invalid_key_generation() {
let result = CookieCipher::generate_key_from_string("");
assert!(result.is_err());
}
#[test]
fn test_invalid_decryption_operation() {
let key = CookieCipher::generate_key_from_string("0193b34e-0ad9-70e0-a3dd-8ec30b73a90a")
.expect("Failed to generate key");
let cipher = CookieCipher::new(&key).expect("Failed to create cipher");
let nonce = chacha20poly1305::Nonce::default();
let ciphertext = vec![0; 1024];
let result = cipher.perform_decryption(&nonce, &ciphertext);
assert!(result.is_err());
}
}

View File

@@ -41,3 +41,5 @@ pub use protected::Protected;
pub use rng::CryptoRng;
pub use rand_core::{RngCore, SeedableRng};
pub mod cookie;

View File

@@ -232,16 +232,16 @@ fn thumb_scale_filter_args(
// if the pixel aspect ratio is defined and is not 1, we have an anamorphic stream
if pixel_aspect_ratio.num != 0 && pixel_aspect_ratio.num != pixel_aspect_ratio.den {
match std::panic::catch_unwind(|| {
width
.checked_mul(pixel_aspect_ratio.num.unsigned_abs())
.and_then(|v| v.checked_div(pixel_aspect_ratio.den.unsigned_abs()))
}) {
Ok(Some(w)) => width = w,
Ok(None) | Err(_) => {
eprintln!("Warning: Failed to calculate width with pixel aspect ratio");
// Keep the original width as fallback
}
};
width
.checked_mul(pixel_aspect_ratio.num.unsigned_abs())
.and_then(|v| v.checked_div(pixel_aspect_ratio.den.unsigned_abs()))
}) {
Ok(Some(w)) => width = w,
Ok(None) | Err(_) => {
eprintln!("Warning: Failed to calculate width with pixel aspect ratio");
// Keep the original width as fallback
}
};
if size != 0 {
if height > width {
width = (width * size) / height;

View File

@@ -0,0 +1,11 @@
# v2.1.0
- Fix: Exclude Tauri IPC requests from the request hook.
# v2.0.0
- New: Hook `fetch` requests and redirect them to [tauri-plugin-http](https://crates.io/crates/tauri-plugin-http).
# v1.0.0
- New: Hook `fetch` requests and redirect them to `x-http` and `x-https` custom protocols.

View File

@@ -0,0 +1,33 @@
[package]
authors = ["Arnab Chakraborty <arnab@spacedrive.com>", "Del Wang <hello@xbox.work>"]
description = "Enabling Cross-Origin Resource Sharing (CORS) for Fetch Requests within Tauri applications. Modified to work with Spacedrive and Supertokens for authentication."
documentation = "https://docs.rs/crate/tauri-plugin-cors-fetch"
edition = "2021"
keywords = ["CORS", "fetch", "tauri-plugin", "unofficial"]
license = "MIT"
links = "tauri-plugin-cors-fetch"
name = "tauri-plugin-cors-fetch"
readme = "README.md"
repository = "https://github.com/idootop/tauri-plugin-cors-fetch"
rust-version = "1.70"
version = "2.1.1-sd-custom"
[dependencies]
http = "0.2"
once_cell = "1.19.0"
reqwest = "0.11"
sd-crypto = { path = "../crypto" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = "2.0.0-beta.11"
thiserror = "1"
tokio = { version = "1.36.0", features = ["macros"] }
tracing = { workspace = true }
url = "2"
[build-dependencies]
tauri-plugin = { version = "2.0.0-beta.9", features = ["build"] }
[package.metadata.docs.rs]
rustc-args = ["--cfg", "docsrs"]
rustdoc-args = ["--cfg", "docsrs"]

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Del.Wang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,88 @@
![tauri-plugin-cors-fetch](https://github.com/idootop/tauri-plugin-cors-fetch/raw/main/banner.png)
[![crates.io](https://img.shields.io/crates/v/tauri-plugin-cors-fetch.svg)](https://crates.io/crates/tauri-plugin-cors-fetch)
[![Documentation](https://docs.rs/tauri-plugin-cors-fetch/badge.svg)](https://docs.rs/crate/tauri-plugin-cors-fetch)
[![MIT licensed](https://img.shields.io/crates/l/tauri-plugin-cors-fetch.svg)](./LICENSE)
An **unofficial** Tauri plugin that enables seamless cross-origin resource sharing (CORS) for web fetch requests within Tauri applications.
## Overview
When building cross-platform desktop applications with [Tauri](https://tauri.app), we often need to access services like [OpenAI](https://openai.com/product) that are restricted by **Cross-Origin Resource Sharing (CORS)** policies in web environments.
However, on the desktop, we can bypass CORS and access these services directly. While the official [tauri-plugin-http](https://crates.io/crates/tauri-plugin-http) can bypass CORS, it requires modifying your network requests and might not be compatible with third-party dependencies that rely on the standard `fetch` API.
## How it Works
This plugin extends the official [tauri-plugin-http](https://crates.io/crates/tauri-plugin-http) by hooking into the browser's native `fetch` method during webpage initialization. It transparently redirects requests to the [tauri-plugin-http](https://crates.io/crates/tauri-plugin-http), allowing you to use the standard `fetch` API without additional code changes or workarounds.
## Installation
1. Add the plugin to your Tauri project's dependencies:
```shell
# src-tauri
cargo add tauri-plugin-cors-fetch
```
2. Initialize the plugin in your Tauri application setup:
```rust
// src-tauri/main.rs
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_cors_fetch::init())
.run(tauri::generate_context!())
.expect("failed to run app");
}
```
3. Add permissions in your `capabilities` configuration:
```json
// src-tauri/capabilities/default.json
{
"permissions": ["cors-fetch:default"]
}
```
4. Enable `withGlobalTauri` in your Tauri configuration:
```json
// src-tauri/tauri.conf.json
{
"app": {
"withGlobalTauri": true
}
}
```
## Usage
After installing and initializing the plugin, you can start making `fetch` requests from your Tauri application without encountering CORS-related errors.
```javascript
// Enable CORS for the hooked fetch globally (default is true on app start)
window.enableCORSFetch(true);
// Use the hooked fetch with CORS support
fetch('https://example.com/api')
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error(error));
// Use the hooked fetch directly
window.hookedFetch('https://example.com/api');
// Use the original, unhooked fetch
window.originalFetch('https://example.com/api');
```
## Limitation
1. **No Custom CSP Policy Support**: By default, all HTTP/HTTPS requests will be redirected to local native requests.
2. **No XMLHttpRequest Support**: The plugin is designed specifically to work with the modern `fetch` API and does not support `XMLHttpRequest` (XHR) requests.
## License
This project is licensed under the MIT License.

View File

@@ -0,0 +1,118 @@
class CORSFetch {
_requestId = 1;
constructor() {
window.originalFetch = fetch.bind(window);
window.hookedFetch = this.hookedFetch.bind(this);
this.enableCORS(true);
}
enableCORS(enable) {
window.fetch = enable ? window.hookedFetch : window.originalFetch;
}
async hookedFetch(input, init) {
const _url = input instanceof Request ? input.url : input.toString();
const isHttpRequests = /^https?:\/\//i.test(_url);
// `ipc://localhost/${path}` and `http://ipc.localhost/${path}` are used for Tauri IPC requests
// https://github.com/tauri-apps/tauri/blob/7898b601d14ed62053dd24011fabadf31ec1af45/core/tauri/scripts/core.js#L12
const isTauriIpcRequests =
/^ipc:\/\/localhost\//i.test(_url) || /^http:\/\/ipc.localhost\//i.test(_url);
if (!isHttpRequests || isTauriIpcRequests) {
return window.originalFetch(input, init);
}
return new Promise(async (resolve, reject) => {
const requestId = this._requestId++;
const maxRedirections = init?.maxRedirections;
const connectTimeout = init?.connectTimeout;
const proxy = init?.proxy;
// Remove these fields before creating the request
if (init) {
delete init.maxRedirections;
delete init.connectTimeout;
delete init.proxy;
}
const signal = init?.signal;
const headers = !init?.headers
? []
: init.headers instanceof Headers
? Array.from(init.headers.entries())
: Array.isArray(init.headers)
? init.headers
: Object.entries(init.headers);
const mappedHeaders = headers.map(([name, val]) => [
name,
// we need to ensure we have all values as strings
typeof val === 'string' ? val : val.toString()
]);
const req = new Request(input, init);
const buffer = await req.arrayBuffer();
const reqData = buffer.byteLength ? Array.from(new Uint8Array(buffer)) : null;
signal?.addEventListener('abort', async (e) => {
const error = e.target.reason;
this._invoke('plugin:cors-fetch|cancel_cors_request', {
requestId
}).catch(() => {});
reject(error);
});
const {
status,
statusText,
url,
body,
headers: responseHeaders
} = await this._invoke('plugin:cors-fetch|cors_request', {
request: {
requestId,
method: req.method,
url: req.url,
headers: mappedHeaders,
data: reqData,
maxRedirections,
connectTimeout,
proxy
}
});
const res = new Response(
body instanceof ArrayBuffer && body.byteLength
? body
: body instanceof Array && body.length
? new Uint8Array(body)
: null,
{
headers: responseHeaders,
status,
statusText
}
);
// url is read only but seems like we can do this
Object.defineProperty(res, 'url', { value: url });
resolve(res);
});
}
_invoke(cmd, args, options) {
if ('__TAURI__' in window) {
return window.__TAURI_INTERNALS__.invoke(cmd, args, options);
}
}
}
(function () {
const cf = new CORSFetch();
window.enableCORSFetch = cf.enableCORS.bind(cf);
})();

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,7 @@
const COMMANDS: &[&str] = &["cors_request", "cancel_cors_request"];
fn main() {
tauri_plugin::Builder::new(COMMANDS)
.global_api_script_path("./api-iife.js")
.build();
}

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-cancel-cors-request"
description = "Enables the cancel_cors_request command without any pre-configured scope."
commands.allow = ["cancel_cors_request"]
[[permission]]
identifier = "deny-cancel-cors-request"
description = "Denies the cancel_cors_request command without any pre-configured scope."
commands.deny = ["cancel_cors_request"]

View File

@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-cors-request"
description = "Enables the cors_request command without any pre-configured scope."
commands.allow = ["cors_request"]
[[permission]]
identifier = "deny-cors-request"
description = "Denies the cors_request command without any pre-configured scope."
commands.deny = ["cors_request"]

View File

@@ -0,0 +1,68 @@
## Default Permission
Allows all fetch operations
- `allow-cancel-cors-request`
- `allow-cors-request`
## Permission Table
<table>
<tr>
<th>Identifier</th>
<th>Description</th>
</tr>
<tr>
<td>
`cors-fetch:allow-cancel-cors-request`
</td>
<td>
Enables the cancel_cors_request command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`cors-fetch:deny-cancel-cors-request`
</td>
<td>
Denies the cancel_cors_request command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`cors-fetch:allow-cors-request`
</td>
<td>
Enables the cors_request command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`cors-fetch:deny-cors-request`
</td>
<td>
Denies the cors_request command without any pre-configured scope.
</td>
</tr>
</table>

View File

@@ -0,0 +1,4 @@
"$schema" = "schemas/schema.json"
[default]
description = "Allows all fetch operations"
permissions = ["allow-cancel-cors-request", "allow-cors-request"]

View File

@@ -0,0 +1,325 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PermissionFile",
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
"type": "object",
"properties": {
"default": {
"description": "The default permission set for the plugin",
"anyOf": [
{
"$ref": "#/definitions/DefaultPermission"
},
{
"type": "null"
}
]
},
"set": {
"description": "A list of permissions sets defined",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionSet"
}
},
"permission": {
"description": "A list of inlined permissions",
"default": [],
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
}
}
},
"definitions": {
"DefaultPermission": {
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
"type": "object",
"required": [
"permissions"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"description": {
"description": "Human-readable description of what the permission does. Tauri convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"PermissionSet": {
"description": "A set of direct permissions grouped together under a new name.",
"type": "object",
"required": [
"description",
"identifier",
"permissions"
],
"properties": {
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does.",
"type": "string"
},
"permissions": {
"description": "All permissions this set contains.",
"type": "array",
"items": {
"$ref": "#/definitions/PermissionKind"
}
}
}
},
"Permission": {
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"version": {
"description": "The version of the permission.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 1.0
},
"identifier": {
"description": "A unique identifier for the permission.",
"type": "string"
},
"description": {
"description": "Human-readable description of what the permission does. Tauri internal convention is to use <h4> headings in markdown content for Tauri documentation generation purposes.",
"type": [
"string",
"null"
]
},
"commands": {
"description": "Allowed or denied commands when using this permission.",
"default": {
"allow": [],
"deny": []
},
"allOf": [
{
"$ref": "#/definitions/Commands"
}
]
},
"scope": {
"description": "Allowed or denied scoped when using this permission.",
"allOf": [
{
"$ref": "#/definitions/Scopes"
}
]
},
"platforms": {
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Target"
}
}
}
},
"Commands": {
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
"type": "object",
"properties": {
"allow": {
"description": "Allowed command.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
},
"deny": {
"description": "Denied command, which takes priority.",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
}
},
"Scopes": {
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
"type": "object",
"properties": {
"allow": {
"description": "Data that defines what is allowed by the scope.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
},
"deny": {
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Value"
}
}
}
},
"Value": {
"description": "All supported ACL values.",
"anyOf": [
{
"description": "Represents a null JSON value.",
"type": "null"
},
{
"description": "Represents a [`bool`].",
"type": "boolean"
},
{
"description": "Represents a valid ACL [`Number`].",
"allOf": [
{
"$ref": "#/definitions/Number"
}
]
},
{
"description": "Represents a [`String`].",
"type": "string"
},
{
"description": "Represents a list of other [`Value`]s.",
"type": "array",
"items": {
"$ref": "#/definitions/Value"
}
},
{
"description": "Represents a map of [`String`] keys to [`Value`]s.",
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/Value"
}
}
]
},
"Number": {
"description": "A valid ACL number.",
"anyOf": [
{
"description": "Represents an [`i64`].",
"type": "integer",
"format": "int64"
},
{
"description": "Represents a [`f64`].",
"type": "number",
"format": "double"
}
]
},
"Target": {
"description": "Platform target.",
"oneOf": [
{
"description": "MacOS.",
"type": "string",
"enum": [
"macOS"
]
},
{
"description": "Windows.",
"type": "string",
"enum": [
"windows"
]
},
{
"description": "Linux.",
"type": "string",
"enum": [
"linux"
]
},
{
"description": "Android.",
"type": "string",
"enum": [
"android"
]
},
{
"description": "iOS.",
"type": "string",
"enum": [
"iOS"
]
}
]
},
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the cancel_cors_request command without any pre-configured scope.",
"type": "string",
"const": "allow-cancel-cors-request"
},
{
"description": "Denies the cancel_cors_request command without any pre-configured scope.",
"type": "string",
"const": "deny-cancel-cors-request"
},
{
"description": "Enables the cors_request command without any pre-configured scope.",
"type": "string",
"const": "allow-cors-request"
},
{
"description": "Denies the cors_request command without any pre-configured scope.",
"type": "string",
"const": "deny-cors-request"
},
{
"description": "Allows all fetch operations",
"type": "string",
"const": "default"
}
]
}
}
}

View File

@@ -0,0 +1,434 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// Source: tauri-plugin-http@2.0.0-beta.3
use std::{collections::HashMap, sync::Arc, time::Duration};
use http::{header, HeaderName, HeaderValue, Method};
use reqwest::{redirect::Policy, NoProxy, RequestBuilder};
use sd_crypto::cookie::CookieCipher;
use serde::{Deserialize, Serialize};
use tauri::command;
use tracing::{debug, error};
use crate::{Error, Result, NODE_DATA_DIR};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestConfig {
request_id: u64,
method: String,
url: url::Url,
headers: Vec<(String, String)>,
data: Option<Vec<u8>>,
connect_timeout: Option<u64>,
max_redirections: Option<usize>,
proxy: Option<Proxy>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchResponse {
status: u16,
status_text: String,
headers: Vec<(String, String)>,
url: String,
body: Option<Vec<u8>>,
}
use once_cell::sync::Lazy;
use tokio::sync::oneshot;
type RequestPool = Arc<std::sync::Mutex<HashMap<u64, oneshot::Sender<()>>>>;
static REQUEST_POOL: Lazy<RequestPool> =
Lazy::new(|| Arc::new(std::sync::Mutex::new(HashMap::new())));
#[command]
pub fn cancel_cors_request(request_id: u64) {
if let Some(tx) = REQUEST_POOL.lock().unwrap().remove(&request_id) {
tx.send(()).ok();
}
}
#[command]
pub async fn cors_request(request: RequestConfig) -> Result<FetchResponse> {
let request_id = request.request_id;
let (tx, rx) = oneshot::channel();
REQUEST_POOL.lock().unwrap().insert(request_id, tx);
let request_config = build_request(request)?;
let response = get_response(request_config, rx).await;
if !REQUEST_POOL.lock().unwrap().contains_key(&request_id) {
return Err(Error::RequestCanceled);
}
REQUEST_POOL.lock().unwrap().remove(&request_id);
response
}
pub fn build_request(request_config: RequestConfig) -> Result<RequestBuilder> {
debug!("\n=== Starting Request Build Process ===");
let RequestConfig {
request_id: _,
method,
url,
headers,
data,
connect_timeout,
max_redirections,
proxy,
} = request_config;
debug!("\nRequest Details:");
debug!(" Method: {}", method);
debug!(" URL: {}", url);
let method = Method::from_bytes(method.as_bytes())?;
debug!("\nParsed HTTP method: {}", method);
let headers: HashMap<String, String> = HashMap::from_iter(headers);
debug!("\nHeaders:");
for (key, value) in &headers {
debug!(" {} = {}", key, value);
}
let mut builder = reqwest::ClientBuilder::new();
debug!("\nBuilding Client Configuration:");
if let Some(timeout) = connect_timeout {
debug!(" Connect Timeout: {}ms", timeout);
builder = builder.connect_timeout(Duration::from_millis(timeout));
}
if let Some(max_redirections) = max_redirections {
debug!(" Redirect Policy:");
builder = builder.redirect(if max_redirections == 0 {
debug!(" Redirects disabled");
Policy::none()
} else {
debug!(" Max redirects: {}", max_redirections);
Policy::limited(max_redirections)
});
}
if let Some(proxy_config) = proxy {
debug!(" Configuring proxy settings");
builder = attach_proxy(proxy_config, builder)?;
}
debug!("\nFinalizing Request:");
let client = builder.build()?;
let mut request = client.request(method.clone(), url.clone());
debug!("\nSetting Headers:");
for (name, value) in &headers {
debug!(" Adding: {} = {}", name, value);
let name = HeaderName::from_bytes(name.as_bytes())?;
let value = HeaderValue::from_bytes(value.as_bytes())?;
request = request.header(name, value);
}
if data.is_none() && matches!(method, Method::POST | Method::PUT) {
debug!(
" Adding empty content-length header for {} request",
method
);
request = request.header(header::CONTENT_LENGTH, HeaderValue::from(0));
}
if headers.contains_key(header::RANGE.as_str()) {
debug!(" Range header present - Setting Accept-Encoding: identity");
request = request.header(
header::ACCEPT_ENCODING,
HeaderValue::from_static("identity"),
);
}
if let Some(data) = data {
debug!("\nRequest Body:");
debug!(" Size: {} bytes", data.len());
request = request.body(data);
}
debug!("\nRequest build completed successfully");
debug!("=====================================\n");
Ok(request)
}
pub async fn get_response(
request: RequestBuilder,
rx: oneshot::Receiver<()>,
) -> Result<FetchResponse> {
debug!("\n=== Starting Response Fetch ===");
let response_or_none = tokio::select! {
_ = rx => {
debug!("\nRequest cancelled by receiver");
None
},
res = request.send() => {
debug!("\nRequest sent, awaiting response");
Some(res)
},
};
if let Some(response) = response_or_none {
debug!("\nProcessing Response:");
match response {
Ok(res) => {
let status = res.status();
debug!(
"\nStatus: {} ({})",
status.as_u16(),
status.canonical_reason().unwrap_or_default()
);
let url = res.url().to_string();
debug!("Final URL: {}", url);
let mut headers = Vec::new();
debug!("\nResponse Headers:");
for (key, val) in res.headers().iter() {
debug!(" {} = {:?}", key.as_str(), val);
headers.push((
key.as_str().into(),
String::from_utf8(val.as_bytes().to_vec())?,
));
}
// Create cookies from headers
let mut cookie_store: HashMap<String, String> = HashMap::new();
// Filter based on Supertokens' headers
for (key, val) in res.headers().iter() {
match key.as_str() {
"front-token" => {
if val.to_str().map(|v| v == "remove").unwrap_or(false) {
debug!("Removing front-token header (value: remove)");
continue;
}
debug!("Adding front-token header");
cookie_store.insert(
"front-token".to_string(),
val.to_str().unwrap_or_default().to_string(),
);
}
"st-access-token" => {
if val.to_str().map(|v| v.is_empty()).unwrap_or(false) {
debug!("Removing empty st-access-token header");
continue;
}
debug!("Setting st-access-token cookie");
cookie_store.insert(
"st-access-token".to_string(),
val.to_str().unwrap_or_default().to_string(),
);
}
"st-refresh-token" => {
if val.to_str().map(|v| v.is_empty()).unwrap_or(false) {
debug!("Removing empty st-refresh-token header");
continue;
}
debug!("Setting st-refresh-token cookie");
cookie_store.insert(
"st-refresh-token".to_string(),
val.to_str().unwrap_or_default().to_string(),
);
}
// "set-cookie" => {
// if let Ok(cookie_str) = val.to_str() {
// if let Some((name, value)) = cookie_str.split_once('=') {
// if let Some(value) = value.split(';').next() {
// cookie_store.insert(name.trim().to_string(), value.trim().to_string());
// }
// }
// }
// }
_ => {}
}
debug!(" {} = {:?}", key.as_str(), val);
headers.push((
key.as_str().into(),
String::from_utf8(val.as_bytes().to_vec())?,
));
}
if !cookie_store.is_empty() {
let data_dir = NODE_DATA_DIR.get().unwrap().clone();
let data_dir = data_dir.join("spacedrive").join("dev");
let node_config_path = data_dir.join("node_state.sdconfig");
let node_config = std::fs::read_to_string(node_config_path).unwrap();
let node_config: serde_json::Value =
serde_json::from_str(&node_config).unwrap();
let node_id = node_config["id"]["Uuid"].as_str().unwrap();
debug!("Node ID: {:?}", node_id);
// Create Cipher
let key = CookieCipher::generate_key_from_string(node_id).unwrap();
let cipher = CookieCipher::new(&key).unwrap();
// Read .sdks file
let sdks_path = data_dir.join(".sdks");
let data = std::fs::read(sdks_path.clone()).unwrap();
let data_str = String::from_utf8(data)
.map_err(|e| {
error!("Failed to convert data to string: {:?}", e.to_string());
})
.unwrap();
let data = CookieCipher::base64_decode(&data_str)
.map_err(|e| {
error!("Failed to decode data: {:?}", e.to_string());
})
.unwrap();
let de_data = cipher
.decrypt(&data)
.map_err(|e| {
error!("Failed to decrypt data: {:?}", e.to_string());
})
.unwrap();
let de_data = String::from_utf8(de_data)
.map_err(|e| {
error!("Failed to convert data to string: {:?}", e.to_string());
})
.unwrap();
debug!("Decrypted Data: {:?}", de_data);
debug!("\nCookies:");
for (name, value) in &cookie_store {
debug!(" {} = {}", name, value);
}
let mut de_data: Vec<String> = serde_json::from_str(&de_data).unwrap();
for cookie in &mut de_data {
for (name, value) in &cookie_store {
if cookie.starts_with(name) {
*cookie = format!("{}={};expires=Fri, 31 Dec 9999 23:59:59 GMT;path=/;samesite=lax", name, value);
}
}
}
debug!("Updated Cookies: {:?}", de_data);
// Now, we will encrypt the de_data and save it to the .sdks file
let de_data = serde_json::to_string(&de_data).unwrap();
let en_data = cipher
.encrypt(de_data.as_bytes())
.map_err(|e| {
error!("Failed to encrypt data: {:?}", e.to_string());
})
.unwrap();
let en_data = CookieCipher::base64_encode(&en_data);
std::fs::write(sdks_path, en_data).unwrap();
}
debug!("\nReading Response Body...");
let body = res.bytes().await;
match body {
Ok(bytes) => {
debug!("Body received: {} bytes", bytes.len());
Ok(FetchResponse {
status: status.as_u16(),
status_text: status.canonical_reason().unwrap_or_default().to_string(),
headers,
url,
body: Some(bytes.to_vec()),
})
}
Err(e) => {
error!("Failed to read body: {}", e);
Err(Error::Network(e))
}
}
}
Err(err) => {
error!("Network error: {}", err);
Err(Error::Network(err))
}
}
} else {
debug!("Request was cancelled");
Err(Error::RequestCanceled)
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Proxy {
all: Option<UrlOrConfig>,
http: Option<UrlOrConfig>,
https: Option<UrlOrConfig>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(untagged)]
pub enum UrlOrConfig {
Url(String),
Config(ProxyConfig),
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyConfig {
url: String,
basic_auth: Option<BasicAuth>,
no_proxy: Option<String>,
}
#[derive(Deserialize)]
pub struct BasicAuth {
username: String,
password: String,
}
#[inline]
fn proxy_creator(
url_or_config: UrlOrConfig,
proxy_fn: fn(String) -> reqwest::Result<reqwest::Proxy>,
) -> reqwest::Result<reqwest::Proxy> {
match url_or_config {
UrlOrConfig::Url(url) => Ok(proxy_fn(url)?),
UrlOrConfig::Config(ProxyConfig {
url,
basic_auth,
no_proxy,
}) => {
let mut proxy = proxy_fn(url)?;
if let Some(basic_auth) = basic_auth {
proxy = proxy.basic_auth(&basic_auth.username, &basic_auth.password);
}
if let Some(no_proxy) = no_proxy {
proxy = proxy.no_proxy(NoProxy::from_string(&no_proxy));
}
Ok(proxy)
}
}
}
fn attach_proxy(
proxy: Proxy,
mut builder: reqwest::ClientBuilder,
) -> crate::Result<reqwest::ClientBuilder> {
let Proxy { all, http, https } = proxy;
if let Some(all) = all {
let proxy = proxy_creator(all, reqwest::Proxy::all)?;
builder = builder.proxy(proxy);
}
if let Some(http) = http {
let proxy = proxy_creator(http, reqwest::Proxy::http)?;
builder = builder.proxy(proxy);
}
if let Some(https) = https {
let proxy = proxy_creator(https, reqwest::Proxy::https)?;
builder = builder.proxy(proxy);
}
Ok(builder)
}

View File

@@ -0,0 +1,49 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use serde::{Serialize, Serializer};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Network(#[from] reqwest::Error),
#[error(transparent)]
Http(#[from] http::Error),
#[error(transparent)]
HttpInvalidHeaderName(#[from] http::header::InvalidHeaderName),
#[error(transparent)]
HttpInvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
#[error(transparent)]
UrlParseError(#[from] url::ParseError),
/// HTTP method error.
#[error(transparent)]
HttpMethod(#[from] http::method::InvalidMethod),
#[error("scheme {0} not supported")]
SchemeNotSupport(String),
#[error("Request canceled")]
RequestCanceled,
#[error("failed to process data url")]
DataUrlError,
#[error("failed to decode data url into bytes")]
DataUrlDecodeError,
#[error(transparent)]
Tauri(#[from] tauri::Error),
#[error(transparent)]
Utf8(#[from] std::string::FromUtf8Error),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,41 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
//! ![tauri-plugin-cors-fetch](https://github.com/idootop/tauri-plugin-cors-fetch/raw/main/banner.png)
//!
//! Enabling Cross-Origin Resource Sharing (CORS) for Fetch Requests within Tauri applications.
use std::path::PathBuf;
use once_cell::sync::OnceCell;
pub use reqwest;
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
pub use error::{Error, Result};
mod commands;
mod error;
pub static NODE_DATA_DIR: OnceCell<PathBuf> = OnceCell::new();
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("cors-fetch")
.invoke_handler(tauri::generate_handler![
commands::cors_request,
commands::cancel_cors_request,
])
.setup(|app_handle, _| {
let data_dir = app_handle
.path()
.data_dir()
.unwrap_or_else(|_| PathBuf::from("./"));
NODE_DATA_DIR.set(data_dir).unwrap();
Ok(())
})
.build()
}

View File

@@ -22,3 +22,17 @@ Migrations are run by the Prisma migration engine on app launch.
The databases file is SQLite and can be opened in any SQL viewer.
![A Spacedrive library database file open in Table Plus](/database-table-plus.webp)
### Environment Variables
The following environment variables can be used to control database behavior:
- `SD_ACCEPT_DATA_LOSS`: When set to `"true"`, allows operations that might result in data loss. This should only be used in development or testing environments where data loss is acceptable.
- `SD_FORCE_RESET_DB`: When set to `"true"`, forces a reset of the database. This will clear all existing data. Use with extreme caution and only in development environments.
You can set these variables when running the app, e.g. `SD_ACCEPT_DATA_LOSS=true pnpm tauri dev`.
These environment variables are primarily used during development and testing. They should not be used in production environments unless you fully understand the implications.
⚠️ **Warning**: Setting these variables can result in permanent data loss. Use them only in development environments or when you explicitly need to reset or modify database behavior.

View File

@@ -15,8 +15,8 @@ const SUPPORTED_ICONS = ['Document', 'Code', 'Text', 'Config'];
const positionConfig: Record<string, string> = {
Text: 'flex h-full w-full items-center justify-center',
Code: 'flex h-full w-full items-center justify-center',
Config: 'flex h-full w-full items-center justify-center'
Code: 'flex h-full w-full items-center justify-center pt-[18px]',
Config: 'flex h-full w-full items-center justify-center pt-[18px]'
};
const LayeredFileIcon = forwardRef<HTMLImageElement, LayeredFileIconProps>(
@@ -51,7 +51,7 @@ const LayeredFileIcon = forwardRef<HTMLImageElement, LayeredFileIconProps>(
className={clsx('pointer-events-none absolute bottom-0 right-0', positionClass)}
>
<Suspense>
<IconComponent viewBox="0 0 16 16" height="40%" width="40%" />
<IconComponent viewBox="0 0 16 16" height="50%" width="50%" />
</Suspense>
</div>
</div>

View File

@@ -1,9 +1,11 @@
import { Transparent } from '@sd/assets/images';
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { memo, useEffect, useMemo } from 'react';
import {
getItemFilePath,
getItemObject,
humanizeSize,
libraryClient,
Tag,
useExplorerLayoutStore,
useLibraryQuery,
@@ -11,6 +13,7 @@ import {
type ExplorerItem
} from '@sd/client';
import { useLocale } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { useExplorerContext } from '../../../Context';
import { ExplorerDraggable } from '../../../ExplorerDraggable';
@@ -19,6 +22,7 @@ import { FileThumb } from '../../../FilePath/Thumb';
import { useFrame } from '../../../FilePath/useFrame';
import { explorerStore } from '../../../store';
import { useExplorerDraggable } from '../../../useExplorerDraggable';
import { useExplorerItemData } from '../../../useExplorerItemData';
import { RenamableItemText } from '../../RenamableItemText';
import { ViewItem } from '../../ViewItem';
import { GridViewItemContext, useGridViewItemContext } from './Context';

View File

@@ -34,6 +34,11 @@ interface Props {
contextMenu?: () => ReactNode;
}
declare global {
interface Window {
useDragAndDrop: () => void;
}
}
/**
* This component is used in a few routes and acts as the reference demonstration of how to combine
* all the elements of the explorer except for the context, which must be used in the parent component.
@@ -82,6 +87,8 @@ export default function Explorer(props: PropsWithChildren<Props>) {
explorer.settingsStore.showHiddenFiles = !explorer.settingsStore.showHiddenFiles;
});
window.useDragAndDrop();
useKeyRevealFinder();
useExplorerDnd();
@@ -111,18 +118,13 @@ export default function Explorer(props: PropsWithChildren<Props>) {
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
emptyNotice={
props.emptyNotice ?? (
<EmptyNotice
icon={FolderNotchOpen}
message="This folder is empty"
/>
<EmptyNotice icon={FolderNotchOpen} message="This folder is empty" />
)
}
listViewOptions={{ hideHeaderBorder: true }}
scrollPadding={{
top: topBar.topBarHeight,
bottom: showPathBar
? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0)
: undefined
bottom: showPathBar ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) : undefined
}}
/>
</div>
@@ -142,9 +144,7 @@ export default function Explorer(props: PropsWithChildren<Props>) {
)}
style={{
paddingTop: topBar.topBarHeight + 12,
bottom: showPathBar
? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0)
: 0
bottom: showPathBar ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) : 0
}}
/>
)}

View File

@@ -1,14 +1,8 @@
import { ArrowRight, EjectSimple } from '@phosphor-icons/react';
import { EjectSimple } from '@phosphor-icons/react';
import { useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
import { MouseEvent, PropsWithChildren, useMemo } from 'react';
import {
useBridgeQuery,
useLibraryMutation,
useLibraryQuery,
useLibrarySubscription,
Volume
} from '@sd/client';
import { useLibraryMutation, useLibraryQuery, useLibrarySubscription, Volume } from '@sd/client';
import { Button, toast, tw } from '@sd/ui';
import { Icon, IconName } from '~/components';
import { useLocale } from '~/hooks';
@@ -156,9 +150,7 @@ export default function LocalSection() {
onTrack={async () => {
if (!isTracked && volume.pub_id) {
try {
await trackVolumeMutation.mutateAsync({
volume_id: Array.from(volume.pub_id) // Convert Uint8Array to number[]
});
await trackVolumeMutation.mutateAsync(volume.pub_id);
toast.success('Volume tracked successfully');
} catch (error) {
toast.error('Failed to track volume');

View File

@@ -33,7 +33,16 @@ const Profile = ({
}) => {
const emailName = user.email?.split('@')[0];
const capitalizedEmailName = (emailName?.charAt(0).toUpperCase() ?? '') + emailName?.slice(1);
const { accessToken, refreshToken } = getTokens();
const [accessToken, setAccessToken] = useState('');
const [refreshToken, setRefreshToken] = useState('');
useEffect(() => {
(async () => {
const tokens = await getTokens();
setAccessToken(tokens.accessToken);
setRefreshToken(tokens.refreshToken);
})();
}, []);
const cloudBootstrap = useBridgeMutation('cloud.bootstrap');
const devices = useBridgeQuery(['cloud.devices.list']);

View File

@@ -1,23 +1,32 @@
import { CookieHandlerInterface } from 'supertokens-website/utils/cookieHandler/types';
const frontendCookiesKey = 'frontendCookies';
import { nonLibraryClient } from '@sd/client';
/**
* Tauri handles cookies differently than in browser environments. The SuperTokens
* SDK uses frontend cookies, to make sure everything work correctly we add custom
* cookie handling and store cookies in local storage instead (This is not a problem
* since these are frontend cookies and not server side cookies)
* cookie handling and store cookies in an encrypted file called `.sdks`. This file
* is stored in the data directory, and must be fetched via RSPC due to the encryption
* requirements.
*/
function getCookiesFromStorage(): string {
const cookiesFromStorage = window.localStorage.getItem(frontendCookiesKey);
let cookiesFromStorage: string = '';
if (cookiesFromStorage === null) {
window.localStorage.setItem(frontendCookiesKey, '[]');
nonLibraryClient
.query(['keys.get'])
.then((response) => {
cookiesFromStorage = response;
})
.catch((e) => {
console.error('Error fetching cookies from storage: ', e);
});
if (cookiesFromStorage.length === 0) {
return '';
}
/**
* Because we store cookies in local storage, we need to manually check
* Because we store cookies in a single string, we need to split them by
* the delimiter `;` and check the `expires=` part of the cookie string
* for expiry before returning all cookies
*/
const cookieArrayInStorage: string[] = JSON.parse(cookiesFromStorage);
@@ -25,47 +34,68 @@ function getCookiesFromStorage(): string {
for (let cookieIndex = 0; cookieIndex < cookieArrayInStorage.length; cookieIndex++) {
const currentCookieString = cookieArrayInStorage[cookieIndex];
const parts = currentCookieString?.split(';');
const parts = currentCookieString?.split(';') ?? [];
let expirationString: string = '';
for (let partIndex = 0; partIndex < parts!.length; partIndex++) {
const currentPart = parts![partIndex];
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
const currentPart = parts[partIndex];
if (currentPart!.toLocaleLowerCase().includes('expires=')) {
expirationString = currentPart!;
if (currentPart?.toLocaleLowerCase().includes('expires=')) {
expirationString = currentPart;
break;
}
}
if (expirationString !== '') {
const expirationValueString = expirationString.split('=')[1];
const expirationDate = new Date(expirationValueString!);
const expirationDate = expirationValueString ? new Date(expirationValueString) : null;
const currentTimeInMillis = Date.now();
// if the cookie has expired, we skip it
if (expirationDate.getTime() < currentTimeInMillis) {
if (expirationDate && expirationDate.getTime() < currentTimeInMillis) {
continue;
}
}
cookieArrayToReturn.push(currentCookieString!);
if (currentCookieString !== undefined) {
cookieArrayToReturn.push(currentCookieString);
}
}
/**
* After processing and removing expired cookies we need to update the cookies
* in storage so we dont have to process the expired ones again
*/
window.localStorage.setItem(frontendCookiesKey, JSON.stringify(cookieArrayToReturn));
// window.localStorage.setItem(frontendCookiesKey, JSON.stringify(cookieArrayToReturn));
try {
nonLibraryClient.mutation(['keys.save', JSON.stringify(cookieArrayToReturn)]);
// console.log("Cookies set successfully");
} catch (e) {
console.error('Error setting cookies to storage: ', e);
}
return cookieArrayToReturn.join('; ');
}
function setCookieToStorage(cookieString: string) {
const cookieName = cookieString.split(';')[0]!.split('=')[0];
const cookiesFromStorage = window.localStorage.getItem(frontendCookiesKey);
async function setCookieToStorage(cookieString: string) {
const cookieName = cookieString.split(';')[0]?.split('=')[0];
let cookiesFromStorage: string = '';
try {
const response = await nonLibraryClient.query(['keys.get']);
// Debugging
const cookiesArrayFromStorage: string[] = JSON.parse(response);
// console.log("Cookies fetched from storage: ", cookiesArrayFromStorage);
// Actual
cookiesFromStorage = response;
} catch (e) {
console.error('Error fetching cookies from storage: ', e);
}
let cookiesArray: string[] = [];
if (cookiesFromStorage !== null) {
if (cookiesFromStorage.length !== 0) {
const cookiesArrayFromStorage: string[] = JSON.parse(cookiesFromStorage);
cookiesArray = cookiesArrayFromStorage;
}
@@ -75,7 +105,7 @@ function setCookieToStorage(cookieString: string) {
for (let i = 0; i < cookiesArray.length; i++) {
const currentCookie = cookiesArray[i];
if (currentCookie!.indexOf(`${cookieName}=`) !== -1) {
if (currentCookie?.indexOf(`${cookieName}=`) !== -1) {
cookieIndex = i;
break;
}
@@ -93,7 +123,15 @@ function setCookieToStorage(cookieString: string) {
cookiesArray.push(cookieString);
}
window.localStorage.setItem(frontendCookiesKey, JSON.stringify(cookiesArray));
try {
await nonLibraryClient.mutation(['keys.save', JSON.stringify(cookiesArray)]);
// console.log("Cookies set successfully");
} catch (e) {
console.error('Error setting cookies to storage: ', e);
return;
}
// console.log("Setting cookies to storage: ", cookiesArray);
}
export default function getCookieHandler(original: CookieHandlerInterface): CookieHandlerInterface {
@@ -104,7 +142,7 @@ export default function getCookieHandler(original: CookieHandlerInterface): Cook
return cookies;
},
setCookie: async function (cookieString: string) {
setCookieToStorage(cookieString);
await setCookieToStorage(cookieString);
}
};
}

View File

@@ -1,10 +1,9 @@
import { WindowHandlerInterface } from 'supertokens-website/utils/windowHandler/types';
/**
* This example app uses HashRouter from react-router-dom. The SuperTokens SDK relies on
* some window properties like location hash, query params etc. Because HashRouter places
* everything other than the website base in the location hash, we need to add custom
* handling for some of the properties of the Window API
* The SuperTokens SDK relies on some window properties like location hash, query params etc.
* This handler is used to override the default window object and provide custom implementations
* for these properties.
*/
export default function getWindowHandler(original: WindowHandlerInterface): WindowHandlerInterface {
return {

View File

@@ -5,7 +5,7 @@ import { useBridgeMutation } from '@sd/client';
import { Button } from '@sd/ui';
import { Authentication } from '~/components';
import { useLocale } from '~/hooks';
import { AUTH_SERVER_URL } from '~/util';
import { AUTH_SERVER_URL, getTokens } from '~/util';
import { Heading } from '../../Layout';
import Profile from './Profile';
@@ -24,11 +24,16 @@ export const Component = () => {
useEffect(() => {
async function _() {
const tokens = await getTokens();
const user_data = await fetch(`${AUTH_SERVER_URL}/api/user`, {
method: 'GET'
method: 'GET',
headers: {
Authorization: `Bearer ${tokens.accessToken}`
}
});
const data = await user_data.json();
// console.log(data);
setUserInfo(data.id ? data : null);
}

View File

@@ -191,7 +191,7 @@ export const Component = () => {
</div>
<div className="mb-1">
<Label className="text-sm font-medium text-ink-faint">
<Database className="mr-1 mt-[-2px] inline h-4 w-4" /> Logs Folder
<Database className="mr-1 mt-[-2px] inline size-4" /> Logs Folder
</Label>
<Input value={node.data?.data_path + '/logs'} />
</div>

View File

@@ -136,7 +136,7 @@ export const Authentication = ({
{activeTab === 'Login' ? (
<Login reload={reload} cloudBootstrap={cloudBootstrap} />
) : (
<Register />
<Register reload={reload} cloudBootstrap={cloudBootstrap} />
)}
<div className="text-center text-sm text-ink-faint">
Social auth and SSO (Single Sign On) available soon!

View File

@@ -2,11 +2,11 @@ import { ArrowLeft } from '@phosphor-icons/react';
import { RSPCError } from '@spacedrive/rspc-client';
import { UseMutationResult } from '@tanstack/react-query';
import clsx from 'clsx';
import { Dispatch, SetStateAction, useState } from 'react';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { Controller } from 'react-hook-form';
import { signIn } from 'supertokens-web-js/recipe/emailpassword';
import { createCode } from 'supertokens-web-js/recipe/passwordless';
import { useZodForm } from '@sd/client';
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
import { Button, Divider, Form, Input, toast, z } from '@sd/ui';
import { useLocale } from '~/hooks';
import { getTokens } from '~/util';
@@ -17,7 +17,8 @@ async function signInClicked(
email: string,
password: string,
reload: Dispatch<SetStateAction<boolean>>,
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown> // Cloud bootstrap mutation
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown>, // Cloud bootstrap mutation
saveEmailAddress: UseMutationResult<null, RSPCError, string, unknown> // Save email mutation
) {
try {
const response = await signIn({
@@ -44,9 +45,9 @@ async function signInClicked(
} else if (response.status === 'SIGN_IN_NOT_ALLOWED') {
toast.error(response.reason);
} else {
const tokens = getTokens();
console.log(cloudBootstrap);
const tokens = await getTokens();
cloudBootstrap.mutate([tokens.accessToken, tokens.refreshToken]);
saveEmailAddress.mutate(email);
toast.success('Sign in successful');
reload(true);
}
@@ -112,18 +113,40 @@ interface LoginProps {
const LoginForm = ({ reload, cloudBootstrap, setContinueWithEmail }: LoginProps) => {
const { t } = useLocale();
const [showPassword, setShowPassword] = useState(false);
const savedEmailAddress = useLibraryQuery(['keys.getEmailAddress']);
const saveEmailAddress = useLibraryMutation(['keys.saveEmailAddress']);
const form = useZodForm({
schema: LoginSchema,
defaultValues: {
email: '',
email: savedEmailAddress.data ?? '',
password: ''
}
});
useEffect(() => {
savedEmailAddress.refetch();
}, []);
useEffect(() => {
if (savedEmailAddress.data) {
form.reset({
email: savedEmailAddress.data,
password: ''
});
}
}, [savedEmailAddress.data]);
return (
<Form
onSubmit={form.handleSubmit(async (data) => {
await signInClicked(data.email, data.password, reload, cloudBootstrap);
await signInClicked(
data.email,
data.password,
reload,
cloudBootstrap,
saveEmailAddress
);
})}
className="w-full"
form={form}
@@ -194,7 +217,13 @@ const LoginForm = ({ reload, cloudBootstrap, setContinueWithEmail }: LoginProps)
variant="accent"
size="md"
onClick={form.handleSubmit(async (data) => {
await signInClicked(data.email, data.password, reload, cloudBootstrap);
await signInClicked(
data.email,
data.password,
reload,
cloudBootstrap,
saveEmailAddress
);
})}
disabled={form.formState.isSubmitting}
>

View File

@@ -1,12 +1,16 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { RSPCError } from '@spacedrive/rspc-client';
import { UseMutationResult } from '@tanstack/react-query';
import clsx from 'clsx';
import { useState } from 'react';
import { Dispatch, SetStateAction, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { signUp } from 'supertokens-web-js/recipe/emailpassword';
import { Button, Form, Input, toast, z } from '@sd/ui';
import { useLocale } from '~/hooks';
import { useLibraryMutation } from '@sd/client';
import ShowPassword from './ShowPassword';
import { getTokens } from '~/util';
const RegisterSchema = z
.object({
@@ -26,7 +30,13 @@ const RegisterSchema = z
});
type RegisterType = z.infer<typeof RegisterSchema>;
async function signUpClicked(email: string, password: string) {
async function signUpClicked(
email: string,
password: string,
reload: Dispatch<SetStateAction<boolean>>,
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown>,
saveEmailAddress: UseMutationResult<null, RSPCError, string, unknown>
) {
try {
const response = await signUp({
formFields: [
@@ -62,9 +72,11 @@ async function signUpClicked(email: string, password: string) {
} else {
// sign up successful. The session tokens are automatically handled by
// the frontend SDK.
const tokens = await getTokens();
cloudBootstrap.mutate([tokens.accessToken, tokens.refreshToken]);
saveEmailAddress.mutate(email);
toast.success('Sign up successful');
// FIXME: This is a temporary workaround. We will provide a better way to handle this.
window.location.reload();
reload(true);
}
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
@@ -76,7 +88,13 @@ async function signUpClicked(email: string, password: string) {
}
}
const Register = () => {
const Register = ({
reload,
cloudBootstrap
}: {
reload: Dispatch<SetStateAction<boolean>>;
cloudBootstrap: UseMutationResult<null, RSPCError, [string, string], unknown>; // Cloud bootstrap mutation
}) => {
const { t } = useLocale();
const [showPassword, setShowPassword] = useState(false);
// useZodForm seems to be out-dated or needs
@@ -89,12 +107,13 @@ const Register = () => {
confirmPassword: ''
}
});
const savedEmailAddress = useLibraryMutation(['keys.saveEmailAddress']);
return (
<Form
onSubmit={form.handleSubmit(async (data) => {
// handle sign-up submission
console.log(data);
await signUpClicked(data.email, data.password);
await signUpClicked(data.email, data.password, reload, cloudBootstrap, savedEmailAddress);
})}
className="w-full"
form={form}
@@ -190,8 +209,7 @@ const Register = () => {
size="md"
variant="accent"
onClick={form.handleSubmit(async (data) => {
console.log(data);
await signUpClicked(data.email, data.password);
await signUpClicked(data.email, data.password, reload, cloudBootstrap, savedEmailAddress);
})}
disabled={form.formState.isSubmitting}
>

View File

@@ -1,18 +1,26 @@
import { ArrowRight } from '@phosphor-icons/react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { HardwareModel, useBridgeMutation, useZodForm } from '@sd/client';
import {
CloudDevice,
CloudP2PNotifyUser,
CloudP2PTicket,
CloudSyncGroupWithDevices,
HardwareModel,
useBridgeMutation,
useZodForm
} from '@sd/client';
import { Dialog, toast, useDialog, UseDialogProps, z } from '@sd/ui';
import { Icon } from '~/components';
import { useLocale } from '~/hooks';
import { hardwareModelToIcon } from '~/util/hardware';
import { usePlatform } from '~/util/Platform';
type ReceivedJoinRequest = Extract<CloudP2PNotifyUser, { kind: 'ReceivedJoinSyncGroupRequest' }>;
export default (
props: {
device_name: string;
device_model: HardwareModel;
library_name: string;
data: ReceivedJoinRequest['data'];
} & UseDialogProps
) => {
// PROPS = device_name, device_model, library_name
@@ -27,24 +35,40 @@ export default (
const queryClient = useQueryClient();
const form = useZodForm({ defaultValues: { libraryId: 'select_library' } });
const userResponse = useBridgeMutation('cloud.userResponse');
// adapted from another dialog - we can change the form submit/remove form if needed but didn't want to
// unnecessarily remove code
const onSubmit = form.handleSubmit(async (data) => {
const onSubmit = form.handleSubmit(async (_d) => {
try {
// const library = await joinLibrary.mutateAsync(data.libraryId);
const library = { uuid: '1234' }; // dummy data
userResponse.mutate({
kind: 'AcceptDeviceInSyncGroup',
data: {
ticket: props.data.ticket,
accepted: {
id: props.data.sync_group.library.pub_id,
name: props.data.sync_group.library.name,
description: null
}
}
});
queryClient.setQueryData(['library.list'], (libraries: any) => {
// The invalidation system beat us to it
if ((libraries || []).find((l: any) => l.uuid === library.uuid)) return libraries;
if (
(libraries || []).find(
(l: any) => l.uuid === props.data.sync_group.library.pub_id
)
)
return libraries;
return [...(libraries || []), library];
return [...(libraries || []), props.data.sync_group.library];
});
if (platform.refreshMenuBar) platform.refreshMenuBar();
navigate(`/${library.uuid}`, { replace: true });
navigate(`/${props.data.sync_group.library.pub_id}`, { replace: true });
} catch (e: any) {
console.error(e);
toast.error(e);
@@ -71,12 +95,12 @@ export default (
<div className="flex flex-col items-center justify-center gap-2">
<Icon
// once backend endpoint is populated need to check if this is working correctly i.e fetching correct icons for devices
name={hardwareModelToIcon(props.device_model)}
name={hardwareModelToIcon(props.data.asking_device.hardware_model)}
alt="Device icon"
size={48}
className="mr-2"
/>
<p className="text-sm text-ink-dull">{props.device_name}</p>
<p className="text-sm text-ink-dull">{props.data.asking_device.name}</p>
</div>
<ArrowRight color="#ABACBA" size={18}></ArrowRight>
{/* library */}
@@ -88,7 +112,7 @@ export default (
size={48}
className="mr-2"
/>
<p className="text-sm text-ink-dull">{props.library_name}</p>
<p className="text-sm text-ink-dull">{props.data.sync_group.library.name}</p>
</div>
</div>
</Dialog>

View File

@@ -96,27 +96,7 @@ export function SpacedriveInterfaceRoot({ children }: PropsWithChildren) {
console.log('Received cloud service notification', d);
switch (d.kind) {
case 'ReceivedJoinSyncGroupRequest':
// WARNING: This is a debug solution to accept the device into the sync group. THIS SHOULD NOT MAKE IT TO PRODUCTION
userResponse.mutate({
kind: 'AcceptDeviceInSyncGroup',
data: {
ticket: d.data.ticket,
accepted: {
id: d.data.sync_group.library.pub_id,
name: d.data.sync_group.library.name,
description: null
}
}
});
// TODO: Move the code above into the dialog below (@Rocky43007)
// dialogManager.create((dp) => (
// <RequestAddDialog
// device_model={'MacBookPro'}
// device_name={"Arnab's Macbook"}
// library_name={"Arnab's Library"}
// {...dp}
// />
// ));
dialogManager.create((dp) => <RequestAddDialog data={d.data} {...dp} />);
break;
default:
toast({ title: 'Cloud Service Notification', body: d.kind }, { type: 'info' });

View File

@@ -29,7 +29,7 @@
"app_crashed_description": "出现了一些错误...",
"appearance": "外观",
"appearance_description": "调整客户端的外观。",
"apply": "申请",
"apply": "应用",
"archive": "存档",
"archive_coming_soon": "存档位置功能即将推出……",
"archive_info": "将库中的数据作为存档提取,有利于保留位置的目录结构。",
@@ -225,7 +225,7 @@
"export_library": "导出库",
"export_library_coming_soon": "导出库功能即将推出",
"export_library_description": "将这个库导出到一个文件。",
"extension": "扩",
"extension": "扩展名",
"extensions": "扩展",
"extensions_description": "安装扩展来扩展这个客户端的功能。",
"fahrenheit": "华氏度",
@@ -301,7 +301,7 @@
"grid_gap": "间隙",
"grid_view": "网格视图",
"grid_view_notice_description": "网格视图以缩略图形式显示文件和文件夹,以便直观、快速识别要寻找的文件。",
"hidden": "",
"hidden": "已隐藏",
"hidden_label": "阻止位置及其内容出现在汇总分类、搜索和标签中,除非启用了“显示隐藏项目”。",
"hide_in_library_search": "在库搜索中隐藏",
"hide_in_library_search_description": "在搜索整个库时从结果中隐藏带有此标签的文件。",
@@ -390,7 +390,7 @@
"local": "本地",
"local_locations": "本地位置",
"local_node": "本地节点",
"location": "地点",
"location": "位置",
"location_added_successfully": "位置添加成功。",
"location_connected_tooltip": "位置正在监视变化",
"location_deleted_successfully": "位置删除成功。",

View File

@@ -13,8 +13,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^1.7.17",
"@icons-pack/react-simple-icons": "^9.1.0",
"@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495",
"@phosphor-icons/react": "^2.1.7",
"@phosphor-icons/react": "^2.0.13",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-progress": "^1.0.1",
@@ -27,6 +26,7 @@
"@sd/client": "workspace:*",
"@sd/ui": "workspace:*",
"@sentry/browser": "^7.74.1",
"@spacedrive/rspc-client": "github:spacedriveapp/rspc#path:packages/client&6a77167495",
"@tanstack/react-query": "^5.59",
"@tanstack/react-query-devtools": "^5.59",
"@tanstack/react-table": "^8.20.5",

View File

@@ -1,4 +1,5 @@
import cryptoRandomString from 'crypto-random-string';
import { nonLibraryClient } from '@sd/client';
// NOTE: `crypto` module is not available in RN so this can't be in client
export const generatePassword = (length: number) =>
@@ -12,27 +13,20 @@ export const isNonEmptyObject = (input: object) => Object.keys(input).length > 0
export const AUTH_SERVER_URL = 'https://auth.spacedrive.com';
// export const AUTH_SERVER_URL = 'http://localhost:9420';
export function getTokens() {
if (typeof window === 'undefined') {
return {
refreshToken: '',
accessToken: ''
};
}
export async function getTokens(): Promise<{ accessToken: string; refreshToken: string }> {
const tokens = await nonLibraryClient.query(['keys.get']);
const tokensArray = JSON.parse(tokens);
const refreshToken: string =
JSON.parse(window.localStorage.getItem('frontendCookies') ?? '[]')
tokensArray
.find((cookie: string) => cookie.startsWith('st-refresh-token'))
?.split('=')[1]
.split(';')[0] || '';
const accessToken: string =
JSON.parse(window.localStorage.getItem('frontendCookies') ?? '[]')
tokensArray
.find((cookie: string) => cookie.startsWith('st-access-token'))
?.split('=')[1]
.split(';')[0] || '';
return {
refreshToken,
accessToken
};
return { accessToken, refreshToken };
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -14,6 +14,7 @@ import Dropbox from './Dropbox.png';
import GoogleDrive from './GoogleDrive.png';
import iCloud from './iCloud.png';
import Mega from './Mega.png';
import Transparent from './Transparent.png';
export {
AlphaBg,
@@ -26,5 +27,6 @@ export {
Dropbox,
GoogleDrive,
Mega,
iCloud
iCloud,
Transparent
};

View File

@@ -24,6 +24,8 @@ export type Procedures = {
{ key: "invalidation.test-invalidate", input: never, result: number } |
{ key: "jobs.isActive", input: LibraryArgs<null>, result: boolean } |
{ key: "jobs.reports", input: LibraryArgs<null>, result: JobGroup[] } |
{ key: "keys.get", input: never, result: string } |
{ key: "keys.getEmailAddress", input: LibraryArgs<null>, result: string } |
{ key: "labels.count", input: LibraryArgs<null>, result: number } |
{ key: "labels.get", input: LibraryArgs<number>, result: Label | null } |
{ key: "labels.getForObject", input: LibraryArgs<number>, result: Label[] } |
@@ -108,6 +110,8 @@ export type Procedures = {
{ key: "jobs.objectValidator", input: LibraryArgs<ObjectValidatorArgs>, result: null } |
{ key: "jobs.pause", input: LibraryArgs<string>, result: null } |
{ key: "jobs.resume", input: LibraryArgs<string>, result: null } |
{ key: "keys.save", input: string, result: null } |
{ key: "keys.saveEmailAddress", input: LibraryArgs<string>, result: null } |
{ key: "labels.delete", input: LibraryArgs<number>, result: null } |
{ key: "library.create", input: CreateLibraryArgs, result: LibraryConfigWrapped } |
{ key: "library.delete", input: string, result: null } |
@@ -138,7 +142,7 @@ export type Procedures = {
{ key: "tags.delete", input: LibraryArgs<number>, result: null } |
{ key: "tags.update", input: LibraryArgs<TagUpdateArgs>, result: null } |
{ key: "toggleFeatureFlag", input: BackendFeature, result: null } |
{ key: "volumes.track", input: LibraryArgs<TrackVolumeInput>, result: null } |
{ key: "volumes.track", input: LibraryArgs<VolumeFingerprint>, result: null } |
{ key: "volumes.unmount", input: LibraryArgs<number[]>, result: null },
subscriptions:
{ key: "cloud.listenCloudServicesNotifications", input: never, result: CloudP2PNotifyUser } |
@@ -524,9 +528,13 @@ 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; generate_sync_operations?: boolean; version: LibraryConfigVersion }
cloud_id?: string | null; generate_sync_operations?: boolean; version: LibraryConfigVersion;
/**
* cloud_email_address is the email address of the user who owns the cloud library this library is linked to.
*/
cloud_email_address: string | null }
export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11"
export type LibraryConfigVersion = "V0" | "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9" | "V10" | "V11" | "V12"
export type LibraryConfigWrapped = { uuid: string; instance_id: string; instance_public_key: RemoteIdentity; config: LibraryConfig }
@@ -790,8 +798,6 @@ export type TextMatch = { contains: string } | { startsWith: string } | { endsWi
*/
export type ThumbKey = { shard_hex: string; cas_id: CasId; base_directory_str: string }
export type TrackVolumeInput = { volume_id: VolumeFingerprint }
export type UpdateThumbnailerPreferences = Record<string, never>
export type VideoProps = { pixel_format: string | null; color_range: string | null; bits_per_channel: number | null; color_space: string | null; color_primaries: string | null; color_transfer: string | null; field_order: string | null; chroma_location: string | null; width: number; height: number; aspect_ratio_num: number | null; aspect_ratio_den: number | null; properties: string[] }
@@ -800,6 +806,11 @@ export type VideoProps = { pixel_format: string | null; color_range: string | nu
* Represents a physical or virtual storage volume in the system
*/
export type Volume = {
/**
* Fingerprint of the volume as a hash of its properties, not persisted to the database
* Used as the unique identifier for a volume in this module
*/
fingerprint: VolumeFingerprint | null;
/**
* Database ID (None if not yet committed to database)
*/
@@ -863,11 +874,7 @@ total_bytes_capacity: string;
/**
* Available storage space in bytes
*/
total_bytes_available: string;
/**
* Fingerprint of the volume, not persisted to the database
*/
fingerprint: string }
total_bytes_available: string }
/**
* Events emitted by the Volume Manager when volume state changes
@@ -898,4 +905,7 @@ export type VolumeEvent =
*/
{ VolumeError: { fingerprint: VolumeFingerprint; error: string } }
/**
* A fingerprint of a volume, used to identify it when it is not persisted in the database
*/
export type VolumeFingerprint = number[]

View File

@@ -4,7 +4,7 @@
"license": "GPL-3.0-only",
"main": "src/index.ts",
"types": "src/index.ts",
"sideEffects": false,
"sideEffects": ["./style/index.js", "./style/style.scss"],
"exports": {
".": "./src/index.ts",
"./src/forms": "./src/forms/index.ts",

View File

@@ -7,6 +7,9 @@ function alpha(variableName) {
}
module.exports = function (app, options) {
/**
* @type {import('tailwindcss').Config}
*/
let config = {
content: [
`../../apps/${app}/src/**/*.{ts,tsx,html,stories.tsx}`,
@@ -20,8 +23,7 @@ module.exports = function (app, options) {
sm: '650px',
md: '868px',
lg: '1024px',
xl: '1280px',
...defaultTheme.screens
xl: '1280px'
},
fontFamily: {
sans: [...defaultTheme.fontFamily.sans],
@@ -175,6 +177,12 @@ module.exports = function (app, options) {
]
};
if (app === 'landing') {
console.log('CONFIGURING TAILWIND for Landing');
config.theme.fontFamily.sans = ['var(--font-inter)', ...defaultTheme.fontFamily.sans];
config.theme.fontFamily.plex = ['var(--font-plex-sans)', ...defaultTheme.fontFamily.sans];
}
return config;
};

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.

163
scripts/switch_servers.ps1 Normal file
View File

@@ -0,0 +1,163 @@
# Usage
# .\switch_servers.ps1 dev # Will prompt for relay server modification
# .\switch_servers.ps1 prod # Will prompt for relay server modification
# .\switch_servers.ps1 dev -r # Will automatically modify relay servers
# .\switch_servers.ps1 prod -r # Will automatically modify relay servers
# .\switch_servers.ps1 dev -s # Will skip relay server modification without prompting
# .\switch_servers.ps1 prod -s # Will skip relay server modification without prompting
# File paths
$rustFile = "core/crates/cloud-services/src/lib.rs"
$tsxFile = "interface/util/index.tsx"
$coreFile = "core/src/lib.rs"
# Function to prompt for relay servers change
function Prompt-RelayServers {
while ($true) {
$response = Read-Host "Do you want to modify relay servers as well? (y/n)"
switch ($response.ToLower()) {
'y' { return $true }
'n' { return $false }
default { Write-Host "Please answer y or n." }
}
}
}
# Check arguments
if ($args.Count -lt 1 -or $args.Count -gt 2) {
Write-Host "Usage: <script> <dev|prod> [-r|-s]"
Write-Host " -r: Automatically modify relay servers without prompting"
Write-Host " -s: Skip relay servers modification without prompting"
exit 1
}
# Check environment argument
$env = $args[0]
if ($env -notmatch '^(dev|prod)$') {
Write-Host "Invalid argument. Use 'dev' or 'prod'"
exit 1
}
# Check flags for relay server handling
$modifyRelay = $false
if ($args.Count -eq 2) {
switch ($args[1]) {
'-r' { $modifyRelay = $true }
'-s' { $modifyRelay = $false }
default {
Write-Host "Invalid flag. Use -r or -s"
exit 1
}
}
} else {
$modifyRelay = Prompt-RelayServers
}
# Function to update file content with regex
function Update-FileContent {
param (
[string]$FilePath,
[string]$Pattern,
[string]$Replacement
)
$content = Get-Content $FilePath -Raw
$content = [regex]::Replace($content, $Pattern, $Replacement)
Set-Content -Path $FilePath -Value $content -NoNewline
}
switch ($env) {
'dev' {
# Update Rust file
Update-FileContent $rustFile `
'^pub const AUTH_SERVER_URL.*' `
'// pub const AUTH_SERVER_URL: &str = "https://auth.spacedrive.com";'
Update-FileContent $rustFile `
'^// pub const AUTH_SERVER_URL.*localhost.*' `
'pub const AUTH_SERVER_URL: &str = "http://localhost:9420";'
# Update TypeScript file
Update-FileContent $tsxFile `
"^export const AUTH_SERVER_URL.*" `
"// export const AUTH_SERVER_URL = 'https://auth.spacedrive.com';"
Update-FileContent $tsxFile `
"^// export const AUTH_SERVER_URL.*localhost.*" `
"export const AUTH_SERVER_URL = 'http://localhost:9420';"
if ($modifyRelay) {
# Comment out production relay
Update-FileContent $coreFile `
'^\s*\.unwrap_or_else\(\|_\| "https://relay\.spacedrive\.com:4433/"\.to_string\(\)\)' `
'$0 // .unwrap_or_else(|_| "https://relay.spacedrive.com:4433/".to_string())'
# Uncomment development relay
Update-FileContent $coreFile `
'^\s*// \.unwrap_or_else\(\|_\| "http://localhost:8081/"\.to_string\(\)\)' `
'.unwrap_or_else(|_| "http://localhost:8081/".to_string())'
# Comment out production pkarr
Update-FileContent $coreFile `
'^\s*\.unwrap_or_else\(\|_\| "https://irohdns\.spacedrive\.com/pkarr"\.to_string\(\)\)' `
'$0 // .unwrap_or_else(|_| "https://irohdns.spacedrive.com/pkarr".to_string())'
# Uncomment development pkarr
Update-FileContent $coreFile `
'^\s*// \.unwrap_or_else\(\|_\| "http://localhost:8080/pkarr"\.to_string\(\)\)' `
'.unwrap_or_else(|_| "http://localhost:8080/pkarr".to_string())'
# Comment out production cloud domain
Update-FileContent $coreFile `
'^\s*\.unwrap_or_else\(\|_\| "cloud\.spacedrive\.com"\.to_string\(\)\)' `
'$0 // .unwrap_or_else(|_| "cloud.spacedrive.com".to_string())'
# Uncomment development cloud domain
Update-FileContent $coreFile `
'^\s*// \.unwrap_or_else\(\|_\| "localhost"\.to_string\(\)\)' `
'.unwrap_or_else(|_| "localhost".to_string())'
}
}
'prod' {
# Update Rust file
Update-FileContent $rustFile `
'^// pub const AUTH_SERVER_URL.*spacedrive.*' `
'pub const AUTH_SERVER_URL: &str = "https://auth.spacedrive.com";'
Update-FileContent $rustFile `
'^pub const AUTH_SERVER_URL.*localhost.*' `
'// pub const AUTH_SERVER_URL: &str = "http://localhost:9420";'
# Update TypeScript file
Update-FileContent $tsxFile `
"^// export const AUTH_SERVER_URL.*spacedrive.*" `
"export const AUTH_SERVER_URL = 'https://auth.spacedrive.com';"
Update-FileContent $tsxFile `
"^export const AUTH_SERVER_URL.*localhost.*" `
"// export const AUTH_SERVER_URL = 'http://localhost:9420';"
if ($modifyRelay) {
# Uncomment production relay
Update-FileContent $coreFile `
'^\s*// \.unwrap_or_else\(\|_\| "https://relay\.spacedrive\.com:4433/"\.to_string\(\)\)' `
'.unwrap_or_else(|_| "https://relay.spacedrive.com:4433/".to_string())'
# Comment out development relay
Update-FileContent $coreFile `
'^\s*\.unwrap_or_else\(\|_\| "http://localhost:8081/"\.to_string\(\)\)' `
'// .unwrap_or_else(|_| "http://localhost:8081/".to_string())'
# Uncomment production pkarr
Update-FileContent $coreFile `
'^\s*// \.unwrap_or_else\(\|_\| "https://irohdns\.spacedrive\.com/pkarr"\.to_string\(\)\)' `
'.unwrap_or_else(|_| "https://irohdns.spacedrive.com/pkarr".to_string())'
# Comment out development pkarr
Update-FileContent $coreFile `
'^\s*\.unwrap_or_else\(\|_\| "http://localhost:8080/pkarr"\.to_string\(\)\)' `
'// .unwrap_or_else(|_| "http://localhost:8080/pkarr".to_string())'
# Uncomment production cloud domain
Update-FileContent $coreFile `
'^\s*// \.unwrap_or_else\(\|_\| "cloud\.spacedrive\.com"\.to_string\(\)\)' `
'.unwrap_or_else(|_| "cloud.spacedrive.com".to_string())'
# Comment out development cloud domain
Update-FileContent $coreFile `
'^\s*\.unwrap_or_else\(\|_\| "localhost"\.to_string\(\)\)' `
'// .unwrap_or_else(|_| "localhost".to_string())'
}
}
}

113
scripts/switch_servers.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
## Usage:
# ./switch_servers.sh dev # Will prompt for relay server modification
# ./switch_servers.sh prod # Will prompt for relay server modification
# ./switch_servers.sh dev -r # Will automatically modify relay servers
# ./switch_servers.sh prod -r # Will automatically modify relay servers
# ./switch_servers.sh dev -s # Will skip relay server modification without prompting
# ./switch_servers.sh prod -s # Will skip relay server modification without prompting
# Create cleanup function
cleanup() {
rm -f "$rust_file-e" "$tsx_file-e" "$core_file-e"
}
# Set trap for cleanup on script exit
trap cleanup EXIT
rust_file="core/crates/cloud-services/src/lib.rs"
tsx_file="interface/util/index.tsx"
core_file="core/src/lib.rs"
# Function to prompt for relay servers change
prompt_relay() {
while true; do
read -p "Do you want to modify relay servers as well? (y/n): " yn
case $yn in
[Yy]* ) return 0;;
[Nn]* ) return 1;;
* ) echo "Please answer y or n.";;
esac
done
}
if [ $# -ne 1 ] && [ $# -ne 2 ]; then
echo "Usage: $0 <dev|prod> [-r|-s]"
echo " -r: Automatically modify relay servers without prompting"
echo " -s: Skip relay servers modification without prompting"
exit 1
fi
# Check flags for relay server handling
modify_relay=false
if [ "$2" = "-r" ]; then
modify_relay=true
elif [ "$2" = "-s" ]; then
modify_relay=false
elif [ $# -eq 1 ]; then
prompt_relay && modify_relay=true
fi
case $1 in
"dev")
# Update Rust file
sed -i'' -e 's|^pub const AUTH_SERVER_URL.*|// pub const AUTH_SERVER_URL: \&str = "https:\/\/auth.spacedrive.com";|' "$rust_file"
sed -i'' -e 's|^// pub const AUTH_SERVER_URL.*localhost.*|pub const AUTH_SERVER_URL: \&str = "http:\/\/localhost:9420";|' "$rust_file"
# Update TypeScript file
sed -i'' -e "s|^export const AUTH_SERVER_URL.*|// export const AUTH_SERVER_URL = 'https:\/\/auth.spacedrive.com';|" "$tsx_file"
sed -i'' -e "s|^// export const AUTH_SERVER_URL.*localhost.*|export const AUTH_SERVER_URL = 'http:\/\/localhost:9420';|" "$tsx_file"
# Update relay servers if requested
if [ "$modify_relay" = true ]; then
# Comment out production relay
sed -i'' -e 's@^\([[:space:]]*\)\.unwrap_or_else(|_| "https://relay.spacedrive.com:4433/".to_string())@\1// .unwrap_or_else(|_| "https://relay.spacedrive.com:4433/".to_string())@' "$core_file"
# Uncomment development relay
sed -i'' -e 's@^\([[:space:]]*\)// \.unwrap_or_else(|_| "http://localhost:8081/".to_string())@\1.unwrap_or_else(|_| "http://localhost:8081/".to_string())@' "$core_file"
# Comment out production pkarr
sed -i'' -e 's@^\([[:space:]]*\)\.unwrap_or_else(|_| "https://irohdns.spacedrive.com/pkarr".to_string())@\1// .unwrap_or_else(|_| "https://irohdns.spacedrive.com/pkarr".to_string())@' "$core_file"
# Uncomment development pkarr
sed -i'' -e 's@^\([[:space:]]*\)// \.unwrap_or_else(|_| "http://localhost:8080/pkarr".to_string())@\1.unwrap_or_else(|_| "http://localhost:8080/pkarr".to_string())@' "$core_file"
# Comment out production cloud domain
sed -i'' -e 's@^\([[:space:]]*\)\.unwrap_or_else(|_| "cloud.spacedrive.com".to_string())@\1// .unwrap_or_else(|_| "cloud.spacedrive.com".to_string())@' "$core_file"
# Uncomment development cloud domain
sed -i'' -e 's@^\([[:space:]]*\)// \.unwrap_or_else(|_| "localhost".to_string())@\1.unwrap_or_else(|_| "localhost".to_string())@' "$core_file"
fi
;;
"prod")
# Update Rust file
sed -i'' -e 's|^// pub const AUTH_SERVER_URL.*spacedrive.*|pub const AUTH_SERVER_URL: \&str = "https:\/\/auth.spacedrive.com";|' "$rust_file"
sed -i'' -e 's|^pub const AUTH_SERVER_URL.*localhost.*|// pub const AUTH_SERVER_URL: \&str = "http:\/\/localhost:9420";|' "$rust_file"
# Update TypeScript file
sed -i'' -e "s|^// export const AUTH_SERVER_URL.*spacedrive.*|export const AUTH_SERVER_URL = 'https:\/\/auth.spacedrive.com';|" "$tsx_file"
sed -i'' -e "s|^export const AUTH_SERVER_URL.*localhost.*|// export const AUTH_SERVER_URL = 'http:\/\/localhost:9420';|" "$tsx_file"
# Update relay servers if requested
if [ "$modify_relay" = true ]; then
# Uncomment production relay
sed -i'' -e 's@^\([[:space:]]*\)// \.unwrap_or_else(|_| "https://relay.spacedrive.com:4433/".to_string())@\1.unwrap_or_else(|_| "https://relay.spacedrive.com:4433/".to_string())@' "$core_file"
# Comment out development relay
sed -i'' -e 's@^\([[:space:]]*\)\.unwrap_or_else(|_| "http://localhost:8081/".to_string())@\1// .unwrap_or_else(|_| "http://localhost:8081/".to_string())@' "$core_file"
# Uncomment production pkarr
sed -i'' -e 's@^\([[:space:]]*\)// \.unwrap_or_else(|_| "https://irohdns.spacedrive.com/pkarr".to_string())@\1.unwrap_or_else(|_| "https://irohdns.spacedrive.com/pkarr".to_string())@' "$core_file"
# Comment out development pkarr
sed -i'' -e 's@^\([[:space:]]*\)\.unwrap_or_else(|_| "http://localhost:8080/pkarr".to_string())@\1// .unwrap_or_else(|_| "http://localhost:8080/pkarr".to_string())@' "$core_file"
# Uncomment production cloud domain
sed -i'' -e 's@^\([[:space:]]*\)// \.unwrap_or_else(|_| "cloud.spacedrive.com".to_string())@\1.unwrap_or_else(|_| "cloud.spacedrive.com".to_string())@' "$core_file"
# Comment out development cloud domain
sed -i'' -e 's@^\([[:space:]]*\)\.unwrap_or_else(|_| "localhost".to_string())@\1// .unwrap_or_else(|_| "localhost".to_string())@' "$core_file"
fi
;;
*)
echo "Invalid argument. Use 'dev' or 'prod'"
exit 1
;;
esac