mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 21:36:56 -04:00
feat: update cloud credential management
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -478,3 +478,6 @@ whitepaper/*.log
|
||||
|
||||
# GitHub Actions build artifacts
|
||||
.github/actions/*/dist/
|
||||
|
||||
|
||||
test_data
|
||||
|
||||
@@ -43,12 +43,12 @@ Spacedrive V2 is built with a **Rust-first architecture**. The core Virtual Dist
|
||||
|
||||
Before you begin, ensure you have the following installed:
|
||||
|
||||
| Tool | Version | Required For |
|
||||
| ----- | ------------------------------ | --------------------------------- |
|
||||
| Rust | [`1.81+`](rust-toolchain.toml) | Core development |
|
||||
| Bun | 1.3+ | Desktop app (Tauri) development |
|
||||
| Xcode | Latest | iOS/macOS development |
|
||||
| Git | Any recent version | Version control & submodules |
|
||||
| Tool | Version | Required For |
|
||||
| ----- | ------------------------------ | ------------------------------- |
|
||||
| Rust | [`1.81+`](rust-toolchain.toml) | Core development |
|
||||
| Bun | 1.3+ | Desktop app (Tauri) development |
|
||||
| Xcode | Latest | iOS/macOS development |
|
||||
| Git | Any recent version | Version control & submodules |
|
||||
|
||||
**Note:** Bun is required for the Tauri desktop app. Install from [bun.sh](https://bun.sh). For CLI-only development, Bun is not required.
|
||||
|
||||
@@ -63,7 +63,7 @@ cd spacedrive
|
||||
|
||||
If you plan to work on GUI applications, initialize the submodules:
|
||||
|
||||
Some submodules are private, such as extensions.
|
||||
Some submodules are private, such as tha landing page and future extensions.
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
@@ -115,6 +115,7 @@ cargo build
|
||||
```
|
||||
|
||||
The `xtask setup` command:
|
||||
|
||||
- Downloads prebuilt native dependencies (FFmpeg, etc.)
|
||||
- Creates symlinks for shared libraries
|
||||
- Generates `.cargo/config.toml` with cargo aliases
|
||||
@@ -123,6 +124,7 @@ The `xtask setup` command:
|
||||
**What does `cargo build` build?**
|
||||
|
||||
Running `cargo build` from the project root builds all core Rust components:
|
||||
|
||||
- `sd-cli` - Command-line interface for Spacedrive
|
||||
- `sd-daemon` - Background service (used by GUI apps)
|
||||
- `sd-core` - Core library with VDFS implementation
|
||||
@@ -154,11 +156,13 @@ cargo run -p sd-cli -- search .
|
||||
To avoid typing `cargo run -p sd-cli --` every time, add an alias to your shell config:
|
||||
|
||||
**Bash/Zsh** (`~/.bashrc` or `~/.zshrc`):
|
||||
|
||||
```bash
|
||||
alias sd="~/Projects/spacedrive/target/debug/sd-cli"
|
||||
```
|
||||
|
||||
**Fish** (`~/.config/fish/config.fish`):
|
||||
|
||||
```fish
|
||||
alias sd="~/Projects/spacedrive/target/debug/sd-cli"
|
||||
```
|
||||
@@ -289,8 +293,8 @@ The cross-platform desktop app uses Tauri with a React frontend. Unlike the nati
|
||||
|
||||
In addition to the standard prerequisites, you need:
|
||||
|
||||
| Tool | Version | Required For |
|
||||
| ---- | ------- | ------------ |
|
||||
| Tool | Version | Required For |
|
||||
| ---- | ------- | ----------------------------- |
|
||||
| Bun | 1.3+ | Frontend build and dev server |
|
||||
|
||||
Install Bun from [bun.sh](https://bun.sh) if you don't have it.
|
||||
@@ -316,6 +320,7 @@ bun run tauri:dev
|
||||
```
|
||||
|
||||
The `tauri:dev` command will:
|
||||
|
||||
1. Start the Vite dev server (serves the React frontend)
|
||||
2. Start the sd-daemon (Rust backend)
|
||||
3. Compile and launch the Tauri app
|
||||
@@ -337,12 +342,14 @@ error: proc macro panicked
|
||||
This means you're explicitly building the Tauri package. Solutions:
|
||||
|
||||
**Option A: Use tauri:dev for development (recommended)**
|
||||
|
||||
```bash
|
||||
cd apps/tauri
|
||||
bun run tauri:dev # Starts dev server with hot reload
|
||||
```
|
||||
|
||||
**Option B: Build the frontend first**
|
||||
|
||||
```bash
|
||||
cd apps/tauri
|
||||
bun run build # Creates dist/ folder
|
||||
@@ -364,6 +371,7 @@ bun run build # Frontend only (Vite build)
|
||||
#### Architecture Notes
|
||||
|
||||
The Tauri app consists of:
|
||||
|
||||
- `apps/tauri/` - React frontend (Vite + React)
|
||||
- `apps/tauri/src-tauri/` - Rust Tauri shell
|
||||
- `apps/tauri/sd-tauri-core/` - Tauri-specific core bindings
|
||||
@@ -800,14 +808,14 @@ packages/
|
||||
|
||||
### Quick Reference: Command Mapping
|
||||
|
||||
| V1 Command | V2 Equivalent |
|
||||
| ------------------------ | -------------------------------------------------- |
|
||||
| `bun install` | `bun install` (still required for Tauri app) |
|
||||
| `bun prep` | `cargo run -p xtask -- setup` |
|
||||
| `bun tauri dev` | `cd apps/tauri && bun run tauri:dev` |
|
||||
| `bun mobile ios` | `open apps/ios/Spacedrive.xcodeproj` |
|
||||
| `cargo run -p sd-server` | `cargo run -p sd-cli` or `cargo run -p sd-daemon` |
|
||||
| `bun dev:web` | Not yet available (web in progress) |
|
||||
| V1 Command | V2 Equivalent |
|
||||
| ------------------------ | ------------------------------------------------- |
|
||||
| `bun install` | `bun install` (still required for Tauri app) |
|
||||
| `bun prep` | `cargo run -p xtask -- setup` |
|
||||
| `bun tauri dev` | `cd apps/tauri && bun run tauri:dev` |
|
||||
| `bun mobile ios` | `open apps/ios/Spacedrive.xcodeproj` |
|
||||
| `cargo run -p sd-server` | `cargo run -p sd-cli` or `cargo run -p sd-daemon` |
|
||||
| `bun dev:web` | Not yet available (web in progress) |
|
||||
|
||||
### Getting Help with Migration
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-get-all-windows",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:path:default",
|
||||
"dialog:allow-open",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions for Spacedrive","local":true,"windows":["main"],"permissions":["core:default","core:event:allow-listen","core:event:allow-emit","core:window:allow-create","core:window:allow-close","core:window:allow-get-all-windows","core:webview:allow-create-webview-window","core:path:default","dialog:allow-open","dialog:allow-save","shell:allow-open","fs:allow-home-read-recursive","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}
|
||||
{"default":{"identifier":"default","description":"Default permissions for Spacedrive","local":true,"windows":["main"],"permissions":["core:default","core:event:allow-listen","core:event:allow-emit","core:window:allow-create","core:window:allow-close","core:window:allow-get-all-windows","core:window:allow-start-dragging","core:webview:allow-create-webview-window","core:path:default","dialog:allow-open","dialog:allow-save","shell:allow-open","fs:allow-home-read-recursive","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text"]}}
|
||||
@@ -323,11 +323,9 @@ struct MenuState {
|
||||
|
||||
/// Called from frontend when app is ready to be shown
|
||||
#[tauri::command]
|
||||
async fn app_ready(app: AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
async fn app_ready(window: tauri::Window) {
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
}
|
||||
|
||||
/// Get the daemon socket address for the frontend to connect
|
||||
|
||||
@@ -390,6 +390,7 @@ fn create_window(
|
||||
.map_err(|e| format!("Failed to create window: {}", e))?;
|
||||
|
||||
window.show().ok();
|
||||
window.set_focus().ok();
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LocationCacheDemo,
|
||||
PopoutInspector,
|
||||
QuickPreview,
|
||||
Settings,
|
||||
PlatformProvider,
|
||||
SpacedriveProvider,
|
||||
} from "@sd/interface";
|
||||
@@ -162,12 +163,11 @@ function App() {
|
||||
// Route to different UIs based on window type
|
||||
if (route === "/settings") {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Settings</h1>
|
||||
<p className="text-gray-400">Settings UI will go here</p>
|
||||
</div>
|
||||
</div>
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveProvider client={client}>
|
||||
<Settings />
|
||||
</SpacedriveProvider>
|
||||
</PlatformProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
BIN
core/:memory:
Normal file
BIN
core/:memory:
Normal file
Binary file not shown.
@@ -169,7 +169,8 @@ uuid = { version = "1.11", features = ["serde", "v4", "v5", "v7"] }
|
||||
whoami = "1.5"
|
||||
|
||||
# Secure storage
|
||||
keyring = "3.6"
|
||||
keyring = "3.6" # Only for device key in keychain
|
||||
redb = "2.2" # Encrypted KV store for library keys, credentials, etc.
|
||||
|
||||
# CLI dependencies
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Shared context providing access to core application components.
|
||||
|
||||
use crate::{
|
||||
config::JobLoggingConfig, crypto::library_key_manager::LibraryKeyManager,
|
||||
config::JobLoggingConfig, crypto::key_manager::KeyManager,
|
||||
device::DeviceManager, infra::action::manager::ActionManager, infra::event::EventBus,
|
||||
infra::sync::TransactionManager, library::LibraryManager, service::network::NetworkingService,
|
||||
service::session::SessionStateService, service::sidecar_manager::SidecarManager,
|
||||
@@ -16,7 +16,7 @@ pub struct CoreContext {
|
||||
pub device_manager: Arc<DeviceManager>,
|
||||
pub library_manager: Arc<RwLock<Option<Arc<LibraryManager>>>>,
|
||||
pub volume_manager: Arc<VolumeManager>,
|
||||
pub library_key_manager: Arc<LibraryKeyManager>,
|
||||
pub key_manager: Arc<KeyManager>,
|
||||
// This is wrapped in an RwLock to allow it to be set after initialization
|
||||
pub sidecar_manager: Arc<RwLock<Option<Arc<SidecarManager>>>>,
|
||||
pub action_manager: Arc<RwLock<Option<Arc<ActionManager>>>>,
|
||||
@@ -35,14 +35,14 @@ impl CoreContext {
|
||||
device_manager: Arc<DeviceManager>,
|
||||
library_manager: Option<Arc<LibraryManager>>,
|
||||
volume_manager: Arc<VolumeManager>,
|
||||
library_key_manager: Arc<LibraryKeyManager>,
|
||||
key_manager: Arc<KeyManager>,
|
||||
) -> Self {
|
||||
Self {
|
||||
events,
|
||||
device_manager,
|
||||
library_manager: Arc::new(RwLock::new(library_manager)),
|
||||
volume_manager,
|
||||
library_key_manager,
|
||||
key_manager,
|
||||
sidecar_manager: Arc::new(RwLock::new(None)),
|
||||
action_manager: Arc::new(RwLock::new(None)),
|
||||
networking: Arc::new(RwLock::new(None)),
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::library_key_manager::LibraryKeyManager;
|
||||
use super::key_manager::KeyManager;
|
||||
use std::sync::Arc;
|
||||
|
||||
const KEYRING_SERVICE: &str = "SpacedriveCloudCredentials";
|
||||
@@ -33,7 +33,7 @@ pub enum CloudCredentialError {
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("Library key error: {0}")]
|
||||
LibraryKey(#[from] super::library_key_manager::LibraryKeyError),
|
||||
LibraryKey(#[from] super::key_manager::KeyManagerError),
|
||||
|
||||
#[error("Credential not found: library={0}, volume={1}")]
|
||||
NotFound(Uuid, String),
|
||||
@@ -44,18 +44,18 @@ pub enum CloudCredentialError {
|
||||
|
||||
/// Manages cloud service credentials encrypted with library keys
|
||||
pub struct CloudCredentialManager {
|
||||
library_key_manager: Arc<LibraryKeyManager>,
|
||||
key_manager: Arc<KeyManager>,
|
||||
}
|
||||
|
||||
impl CloudCredentialManager {
|
||||
pub fn new(library_key_manager: Arc<LibraryKeyManager>) -> Self {
|
||||
pub fn new(key_manager: Arc<KeyManager>) -> Self {
|
||||
Self {
|
||||
library_key_manager,
|
||||
key_manager,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store cloud credentials for a volume, encrypted with the library key
|
||||
pub fn store_credential(
|
||||
pub async fn store_credential(
|
||||
&self,
|
||||
library_id: Uuid,
|
||||
volume_fingerprint: &str,
|
||||
@@ -63,8 +63,8 @@ impl CloudCredentialManager {
|
||||
) -> Result<(), CloudCredentialError> {
|
||||
// Get or create library encryption key
|
||||
let library_key = self
|
||||
.library_key_manager
|
||||
.get_or_create_library_key(library_id)?;
|
||||
.key_manager
|
||||
.get_library_key(library_id).await?;
|
||||
|
||||
// Serialize credential
|
||||
let credential_json = serde_json::to_vec(credential)?;
|
||||
@@ -81,7 +81,7 @@ impl CloudCredentialManager {
|
||||
}
|
||||
|
||||
/// Retrieve cloud credentials for a volume, decrypted with the library key
|
||||
pub fn get_credential(
|
||||
pub async fn get_credential(
|
||||
&self,
|
||||
library_id: Uuid,
|
||||
volume_fingerprint: &str,
|
||||
@@ -101,7 +101,7 @@ impl CloudCredentialManager {
|
||||
let encrypted =
|
||||
hex::decode(&encrypted_hex).map_err(|_| CloudCredentialError::InvalidFormat)?;
|
||||
|
||||
let library_key = self.library_key_manager.get_library_key(library_id)?;
|
||||
let library_key = self.key_manager.get_library_key(library_id).await?;
|
||||
let decrypted = self.decrypt_credential(&encrypted, &library_key)?;
|
||||
|
||||
// Deserialize
|
||||
@@ -285,10 +285,14 @@ impl CloudCredential {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_credential() {
|
||||
let library_key_manager = Arc::new(LibraryKeyManager::new().unwrap());
|
||||
let manager = CloudCredentialManager::new(library_key_manager);
|
||||
#[tokio::test]
|
||||
async fn test_encrypt_decrypt_credential() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let key_manager = Arc::new(KeyManager::new_with_fallback(
|
||||
temp_dir.path().to_path_buf(),
|
||||
Some(temp_dir.path().join("device_key")),
|
||||
).unwrap());
|
||||
let manager = CloudCredentialManager::new(key_manager);
|
||||
|
||||
let library_id = Uuid::new_v4();
|
||||
let volume_fp = "test-volume-fingerprint";
|
||||
@@ -304,10 +308,11 @@ mod tests {
|
||||
// Store
|
||||
manager
|
||||
.store_credential(library_id, volume_fp, &credential)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Retrieve
|
||||
let retrieved = manager.get_credential(library_id, volume_fp).unwrap();
|
||||
let retrieved = manager.get_credential(library_id, volume_fp).await.unwrap();
|
||||
|
||||
match (&credential.data, &retrieved.data) {
|
||||
(
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
//! Master encryption key management using OS secure storage
|
||||
|
||||
use keyring::{Entry, Error as KeyringError};
|
||||
use rand::{thread_rng, Rng};
|
||||
use thiserror::Error;
|
||||
|
||||
const KEYRING_SERVICE: &str = "Spacedrive";
|
||||
const DEVICE_KEY_USERNAME: &str = "master_encryption_key";
|
||||
const MASTER_KEY_LENGTH: usize = 32; // 256 bits
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DeviceKeyError {
|
||||
#[error("Keyring error: {0}")]
|
||||
Keyring(#[from] KeyringError),
|
||||
|
||||
#[error("Invalid key format")]
|
||||
InvalidKeyFormat,
|
||||
|
||||
#[error("Key generation failed")]
|
||||
KeyGenerationFailed,
|
||||
}
|
||||
|
||||
pub struct DeviceKeyManager {
|
||||
entry: Entry,
|
||||
fallback_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl DeviceKeyManager {
|
||||
pub fn new() -> Result<Self, DeviceKeyError> {
|
||||
let entry = Entry::new(KEYRING_SERVICE, DEVICE_KEY_USERNAME)?;
|
||||
Ok(Self {
|
||||
entry,
|
||||
fallback_path: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_with_fallback(fallback_path: std::path::PathBuf) -> Result<Self, DeviceKeyError> {
|
||||
let entry = Entry::new(KEYRING_SERVICE, DEVICE_KEY_USERNAME)?;
|
||||
Ok(Self {
|
||||
entry,
|
||||
fallback_path: Some(fallback_path),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test(service: &str, username: &str) -> Result<Self, DeviceKeyError> {
|
||||
let entry = Entry::new(service, username)?;
|
||||
Ok(Self {
|
||||
entry,
|
||||
fallback_path: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_or_create_master_key(&self) -> Result<[u8; MASTER_KEY_LENGTH], DeviceKeyError> {
|
||||
// Try keyring first
|
||||
match self.entry.get_password() {
|
||||
Ok(key_hex) => {
|
||||
let key_bytes =
|
||||
hex::decode(key_hex).map_err(|_| DeviceKeyError::InvalidKeyFormat)?;
|
||||
|
||||
if key_bytes.len() != MASTER_KEY_LENGTH {
|
||||
return Err(DeviceKeyError::InvalidKeyFormat);
|
||||
}
|
||||
|
||||
let mut key = [0u8; MASTER_KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
Ok(key)
|
||||
}
|
||||
Err(KeyringError::NoEntry) => {
|
||||
// Check fallback file if keyring has no entry
|
||||
if let Some(ref path) = self.fallback_path {
|
||||
if path.exists() {
|
||||
if let Ok(key_hex) = std::fs::read_to_string(path) {
|
||||
if let Ok(key_bytes) = hex::decode(key_hex.trim()) {
|
||||
if key_bytes.len() == MASTER_KEY_LENGTH {
|
||||
let mut key = [0u8; MASTER_KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
// Also save to keyring for future use
|
||||
let _ = self.entry.set_password(&key_hex.trim());
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
let key = self.generate_new_master_key()?;
|
||||
let key_hex = hex::encode(key);
|
||||
|
||||
// Save to keyring
|
||||
self.entry.set_password(&key_hex)?;
|
||||
|
||||
// Also save to fallback file if specified
|
||||
if let Some(ref path) = self.fallback_path {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(path, &key_hex);
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => {
|
||||
// If keyring fails, try fallback file
|
||||
if let Some(ref path) = self.fallback_path {
|
||||
if path.exists() {
|
||||
if let Ok(key_hex) = std::fs::read_to_string(path) {
|
||||
if let Ok(key_bytes) = hex::decode(key_hex.trim()) {
|
||||
if key_bytes.len() == MASTER_KEY_LENGTH {
|
||||
let mut key = [0u8; MASTER_KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(DeviceKeyError::Keyring(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_master_key(&self) -> Result<[u8; MASTER_KEY_LENGTH], DeviceKeyError> {
|
||||
// Try keyring first
|
||||
match self.entry.get_password() {
|
||||
Ok(key_hex) => {
|
||||
let key_bytes =
|
||||
hex::decode(key_hex).map_err(|_| DeviceKeyError::InvalidKeyFormat)?;
|
||||
|
||||
if key_bytes.len() != MASTER_KEY_LENGTH {
|
||||
return Err(DeviceKeyError::InvalidKeyFormat);
|
||||
}
|
||||
|
||||
let mut key = [0u8; MASTER_KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
Ok(key)
|
||||
}
|
||||
Err(_) => {
|
||||
// If keyring fails, try fallback file
|
||||
if let Some(ref path) = self.fallback_path {
|
||||
if path.exists() {
|
||||
if let Ok(key_hex) = std::fs::read_to_string(path) {
|
||||
if let Ok(key_bytes) = hex::decode(key_hex.trim()) {
|
||||
if key_bytes.len() == MASTER_KEY_LENGTH {
|
||||
let mut key = [0u8; MASTER_KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(DeviceKeyError::Keyring(KeyringError::NoEntry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_master_key_hex(&self) -> Result<String, DeviceKeyError> {
|
||||
let key = self.get_master_key()?;
|
||||
Ok(hex::encode(key))
|
||||
}
|
||||
|
||||
fn generate_new_master_key(&self) -> Result<[u8; MASTER_KEY_LENGTH], DeviceKeyError> {
|
||||
let mut key = [0u8; MASTER_KEY_LENGTH];
|
||||
thread_rng().fill(&mut key);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn regenerate_master_key(&self) -> Result<[u8; MASTER_KEY_LENGTH], DeviceKeyError> {
|
||||
let key = self.generate_new_master_key()?;
|
||||
let key_hex = hex::encode(key);
|
||||
self.entry.set_password(&key_hex)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn delete_master_key(&self) -> Result<(), DeviceKeyError> {
|
||||
self.entry.delete_credential()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use keyring::Entry;
|
||||
|
||||
const TEST_SERVICE: &str = "SpacedriveTest";
|
||||
const TEST_USERNAME: &str = "test_master_key";
|
||||
|
||||
fn create_test_manager() -> DeviceKeyManager {
|
||||
let entry = Entry::new(TEST_SERVICE, TEST_USERNAME).unwrap();
|
||||
DeviceKeyManager {
|
||||
entry,
|
||||
fallback_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_test_key() {
|
||||
let entry = Entry::new(TEST_SERVICE, TEST_USERNAME).unwrap();
|
||||
let _ = entry.delete_credential();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_retrieve_master_key() {
|
||||
cleanup_test_key();
|
||||
let manager = create_test_manager();
|
||||
|
||||
let key1 = manager.get_or_create_master_key().unwrap();
|
||||
let key2 = manager.get_master_key().unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
assert_eq!(key1.len(), MASTER_KEY_LENGTH);
|
||||
|
||||
cleanup_test_key();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_key_persistence() {
|
||||
cleanup_test_key();
|
||||
let manager1 = create_test_manager();
|
||||
let key1 = manager1.get_or_create_master_key().unwrap();
|
||||
|
||||
let manager2 = create_test_manager();
|
||||
let key2 = manager2.get_master_key().unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
|
||||
cleanup_test_key();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_master_key() {
|
||||
cleanup_test_key();
|
||||
let manager = create_test_manager();
|
||||
|
||||
let key1 = manager.get_or_create_master_key().unwrap();
|
||||
let key2 = manager.regenerate_master_key().unwrap();
|
||||
|
||||
assert_ne!(key1, key2);
|
||||
assert_eq!(key2.len(), MASTER_KEY_LENGTH);
|
||||
|
||||
let key3 = manager.get_master_key().unwrap();
|
||||
assert_eq!(key2, key3);
|
||||
|
||||
cleanup_test_key();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_representation() {
|
||||
cleanup_test_key();
|
||||
let manager = create_test_manager();
|
||||
|
||||
let key = manager.get_or_create_master_key().unwrap();
|
||||
let hex_key = manager.get_master_key_hex().unwrap();
|
||||
|
||||
assert_eq!(hex_key.len(), MASTER_KEY_LENGTH * 2);
|
||||
assert_eq!(hex::decode(&hex_key).unwrap(), key);
|
||||
|
||||
cleanup_test_key();
|
||||
}
|
||||
}
|
||||
391
core/src/crypto/key_manager.rs
Normal file
391
core/src/crypto/key_manager.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! Unified key management system
|
||||
//!
|
||||
//! Manages all encryption keys in Spacedrive:
|
||||
//! - Device key: Stored in OS keychain (with file fallback)
|
||||
//! - Library keys: Stored encrypted in redb database
|
||||
//! - Cloud credentials: (future) Stored encrypted in redb database
|
||||
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use keyring::{Entry, Error as KeyringError};
|
||||
use rand::RngCore;
|
||||
use redb::{Database, ReadableTable, TableDefinition};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
const KEYRING_SERVICE: &str = "Spacedrive";
|
||||
const DEVICE_KEY_USERNAME: &str = "device_key";
|
||||
const KEY_LENGTH: usize = 32; // 256 bits
|
||||
|
||||
// redb table for encrypted secrets
|
||||
const SECRETS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("secrets");
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum KeyManagerError {
|
||||
#[error("Keyring error: {0}")]
|
||||
Keyring(#[from] KeyringError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] redb::Error),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
StorageError(#[from] redb::StorageError),
|
||||
|
||||
#[error("Table error: {0}")]
|
||||
TableError(#[from] redb::TableError),
|
||||
|
||||
#[error("Transaction error: {0}")]
|
||||
TransactionError(#[from] redb::TransactionError),
|
||||
|
||||
#[error("Commit error: {0}")]
|
||||
CommitError(#[from] redb::CommitError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
DatabaseError(#[from] redb::DatabaseError),
|
||||
|
||||
#[error("Encryption error: {0}")]
|
||||
Encryption(String),
|
||||
|
||||
#[error("Decryption error: {0}")]
|
||||
Decryption(String),
|
||||
|
||||
#[error("Invalid key format")]
|
||||
InvalidKeyFormat,
|
||||
|
||||
#[error("Key not found: {0}")]
|
||||
KeyNotFound(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Unified key manager for all Spacedrive encryption keys
|
||||
pub struct KeyManager {
|
||||
/// Path to encrypted secrets database
|
||||
db_path: PathBuf,
|
||||
|
||||
/// redb database for encrypted secrets
|
||||
db: Arc<RwLock<Database>>,
|
||||
|
||||
/// Device master key (cached in memory)
|
||||
device_key: Arc<RwLock<Option<[u8; KEY_LENGTH]>>>,
|
||||
|
||||
/// File fallback path for device key (used in tests)
|
||||
device_key_fallback: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl KeyManager {
|
||||
/// Create a new KeyManager
|
||||
pub fn new(data_dir: PathBuf) -> Result<Self, KeyManagerError> {
|
||||
Self::new_with_fallback(data_dir, None)
|
||||
}
|
||||
|
||||
/// Create a new KeyManager with file fallback for device key (for tests)
|
||||
pub fn new_with_fallback(
|
||||
data_dir: PathBuf,
|
||||
device_key_fallback: Option<PathBuf>,
|
||||
) -> Result<Self, KeyManagerError> {
|
||||
let db_path = data_dir.join("secrets.redb");
|
||||
|
||||
// Create or open the redb database
|
||||
let db = Database::create(&db_path)?;
|
||||
let db = Arc::new(RwLock::new(db));
|
||||
|
||||
Ok(Self {
|
||||
db_path,
|
||||
db,
|
||||
device_key: Arc::new(RwLock::new(None)),
|
||||
device_key_fallback,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get or create the device master key
|
||||
pub async fn get_device_key(&self) -> Result<[u8; KEY_LENGTH], KeyManagerError> {
|
||||
// Check if already cached
|
||||
{
|
||||
let cached = self.device_key.read().await;
|
||||
if let Some(key) = *cached {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load from file fallback first (if specified and exists)
|
||||
if let Some(ref path) = self.device_key_fallback {
|
||||
if path.exists() {
|
||||
if let Ok(key_hex) = std::fs::read_to_string(path) {
|
||||
if let Ok(key_bytes) = hex::decode(key_hex.trim()) {
|
||||
if key_bytes.len() == KEY_LENGTH {
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
|
||||
// Cache it
|
||||
*self.device_key.write().await = Some(key);
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File doesn't exist - generate new key and save to file
|
||||
let key = self.generate_key()?;
|
||||
let key_hex = hex::encode(key);
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, &key_hex)?;
|
||||
|
||||
// Cache it
|
||||
*self.device_key.write().await = Some(key);
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// No fallback - use keyring
|
||||
let entry = Entry::new(KEYRING_SERVICE, DEVICE_KEY_USERNAME)?;
|
||||
|
||||
match entry.get_password() {
|
||||
Ok(key_hex) => {
|
||||
let key_bytes = hex::decode(key_hex)
|
||||
.map_err(|_| KeyManagerError::InvalidKeyFormat)?;
|
||||
|
||||
if key_bytes.len() != KEY_LENGTH {
|
||||
return Err(KeyManagerError::InvalidKeyFormat);
|
||||
}
|
||||
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
|
||||
// Cache it
|
||||
*self.device_key.write().await = Some(key);
|
||||
Ok(key)
|
||||
}
|
||||
Err(KeyringError::NoEntry) => {
|
||||
// Generate new device key
|
||||
let key = self.generate_key()?;
|
||||
let key_hex = hex::encode(key);
|
||||
entry.set_password(&key_hex)?;
|
||||
|
||||
// Cache it
|
||||
*self.device_key.write().await = Some(key);
|
||||
Ok(key)
|
||||
}
|
||||
Err(e) => Err(KeyManagerError::Keyring(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a library encryption key (creates if doesn't exist)
|
||||
pub async fn get_library_key(&self, library_id: Uuid) -> Result<[u8; KEY_LENGTH], KeyManagerError> {
|
||||
let key_id = format!("library_{}", library_id);
|
||||
|
||||
// Try to load from encrypted storage
|
||||
let db = self.db.read().await;
|
||||
let read_txn = db.begin_read()?;
|
||||
|
||||
// Handle case where table doesn't exist yet (first time)
|
||||
let table_result = read_txn.open_table(SECRETS_TABLE);
|
||||
|
||||
if let Ok(table) = table_result {
|
||||
if let Some(encrypted_value) = table.get(key_id.as_str())? {
|
||||
drop(table);
|
||||
drop(read_txn);
|
||||
drop(db);
|
||||
|
||||
// Decrypt the library key
|
||||
let device_key = self.get_device_key().await?;
|
||||
let decrypted = self.decrypt(&encrypted_value.value(), &device_key)?;
|
||||
|
||||
if decrypted.len() != KEY_LENGTH {
|
||||
return Err(KeyManagerError::InvalidKeyFormat);
|
||||
}
|
||||
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
key.copy_from_slice(&decrypted);
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
drop(read_txn);
|
||||
drop(db);
|
||||
|
||||
// Key doesn't exist - generate new one
|
||||
let key = self.generate_key()?;
|
||||
|
||||
// Encrypt and store it
|
||||
let device_key = self.get_device_key().await?;
|
||||
let encrypted = self.encrypt(&key, &device_key)?;
|
||||
|
||||
let db = self.db.write().await;
|
||||
let write_txn = db.begin_write()?;
|
||||
{
|
||||
let mut table = write_txn.open_table(SECRETS_TABLE)?;
|
||||
table.insert(key_id.as_str(), encrypted.as_slice())?;
|
||||
}
|
||||
write_txn.commit()?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store an encrypted secret in the KV store
|
||||
pub async fn set_secret(&self, key: &str, value: &[u8]) -> Result<(), KeyManagerError> {
|
||||
let device_key = self.get_device_key().await?;
|
||||
let encrypted = self.encrypt(value, &device_key)?;
|
||||
|
||||
let db = self.db.write().await;
|
||||
let write_txn = db.begin_write()?;
|
||||
{
|
||||
let mut table = write_txn.open_table(SECRETS_TABLE)?;
|
||||
table.insert(key, encrypted.as_slice())?;
|
||||
}
|
||||
write_txn.commit()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a decrypted secret from the KV store
|
||||
pub async fn get_secret(&self, key: &str) -> Result<Vec<u8>, KeyManagerError> {
|
||||
let db = self.db.read().await;
|
||||
let read_txn = db.begin_read()?;
|
||||
let table = read_txn.open_table(SECRETS_TABLE)?;
|
||||
|
||||
let encrypted_value = table
|
||||
.get(key)?
|
||||
.ok_or_else(|| KeyManagerError::KeyNotFound(key.to_string()))?;
|
||||
|
||||
let device_key = self.get_device_key().await?;
|
||||
let decrypted = self.decrypt(&encrypted_value.value(), &device_key)?;
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
/// Delete a secret from the KV store
|
||||
pub async fn delete_secret(&self, key: &str) -> Result<(), KeyManagerError> {
|
||||
let db = self.db.write().await;
|
||||
let write_txn = db.begin_write()?;
|
||||
{
|
||||
let mut table = write_txn.open_table(SECRETS_TABLE)?;
|
||||
table.remove(key)?;
|
||||
}
|
||||
write_txn.commit()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the database and release file locks
|
||||
/// This should be called before dropping KeyManager to ensure clean shutdown
|
||||
pub async fn close(&self) -> Result<(), KeyManagerError> {
|
||||
// Get a write lock and replace with an in-memory database to force file close
|
||||
let mut db_guard = self.db.write().await;
|
||||
// Drop the old database and replace with a dummy in-memory one
|
||||
drop(std::mem::replace(&mut *db_guard, Database::create(":memory:")?));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a new random key
|
||||
fn generate_key(&self) -> Result<[u8; KEY_LENGTH], KeyManagerError> {
|
||||
let mut key = [0u8; KEY_LENGTH];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt data with XChaCha20-Poly1305
|
||||
fn encrypt(&self, data: &[u8], key: &[u8; KEY_LENGTH]) -> Result<Vec<u8>, KeyManagerError> {
|
||||
// Generate random nonce
|
||||
let mut nonce_bytes = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = XNonce::from_slice(&nonce_bytes);
|
||||
|
||||
// Create cipher
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
|
||||
// Encrypt
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, data)
|
||||
.map_err(|e| KeyManagerError::Encryption(e.to_string()))?;
|
||||
|
||||
// Prepend nonce to ciphertext
|
||||
let mut result = nonce.to_vec();
|
||||
result.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Decrypt data with XChaCha20-Poly1305
|
||||
fn decrypt(&self, encrypted: &[u8], key: &[u8; KEY_LENGTH]) -> Result<Vec<u8>, KeyManagerError> {
|
||||
// Extract nonce (first 24 bytes)
|
||||
if encrypted.len() < 24 {
|
||||
return Err(KeyManagerError::Decryption(
|
||||
"Invalid ciphertext length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = encrypted.split_at(24);
|
||||
let nonce = XNonce::from_slice(nonce_bytes);
|
||||
|
||||
// Create cipher
|
||||
let cipher = XChaCha20Poly1305::new(key.into());
|
||||
|
||||
// Decrypt
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| KeyManagerError::Decryption(e.to_string()))?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_device_key_persistence() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let fallback = temp_dir.path().join("device_key.txt");
|
||||
|
||||
let manager1 = KeyManager::new_with_fallback(temp_dir.path().to_path_buf(), Some(fallback.clone())).unwrap();
|
||||
let key1 = manager1.get_device_key().await.unwrap();
|
||||
|
||||
let manager2 = KeyManager::new_with_fallback(temp_dir.path().to_path_buf(), Some(fallback)).unwrap();
|
||||
let key2 = manager2.get_device_key().await.unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_library_key_storage() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = KeyManager::new_with_fallback(
|
||||
temp_dir.path().to_path_buf(),
|
||||
Some(temp_dir.path().join("device_key.txt")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let library_id = Uuid::new_v4();
|
||||
let key1 = manager.get_library_key(library_id).await.unwrap();
|
||||
let key2 = manager.get_library_key(library_id).await.unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_secret_storage() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let manager = KeyManager::new_with_fallback(
|
||||
temp_dir.path().to_path_buf(),
|
||||
Some(temp_dir.path().join("device_key.txt")),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let secret = b"my secret data";
|
||||
manager.set_secret("test_key", secret).await.unwrap();
|
||||
|
||||
let retrieved = manager.get_secret("test_key").await.unwrap();
|
||||
assert_eq!(secret, retrieved.as_slice());
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
//! Library encryption key management using OS secure storage
|
||||
use keyring::{Entry, Error as KeyringError};
|
||||
use rand::RngCore;
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const KEYRING_SERVICE: &str = "SpacedriveLibraryKeys";
|
||||
const LIBRARY_KEY_LENGTH: usize = 32; // 256 bits
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LibraryKeyError {
|
||||
#[error("Keyring error: {0}")]
|
||||
Keyring(#[from] KeyringError),
|
||||
#[error("Invalid key length: expected {LIBRARY_KEY_LENGTH} bytes, got {0}")]
|
||||
InvalidKeyLength(usize),
|
||||
#[error("Key not found for library: {0}")]
|
||||
KeyNotFound(Uuid),
|
||||
#[error("Failed to generate random key")]
|
||||
RandomKeyGenerationFailed,
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub struct LibraryKeyManager {
|
||||
keyring_entry: Entry,
|
||||
}
|
||||
|
||||
impl LibraryKeyManager {
|
||||
pub fn new() -> Result<Self, LibraryKeyError> {
|
||||
let entry = Entry::new(KEYRING_SERVICE, "library_keys_store")?;
|
||||
Ok(Self {
|
||||
keyring_entry: entry,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get or create a library encryption key for a given library ID
|
||||
pub fn get_or_create_library_key(
|
||||
&self,
|
||||
library_id: Uuid,
|
||||
) -> Result<[u8; LIBRARY_KEY_LENGTH], LibraryKeyError> {
|
||||
let mut all_keys = self.load_all_library_keys()?;
|
||||
if let Some(key_hex) = all_keys.get(&library_id) {
|
||||
let key_bytes =
|
||||
hex::decode(key_hex).map_err(|_| LibraryKeyError::InvalidKeyLength(0))?; // Placeholder for actual error
|
||||
if key_bytes.len() != LIBRARY_KEY_LENGTH {
|
||||
return Err(LibraryKeyError::InvalidKeyLength(key_bytes.len()));
|
||||
}
|
||||
let mut key = [0u8; LIBRARY_KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
Ok(key)
|
||||
} else {
|
||||
let new_key = self.generate_new_library_key()?;
|
||||
all_keys.insert(library_id, hex::encode(new_key));
|
||||
self.save_all_library_keys(&all_keys)?;
|
||||
Ok(new_key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a library encryption key for a given library ID
|
||||
pub fn get_library_key(
|
||||
&self,
|
||||
library_id: Uuid,
|
||||
) -> Result<[u8; LIBRARY_KEY_LENGTH], LibraryKeyError> {
|
||||
let all_keys = self.load_all_library_keys()?;
|
||||
let key_hex = all_keys
|
||||
.get(&library_id)
|
||||
.ok_or(LibraryKeyError::KeyNotFound(library_id))?;
|
||||
let key_bytes = hex::decode(key_hex).map_err(|_| LibraryKeyError::InvalidKeyLength(0))?; // Placeholder for actual error
|
||||
if key_bytes.len() != LIBRARY_KEY_LENGTH {
|
||||
return Err(LibraryKeyError::InvalidKeyLength(key_bytes.len()));
|
||||
}
|
||||
let mut key = [0u8; LIBRARY_KEY_LENGTH];
|
||||
key.copy_from_slice(&key_bytes);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store a library encryption key for a given library ID
|
||||
pub fn store_library_key(
|
||||
&self,
|
||||
library_id: Uuid,
|
||||
key: [u8; LIBRARY_KEY_LENGTH],
|
||||
) -> Result<(), LibraryKeyError> {
|
||||
let mut all_keys = self.load_all_library_keys()?;
|
||||
all_keys.insert(library_id, hex::encode(key));
|
||||
self.save_all_library_keys(&all_keys)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a library encryption key for a given library ID
|
||||
pub fn delete_library_key(&self, library_id: Uuid) -> Result<(), LibraryKeyError> {
|
||||
let mut all_keys = self.load_all_library_keys()?;
|
||||
all_keys.remove(&library_id);
|
||||
self.save_all_library_keys(&all_keys)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_new_library_key(&self) -> Result<[u8; LIBRARY_KEY_LENGTH], LibraryKeyError> {
|
||||
use rand::RngCore;
|
||||
let mut key = [0u8; LIBRARY_KEY_LENGTH];
|
||||
rand::thread_rng()
|
||||
.try_fill_bytes(&mut key)
|
||||
.map_err(|_| LibraryKeyError::RandomKeyGenerationFailed)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn load_all_library_keys(&self) -> Result<HashMap<Uuid, String>, LibraryKeyError> {
|
||||
match self.keyring_entry.get_password() {
|
||||
Ok(json_string) => serde_json::from_str(&json_string).map_err(LibraryKeyError::Json),
|
||||
Err(KeyringError::NoEntry) => Ok(HashMap::new()),
|
||||
Err(e) => Err(LibraryKeyError::Keyring(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_library_keys(&self, keys: &HashMap<Uuid, String>) -> Result<(), LibraryKeyError> {
|
||||
let json_string = serde_json::to_string(keys).map_err(LibraryKeyError::Json)?;
|
||||
self.keyring_entry.set_password(&json_string)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Helper to clean up keyring entry after tests
|
||||
struct TestCleanup {
|
||||
manager: LibraryKeyManager,
|
||||
}
|
||||
|
||||
impl Drop for TestCleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.manager.keyring_entry.delete_credential();
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_manager() -> (LibraryKeyManager, TestCleanup) {
|
||||
let manager = LibraryKeyManager::new().unwrap();
|
||||
let cleanup = TestCleanup {
|
||||
manager: LibraryKeyManager::new().unwrap(),
|
||||
};
|
||||
// Ensure a clean state before test
|
||||
let _ = manager.keyring_entry.delete_credential();
|
||||
(manager, cleanup)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_retrieve_library_key() {
|
||||
let (manager, _cleanup) = create_test_manager();
|
||||
let library_id = Uuid::new_v4();
|
||||
|
||||
let key1 = manager.get_or_create_library_key(library_id).unwrap();
|
||||
let key2 = manager.get_library_key(library_id).unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
assert_eq!(key1.len(), LIBRARY_KEY_LENGTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_library_key_persistence() {
|
||||
let (manager1, _cleanup) = create_test_manager();
|
||||
let library_id = Uuid::new_v4();
|
||||
|
||||
let key1 = manager1.get_or_create_library_key(library_id).unwrap();
|
||||
drop(manager1); // Simulate application restart
|
||||
|
||||
let (manager2, _cleanup2) = create_test_manager();
|
||||
let key2 = manager2.get_library_key(library_id).unwrap();
|
||||
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_delete_library_key() {
|
||||
let (manager, _cleanup) = create_test_manager();
|
||||
let library_id = Uuid::new_v4();
|
||||
let mut test_key = [0u8; LIBRARY_KEY_LENGTH];
|
||||
rand::thread_rng().fill_bytes(&mut test_key);
|
||||
|
||||
manager.store_library_key(library_id, test_key).unwrap();
|
||||
let retrieved_key = manager.get_library_key(library_id).unwrap();
|
||||
assert_eq!(test_key, retrieved_key);
|
||||
|
||||
manager.delete_library_key(library_id).unwrap();
|
||||
let result = manager.get_library_key(library_id);
|
||||
assert!(matches!(result, Err(LibraryKeyError::KeyNotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_library_keys() {
|
||||
let (manager, _cleanup) = create_test_manager();
|
||||
let library_id1 = Uuid::new_v4();
|
||||
let library_id2 = Uuid::new_v4();
|
||||
|
||||
let key1 = manager.get_or_create_library_key(library_id1).unwrap();
|
||||
let key2 = manager.get_or_create_library_key(library_id2).unwrap();
|
||||
|
||||
assert_ne!(key1, key2);
|
||||
|
||||
let retrieved_key1 = manager.get_library_key(library_id1).unwrap();
|
||||
let retrieved_key2 = manager.get_library_key(library_id2).unwrap();
|
||||
|
||||
assert_eq!(key1, retrieved_key1);
|
||||
assert_eq!(key2, retrieved_key2);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod cloud_credentials;
|
||||
pub mod device_key_manager;
|
||||
pub mod library_key_manager;
|
||||
pub mod key_manager;
|
||||
|
||||
@@ -6,7 +6,6 @@ mod config;
|
||||
mod id;
|
||||
mod manager;
|
||||
|
||||
pub use crate::crypto::device_key_manager::{DeviceKeyError, DeviceKeyManager};
|
||||
pub use config::DeviceConfig;
|
||||
pub use id::{
|
||||
get_current_device_id, get_current_device_slug, set_current_device_id, set_current_device_slug,
|
||||
|
||||
@@ -230,7 +230,7 @@ impl SdPath {
|
||||
pub fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Physical { device_slug, path } => {
|
||||
format!("local://{}{}", device_slug, path.display())
|
||||
format!("local://{}/{}", device_slug, path.display())
|
||||
}
|
||||
Self::Cloud {
|
||||
service,
|
||||
|
||||
@@ -63,6 +63,81 @@ impl ResourceManager {
|
||||
paths.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Build a File object from an entry model (for space item resolution)
|
||||
async fn build_file_from_entry(
|
||||
entry_model: crate::infra::db::entities::entry::Model,
|
||||
item_type: &crate::domain::ItemType,
|
||||
db: &DatabaseConnection,
|
||||
) -> Option<crate::domain::File> {
|
||||
use crate::domain::{ContentIdentity, ContentKind, File, ItemType, Sidecar, SdPath};
|
||||
use crate::infra::db::entities::{content_identity, sidecar};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
let sd_path = match item_type {
|
||||
ItemType::Path { sd_path } => sd_path.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let content_identity = if let Some(content_id) = entry_model.content_id {
|
||||
content_identity::Entity::find_by_id(content_id)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|ci| ContentIdentity {
|
||||
uuid: ci.uuid.unwrap_or_else(Uuid::new_v4),
|
||||
kind: ContentKind::from_id(ci.kind_id),
|
||||
content_hash: ci.content_hash,
|
||||
integrity_hash: ci.integrity_hash,
|
||||
mime_type_id: ci.mime_type_id,
|
||||
text_content: ci.text_content,
|
||||
total_size: ci.total_size,
|
||||
entry_count: ci.entry_count,
|
||||
first_seen_at: ci.first_seen_at,
|
||||
last_verified_at: ci.last_verified_at,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let sidecars = if let Some(ref ci) = content_identity {
|
||||
if let Some(uuid) = Some(ci.uuid) {
|
||||
sidecar::Entity::find()
|
||||
.filter(sidecar::Column::ContentUuid.eq(uuid))
|
||||
.all(db)
|
||||
.await
|
||||
.ok()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| Sidecar {
|
||||
id: s.id,
|
||||
content_uuid: s.content_uuid,
|
||||
kind: s.kind,
|
||||
variant: s.variant,
|
||||
format: s.format,
|
||||
status: s.status,
|
||||
size: s.size,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.updated_at,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut file = File::from_entity_model(entry_model, sd_path);
|
||||
file.content_identity = content_identity;
|
||||
file.sidecars = sidecars;
|
||||
if let Some(ref ci) = file.content_identity {
|
||||
file.content_kind = ci.kind;
|
||||
}
|
||||
|
||||
Some(file)
|
||||
}
|
||||
|
||||
/// Emit direct ResourceChanged events for simple resources
|
||||
async fn emit_direct_events(&self, resource_type: &str, resource_ids: &[Uuid]) -> Result<()> {
|
||||
use crate::domain::{GroupType, ItemType, Space, SpaceGroup, SpaceItem};
|
||||
@@ -247,6 +322,19 @@ impl ResourceManager {
|
||||
))
|
||||
})?;
|
||||
|
||||
let resolved_file = if let Some(entry_id) = item_model.entry_id {
|
||||
use crate::infra::db::entities::entry;
|
||||
if let Some(entry_model) = entry::Entity::find_by_id(entry_id).one(&*self.db).await? {
|
||||
Self::build_file_from_entry(entry_model, &item_type, &self.db)
|
||||
.await
|
||||
.map(Box::new)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let item = SpaceItem {
|
||||
id: item_model.uuid,
|
||||
space_id,
|
||||
@@ -258,7 +346,7 @@ impl ResourceManager {
|
||||
item_type,
|
||||
order: item_model.order,
|
||||
created_at: item_model.created_at.into(),
|
||||
resolved_file: None, // Not resolved in events
|
||||
resolved_file,
|
||||
};
|
||||
|
||||
self.events.emit(Event::ResourceChanged {
|
||||
|
||||
@@ -301,6 +301,9 @@ pub struct Volume {
|
||||
/// while maintaining the correct cloud resource identifier for backend operations
|
||||
pub cloud_identifier: Option<String>,
|
||||
|
||||
/// Cloud service configuration (service-specific settings like region, endpoint)
|
||||
pub cloud_config: Option<serde_json::Value>,
|
||||
|
||||
/// APFS container information (macOS only)
|
||||
pub apfs_container: Option<ApfsContainer>,
|
||||
|
||||
@@ -569,6 +572,7 @@ impl Volume {
|
||||
hardware_id: None,
|
||||
backend: None,
|
||||
cloud_identifier: None,
|
||||
cloud_config: None,
|
||||
apfs_container: None,
|
||||
container_volume_id: None,
|
||||
path_mappings: Vec::new(),
|
||||
@@ -835,6 +839,7 @@ impl TrackedVolume {
|
||||
hardware_id: self.device_model.clone(),
|
||||
backend: None,
|
||||
cloud_identifier: None,
|
||||
cloud_config: None,
|
||||
apfs_container: None,
|
||||
container_volume_id: None,
|
||||
path_mappings: Vec::new(),
|
||||
|
||||
@@ -38,6 +38,8 @@ pub struct Model {
|
||||
pub auto_track_eligible: Option<bool>,
|
||||
/// Cloud identifier (bucket/drive/container name) for cloud volumes
|
||||
pub cloud_identifier: Option<String>,
|
||||
/// Cloud service configuration (JSON) - stores region, endpoint, etc.
|
||||
pub cloud_config: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -302,6 +304,10 @@ impl Syncable for Model {
|
||||
.get("cloud_identifier")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)),
|
||||
cloud_config: Set(data
|
||||
.get("cloud_config")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)),
|
||||
};
|
||||
|
||||
Entity::insert(active)
|
||||
@@ -325,6 +331,7 @@ impl Syncable for Model {
|
||||
Column::IsUserVisible,
|
||||
Column::AutoTrackEligible,
|
||||
Column::CloudIdentifier,
|
||||
Column::CloudConfig,
|
||||
Column::LastSeenAt,
|
||||
])
|
||||
.to_owned(),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Add cloud_config column to volumes table for storing cloud service configuration
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// Add cloud_config column to volumes table (JSON text for service-specific config)
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Volumes::Table)
|
||||
.add_column(ColumnDef::new(Volumes::CloudConfig).text().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// Remove cloud_config column
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Volumes::Table)
|
||||
.drop_column(Volumes::CloudConfig)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum Volumes {
|
||||
Table,
|
||||
CloudConfig,
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod m20251117_000001_add_blurhash_to_media_data;
|
||||
mod m20251117_000002_add_unique_constraint_to_entries;
|
||||
mod m20251117_000003_add_unique_bytes_to_volumes;
|
||||
mod m20251129_000001_add_entry_id_to_space_items;
|
||||
mod m20251202_000001_add_cloud_config_to_volumes;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -59,6 +60,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20251117_000002_add_unique_constraint_to_entries::Migration),
|
||||
Box::new(m20251117_000003_add_unique_bytes_to_volumes::Migration),
|
||||
Box::new(m20251129_000001_add_entry_id_to_space_items::Migration),
|
||||
Box::new(m20251202_000001_add_cloud_config_to_volumes::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +133,14 @@ impl Core {
|
||||
}
|
||||
drop(config_read);
|
||||
|
||||
// Initialize library key manager
|
||||
let library_key_manager =
|
||||
Arc::new(crate::crypto::library_key_manager::LibraryKeyManager::new()?);
|
||||
// Initialize unified key manager with file fallback
|
||||
let device_key_fallback = data_dir.join("device_key");
|
||||
let key_manager = Arc::new(
|
||||
crate::crypto::key_manager::KeyManager::new_with_fallback(
|
||||
data_dir.clone(),
|
||||
Some(device_key_fallback),
|
||||
)?,
|
||||
);
|
||||
|
||||
// Create the context that will be shared with services
|
||||
let mut context_inner = CoreContext::new(
|
||||
@@ -143,7 +148,7 @@ impl Core {
|
||||
device.clone(),
|
||||
None, // Libraries will be set after context creation
|
||||
volumes.clone(),
|
||||
library_key_manager.clone(),
|
||||
key_manager.clone(),
|
||||
);
|
||||
|
||||
// Enable per-job file logging by default
|
||||
@@ -249,7 +254,7 @@ impl Core {
|
||||
// This restores cloud volumes that were previously added
|
||||
info!("Loading cloud volumes from database...");
|
||||
if let Err(e) = volumes
|
||||
.load_cloud_volumes_from_db(&loaded_libraries, library_key_manager.clone())
|
||||
.load_cloud_volumes_from_db(&loaded_libraries, key_manager.clone())
|
||||
.await
|
||||
{
|
||||
error!("Failed to load cloud volumes from database: {}", e);
|
||||
@@ -262,7 +267,7 @@ impl Core {
|
||||
match services
|
||||
.init_networking(
|
||||
device.clone(),
|
||||
services.library_key_manager.clone(),
|
||||
services.key_manager.clone(),
|
||||
config.read().await.data_dir.clone(),
|
||||
)
|
||||
.await
|
||||
@@ -448,7 +453,7 @@ impl Core {
|
||||
self.services
|
||||
.init_networking(
|
||||
self.device.clone(),
|
||||
self.services.library_key_manager.clone(),
|
||||
self.services.key_manager.clone(),
|
||||
data_dir,
|
||||
)
|
||||
.await?;
|
||||
@@ -528,6 +533,11 @@ impl Core {
|
||||
// Close all libraries
|
||||
self.libraries.close_all().await?;
|
||||
|
||||
// Close KeyManager database to release file locks
|
||||
if let Err(e) = self.context.key_manager.close().await {
|
||||
warn!("Failed to close KeyManager database: {}", e);
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
self.config.write().await.save()?;
|
||||
|
||||
|
||||
@@ -785,8 +785,9 @@ impl LibraryManager {
|
||||
|
||||
// Initialize encryption key
|
||||
context
|
||||
.library_key_manager
|
||||
.get_or_create_library_key(config.id)
|
||||
.key_manager
|
||||
.get_library_key(config.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LibraryError::Other(format!(
|
||||
"Failed to initialize library encryption key: {}",
|
||||
@@ -841,8 +842,9 @@ impl LibraryManager {
|
||||
|
||||
// Initialize encryption key
|
||||
context
|
||||
.library_key_manager
|
||||
.get_or_create_library_key(config.id)
|
||||
.key_manager
|
||||
.get_library_key(config.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LibraryError::Other(format!(
|
||||
"Failed to initialize library encryption key: {}",
|
||||
|
||||
@@ -225,11 +225,27 @@ impl EntryProcessor {
|
||||
// If not in cache, try to find it in the database
|
||||
// This handles cases where parent was created in a previous run
|
||||
let parent_path_str = parent_path.to_string_lossy().to_string();
|
||||
if let Ok(Some(dir_path_record)) = entities::directory_paths::Entity::find()
|
||||
.filter(entities::directory_paths::Column::Path.eq(&parent_path_str))
|
||||
.one(ctx.library_db())
|
||||
.await
|
||||
{
|
||||
|
||||
// For cloud paths, directory paths may have trailing slashes
|
||||
// Try both with and without to handle path normalization differences
|
||||
let parent_with_slash = if !parent_path_str.ends_with('/') && parent_path_str.contains("://") {
|
||||
Some(format!("{}/", parent_path_str))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut query = entities::directory_paths::Entity::find();
|
||||
if let Some(alt_path) = &parent_with_slash {
|
||||
// Try both variants for cloud paths
|
||||
query = query.filter(
|
||||
entities::directory_paths::Column::Path.is_in([&parent_path_str, alt_path])
|
||||
);
|
||||
} else {
|
||||
// Local paths - exact match
|
||||
query = query.filter(entities::directory_paths::Column::Path.eq(&parent_path_str));
|
||||
}
|
||||
|
||||
if let Ok(Some(dir_path_record)) = query.one(ctx.library_db()).await {
|
||||
// Found parent in database, cache it
|
||||
state
|
||||
.entry_id_cache
|
||||
@@ -238,9 +254,10 @@ impl EntryProcessor {
|
||||
} else {
|
||||
// Parent not found - this shouldn't happen with proper sorting
|
||||
ctx.log(format!(
|
||||
"WARNING: Parent not found for {}: {}",
|
||||
"WARNING: Parent not found for {}: {} (tried: {:?})",
|
||||
entry.path.display(),
|
||||
parent_path.display()
|
||||
parent_path.display(),
|
||||
parent_with_slash.as_ref().unwrap_or(&parent_path_str)
|
||||
));
|
||||
None
|
||||
}
|
||||
@@ -333,7 +350,20 @@ impl EntryProcessor {
|
||||
}
|
||||
|
||||
// Cache the entry ID for potential children
|
||||
state.entry_id_cache.insert(entry.path.clone(), result.id);
|
||||
// For directories, normalize the path by removing trailing slash for consistent lookups
|
||||
// since PathBuf::parent() doesn't preserve trailing slashes
|
||||
let cache_key = if entry.kind == EntryKind::Directory {
|
||||
let path_str = entry.path.to_string_lossy();
|
||||
if path_str.ends_with('/') && path_str.contains("://") {
|
||||
// Cloud directory path - remove trailing slash for cache consistency
|
||||
PathBuf::from(path_str.trim_end_matches('/'))
|
||||
} else {
|
||||
entry.path.clone()
|
||||
}
|
||||
} else {
|
||||
entry.path.clone()
|
||||
};
|
||||
state.entry_id_cache.insert(cache_key, result.id);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,21 @@ pub async fn run_processing_phase(
|
||||
.await
|
||||
.map_err(|e| JobError::execution(format!("Failed to resolve location root path: {}", e)))?;
|
||||
|
||||
if !location_root_path.starts_with(&location_actual_path) {
|
||||
// For cloud paths, compare strings instead of PathBuf (cloud paths have empty path component for root)
|
||||
let location_actual_str = location_actual_path.to_string_lossy();
|
||||
let is_cloud_path = location_actual_str.contains("://") && !location_actual_str.starts_with("local://");
|
||||
|
||||
let is_within_boundaries = if is_cloud_path {
|
||||
// For cloud paths, check if the root path matches or is a subpath
|
||||
let root_str = location_root_path.to_string_lossy();
|
||||
// Empty path means root of cloud location, which is always valid
|
||||
root_str.is_empty() || location_actual_str.starts_with(root_str.as_ref())
|
||||
} else {
|
||||
// For local paths, use standard PathBuf comparison
|
||||
location_root_path.starts_with(&location_actual_path)
|
||||
};
|
||||
|
||||
if !is_within_boundaries {
|
||||
return Err(JobError::execution(format!(
|
||||
"SAFETY VIOLATION: Indexing path '{}' is outside location boundaries '{}' (location_id={}). \
|
||||
This indicates a routing bug in the watcher. Aborting to prevent data loss.",
|
||||
|
||||
@@ -86,8 +86,10 @@ impl ToGenericProgress for IndexerProgress {
|
||||
|| self.current_path.contains('/')
|
||||
|| self.current_path.contains('\\')
|
||||
{
|
||||
// Use local device slug for local paths
|
||||
Some(SdPath::local(path_buf))
|
||||
// Try to parse as URI first (for cloud paths), fall back to local path
|
||||
SdPath::from_uri(&self.current_path)
|
||||
.ok()
|
||||
.or_else(|| Some(SdPath::local(path_buf)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ impl LibraryAction for LocationRescanAction {
|
||||
.await
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to get location path: {}", e)))?;
|
||||
let location_path_str = location_path_buf.to_string_lossy().to_string();
|
||||
let location_path = SdPath::local(location_path_buf);
|
||||
let location_path = SdPath::from_uri(&location_path_str)
|
||||
.map_err(|e| ActionError::Internal(format!("Failed to parse location path: {}", e)))?;
|
||||
|
||||
// Determine index mode based on full_rescan flag
|
||||
let mode = if self.input.full_rescan {
|
||||
|
||||
@@ -93,17 +93,8 @@ impl ImageGenerator {
|
||||
let img = sd_images::format_image(&source_path)
|
||||
.map_err(|e| ThumbnailError::other(format!("Failed to load image: {}", e)))?;
|
||||
|
||||
// Generate blurhash from original image for better quality
|
||||
// Wrap in catch_unwind since blurhash library can panic on some images
|
||||
let blurhash = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
crate::ops::media::blurhash::generate_blurhash(&img)
|
||||
}))
|
||||
.ok()
|
||||
.and_then(|result| result.ok())
|
||||
.or_else(|| {
|
||||
tracing::warn!("Failed to generate blurhash for image (caught panic or error)");
|
||||
None
|
||||
});
|
||||
// Blurhash generation disabled for performance
|
||||
let blurhash: Option<String> = None;
|
||||
|
||||
// Calculate target dimensions maintaining aspect ratio
|
||||
let (original_width, original_height) = (img.width(), img.height());
|
||||
@@ -179,50 +170,8 @@ impl VideoGenerator {
|
||||
return Err(ThumbnailError::InvalidQuality(quality));
|
||||
}
|
||||
|
||||
// Generate blurhash from first video frame
|
||||
let source_path_clone = source_path.to_path_buf();
|
||||
let blurhash = tokio::task::spawn_blocking(move || {
|
||||
// Decode first frame to generate blurhash
|
||||
let mut decoder =
|
||||
match sd_ffmpeg::FrameDecoder::new(&source_path_clone, true, false) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to create frame decoder for blurhash: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = decoder.decode_video_frame() {
|
||||
tracing::debug!("Failed to decode video frame for blurhash: {}", e);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get frame as image (scaled to 256px for blurhash generation)
|
||||
let frame = match decoder
|
||||
.get_scaled_video_frame(Some(sd_ffmpeg::ThumbnailSize::Scale(256)), true)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to extract frame for blurhash: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to DynamicImage
|
||||
let img = match image::RgbImage::from_raw(frame.width, frame.height, frame.data) {
|
||||
Some(img) => image::DynamicImage::ImageRgb8(img),
|
||||
None => {
|
||||
tracing::debug!("Failed to create image from frame data");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate blurhash
|
||||
crate::ops::media::blurhash::generate_blurhash(&img).ok()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
// Blurhash generation disabled for performance
|
||||
let blurhash: Option<String> = None;
|
||||
|
||||
// Use sd-ffmpeg helper function to generate thumbnail
|
||||
sd_ffmpeg::to_thumbnail(
|
||||
@@ -294,13 +243,8 @@ impl DocumentGenerator {
|
||||
let img = sd_images::format_image(&source_path)
|
||||
.map_err(|e| ThumbnailError::other(format!("Failed to load PDF: {}", e)))?;
|
||||
|
||||
// Generate blurhash from original image
|
||||
let blurhash = crate::ops::media::blurhash::generate_blurhash(&img)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
tracing::debug!("Failed to generate blurhash for PDF");
|
||||
None
|
||||
});
|
||||
// Blurhash generation disabled for performance
|
||||
let blurhash: Option<String> = None;
|
||||
|
||||
// Calculate target dimensions maintaining aspect ratio
|
||||
let (original_width, original_height) = (img.width(), img.height());
|
||||
|
||||
@@ -97,7 +97,7 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
.map_err(|e| ActionError::InvalidInput(format!("Failed to get device ID: {}", e)))?;
|
||||
let library_id = library.id();
|
||||
|
||||
let (backend, credential, cloud_identifier, mount_point) = match &self.input.config {
|
||||
let (backend, credential, cloud_identifier, mount_point, cloud_config) = match &self.input.config {
|
||||
CloudStorageConfig::S3 {
|
||||
bucket,
|
||||
region,
|
||||
@@ -131,7 +131,12 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
.ensure_unique_mount_point(&desired_mount_point)
|
||||
.await;
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point)
|
||||
let config = serde_json::json!({
|
||||
"region": region,
|
||||
"endpoint": endpoint,
|
||||
});
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point, config)
|
||||
}
|
||||
CloudStorageConfig::GoogleDrive {
|
||||
root,
|
||||
@@ -169,7 +174,11 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
.ensure_unique_mount_point(&desired_mount_point)
|
||||
.await;
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point)
|
||||
let config = serde_json::json!({
|
||||
"root": root,
|
||||
});
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point, config)
|
||||
}
|
||||
CloudStorageConfig::OneDrive {
|
||||
root,
|
||||
@@ -204,7 +213,11 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
.ensure_unique_mount_point(&desired_mount_point)
|
||||
.await;
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point)
|
||||
let config = serde_json::json!({
|
||||
"root": root,
|
||||
});
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point, config)
|
||||
}
|
||||
CloudStorageConfig::Dropbox {
|
||||
root,
|
||||
@@ -239,7 +252,11 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
.ensure_unique_mount_point(&desired_mount_point)
|
||||
.await;
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point)
|
||||
let config = serde_json::json!({
|
||||
"root": root,
|
||||
});
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point, config)
|
||||
}
|
||||
CloudStorageConfig::AzureBlob {
|
||||
container,
|
||||
@@ -272,7 +289,11 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
.ensure_unique_mount_point(&desired_mount_point)
|
||||
.await;
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point)
|
||||
let config = serde_json::json!({
|
||||
"endpoint": endpoint,
|
||||
});
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point, config)
|
||||
}
|
||||
CloudStorageConfig::GoogleCloudStorage {
|
||||
bucket,
|
||||
@@ -303,7 +324,12 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
.ensure_unique_mount_point(&desired_mount_point)
|
||||
.await;
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point)
|
||||
let config = serde_json::json!({
|
||||
"root": root,
|
||||
"endpoint": endpoint,
|
||||
});
|
||||
|
||||
(backend, credential, cloud_identifier, mount_point, config)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,6 +365,7 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
hardware_id: None,
|
||||
backend: Some(backend_arc),
|
||||
cloud_identifier: Some(cloud_identifier),
|
||||
cloud_config: Some(cloud_config),
|
||||
apfs_container: None,
|
||||
container_volume_id: None,
|
||||
path_mappings: Vec::new(),
|
||||
@@ -359,9 +386,10 @@ impl LibraryAction for VolumeAddCloudAction {
|
||||
error_message: None,
|
||||
};
|
||||
|
||||
let credential_manager = CloudCredentialManager::new(context.library_key_manager.clone());
|
||||
let credential_manager = CloudCredentialManager::new(context.key_manager.clone());
|
||||
credential_manager
|
||||
.store_credential(library_id, &fingerprint.0, &credential)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ActionError::InvalidInput(format!("Failed to store credentials: {}", e))
|
||||
})?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Background services management
|
||||
|
||||
use crate::{
|
||||
context::CoreContext, crypto::library_key_manager::LibraryKeyManager, infra::event::EventBus,
|
||||
context::CoreContext, crypto::key_manager::KeyManager, infra::event::EventBus,
|
||||
service::session::SessionStateService,
|
||||
};
|
||||
use anyhow::Result;
|
||||
@@ -41,8 +41,8 @@ pub struct Services {
|
||||
pub volume_monitor: Option<Arc<VolumeMonitorService>>,
|
||||
/// Sidecar manager
|
||||
pub sidecar_manager: Arc<SidecarManager>,
|
||||
/// Library key manager
|
||||
pub library_key_manager: Arc<LibraryKeyManager>,
|
||||
/// Key manager
|
||||
pub key_manager: Arc<KeyManager>,
|
||||
/// Shared context for all services
|
||||
context: Arc<CoreContext>,
|
||||
}
|
||||
@@ -61,7 +61,7 @@ impl Services {
|
||||
let file_sharing = Arc::new(FileSharingService::new(context.clone()));
|
||||
let device = Arc::new(DeviceService::new(context.clone()));
|
||||
let sidecar_manager = Arc::new(SidecarManager::new(context.clone()));
|
||||
let library_key_manager = context.library_key_manager.clone();
|
||||
let key_manager = context.key_manager.clone();
|
||||
Self {
|
||||
location_watcher,
|
||||
file_sharing,
|
||||
@@ -69,7 +69,7 @@ impl Services {
|
||||
networking: None, // Initialized separately when needed
|
||||
volume_monitor: None, // Initialized after library manager is available
|
||||
sidecar_manager,
|
||||
library_key_manager,
|
||||
key_manager,
|
||||
context,
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ impl Services {
|
||||
pub async fn init_networking(
|
||||
&mut self,
|
||||
device_manager: std::sync::Arc<crate::device::DeviceManager>,
|
||||
library_key_manager: std::sync::Arc<crate::crypto::library_key_manager::LibraryKeyManager>,
|
||||
key_manager: std::sync::Arc<crate::crypto::key_manager::KeyManager>,
|
||||
data_dir: impl AsRef<std::path::Path>,
|
||||
) -> Result<()> {
|
||||
use crate::service::network::{utils::logging::ConsoleLogger, NetworkingService};
|
||||
@@ -161,7 +161,7 @@ impl Services {
|
||||
info!("Initializing networking service");
|
||||
let logger = std::sync::Arc::new(ConsoleLogger);
|
||||
let networking_service =
|
||||
NetworkingService::new(device_manager, library_key_manager, data_dir, logger)
|
||||
NetworkingService::new(device_manager, key_manager, data_dir, logger)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create networking service: {}", e))?;
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ impl NetworkingService {
|
||||
/// Create a new networking service
|
||||
pub async fn new(
|
||||
device_manager: Arc<DeviceManager>,
|
||||
library_key_manager: Arc<crate::crypto::library_key_manager::LibraryKeyManager>,
|
||||
key_manager: Arc<crate::crypto::key_manager::KeyManager>,
|
||||
data_dir: impl AsRef<std::path::Path>,
|
||||
logger: Arc<dyn NetworkLogger>,
|
||||
) -> Result<Self> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Persistence for paired devices and their connection info
|
||||
|
||||
use super::{DeviceInfo, SessionKeys};
|
||||
use crate::crypto::device_key_manager::DeviceKeyManager;
|
||||
use crate::service::network::{NetworkingError, Result};
|
||||
use aes_gcm::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
@@ -71,7 +70,7 @@ struct PersistedPairedDevices {
|
||||
pub struct DevicePersistence {
|
||||
data_dir: PathBuf,
|
||||
devices_file: PathBuf,
|
||||
device_key_manager: DeviceKeyManager,
|
||||
device_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl DevicePersistence {
|
||||
@@ -81,19 +80,16 @@ impl DevicePersistence {
|
||||
let networking_dir = data_dir.join("networking");
|
||||
let devices_file = networking_dir.join("paired_devices.json");
|
||||
|
||||
// Use fallback file for master key to ensure consistency
|
||||
// IMPORTANT: Use the root data_dir for the master key, not the networking subdirectory
|
||||
// This ensures consistency with DeviceManager which also uses the root data_dir
|
||||
// Load device key from fallback file (consistent with DeviceManager)
|
||||
let master_key_path = data_dir.join("master_key");
|
||||
let device_key_manager =
|
||||
DeviceKeyManager::new_with_fallback(master_key_path).map_err(|e| {
|
||||
NetworkingError::Protocol(format!("Failed to initialize master key manager: {}", e))
|
||||
})?;
|
||||
let device_key = load_or_create_device_key(&master_key_path).map_err(|e| {
|
||||
NetworkingError::Protocol(format!("Failed to load device key: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
data_dir: networking_dir,
|
||||
devices_file,
|
||||
device_key_manager,
|
||||
device_key,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,14 +103,9 @@ impl DevicePersistence {
|
||||
|
||||
/// Derive encryption key from master key for device persistence
|
||||
fn derive_encryption_key(&self, salt: &[u8]) -> Result<[u8; 32]> {
|
||||
let master_key = self
|
||||
.device_key_manager
|
||||
.get_or_create_master_key()
|
||||
.map_err(|e| {
|
||||
NetworkingError::Protocol(format!("Failed to get or create master key: {}", e))
|
||||
})?;
|
||||
let master_key = &self.device_key;
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(Some(salt), &master_key);
|
||||
let hk = Hkdf::<Sha256>::new(Some(salt), master_key);
|
||||
let mut derived_key = [0u8; 32];
|
||||
hk.expand(b"spacedrive-device-persistence", &mut derived_key)
|
||||
.map_err(|e| NetworkingError::Protocol(format!("Key derivation failed: {}", e)))?;
|
||||
@@ -561,3 +552,28 @@ mod tests {
|
||||
println!("Device data is properly encrypted on disk");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Load device key from file, or create a new one
|
||||
fn load_or_create_device_key(path: &PathBuf) -> std::io::Result<[u8; 32]> {
|
||||
use rand::RngCore;
|
||||
|
||||
// Try to load from file
|
||||
if path.exists() {
|
||||
let data = std::fs::read(path)?;
|
||||
if data.len() == 32 {
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&data);
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new key
|
||||
let mut key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
|
||||
// Save to file
|
||||
std::fs::write(path, &key)?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@@ -67,9 +67,9 @@ pub type Result<T> = std::result::Result<T, NetworkingError>;
|
||||
/// Initialize the new networking system
|
||||
pub async fn init_networking(
|
||||
device_manager: std::sync::Arc<crate::device::DeviceManager>,
|
||||
library_key_manager: std::sync::Arc<crate::crypto::library_key_manager::LibraryKeyManager>,
|
||||
key_manager: std::sync::Arc<crate::crypto::key_manager::KeyManager>,
|
||||
data_dir: impl AsRef<std::path::Path>,
|
||||
) -> Result<NetworkingService> {
|
||||
let logger = std::sync::Arc::new(utils::logging::ConsoleLogger);
|
||||
NetworkingService::new(device_manager, library_key_manager, data_dir, logger).await
|
||||
NetworkingService::new(device_manager, key_manager, data_dir, logger).await
|
||||
}
|
||||
|
||||
@@ -413,6 +413,7 @@ pub fn containers_to_volumes(
|
||||
hardware_id: Some(volume_info.disk_id.clone()),
|
||||
backend: None,
|
||||
cloud_identifier: None,
|
||||
cloud_config: None,
|
||||
apfs_container: Some(container.clone()),
|
||||
container_volume_id: Some(volume_info.disk_id.clone()),
|
||||
path_mappings,
|
||||
|
||||
@@ -135,7 +135,7 @@ impl VolumeManager {
|
||||
pub async fn load_cloud_volumes_from_db(
|
||||
&self,
|
||||
libraries: &[std::sync::Arc<crate::library::Library>],
|
||||
library_key_manager: std::sync::Arc<crate::crypto::library_key_manager::LibraryKeyManager>,
|
||||
key_manager: std::sync::Arc<crate::crypto::key_manager::KeyManager>,
|
||||
) -> VolumeResult<()> {
|
||||
use crate::crypto::cloud_credentials::CloudCredentialManager;
|
||||
|
||||
@@ -166,9 +166,9 @@ impl VolumeManager {
|
||||
}
|
||||
|
||||
// Try to load credentials and recreate the backend
|
||||
let credential_manager = CloudCredentialManager::new(library_key_manager.clone());
|
||||
let credential_manager = CloudCredentialManager::new(key_manager.clone());
|
||||
|
||||
match credential_manager.get_credential(library.id(), &db_volume.fingerprint) {
|
||||
match credential_manager.get_credential(library.id(), &db_volume.fingerprint).await {
|
||||
Ok(credential) => {
|
||||
// Get mount point from database (for display and cache purposes)
|
||||
let mount_point_str = match &db_volume.mount_point {
|
||||
@@ -188,6 +188,12 @@ impl VolumeManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Parse cloud_config JSON if available
|
||||
let cloud_config: Option<serde_json::Value> = db_volume
|
||||
.cloud_config
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
|
||||
let backend_result = match credential.service {
|
||||
crate::volume::CloudServiceType::S3 => {
|
||||
if let crate::crypto::cloud_credentials::CredentialData::AccessKey {
|
||||
@@ -196,12 +202,25 @@ impl VolumeManager {
|
||||
..
|
||||
} = &credential.data
|
||||
{
|
||||
// Extract region from cloud_config, or default to us-east-1
|
||||
let region = cloud_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.get("region"))
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("us-east-1");
|
||||
|
||||
let endpoint = cloud_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.get("endpoint"))
|
||||
.and_then(|e| e.as_str())
|
||||
.map(String::from);
|
||||
|
||||
crate::volume::CloudBackend::new_s3(
|
||||
cloud_identifier,
|
||||
"us-east-1", // Default region
|
||||
region,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
None,
|
||||
endpoint,
|
||||
).await
|
||||
} else {
|
||||
warn!("Invalid credential type for S3 volume {}", fingerprint.0);
|
||||
@@ -330,6 +349,7 @@ impl VolumeManager {
|
||||
hardware_id: None,
|
||||
backend: Some(Arc::new(backend)),
|
||||
cloud_identifier: db_volume.cloud_identifier.clone(),
|
||||
cloud_config,
|
||||
apfs_container: None,
|
||||
container_volume_id: None,
|
||||
path_mappings: Vec::new(),
|
||||
@@ -1238,6 +1258,7 @@ impl VolumeManager {
|
||||
is_user_visible: Set(Some(volume.is_user_visible)),
|
||||
auto_track_eligible: Set(Some(volume.auto_track_eligible)),
|
||||
cloud_identifier: Set(volume.cloud_identifier.clone()),
|
||||
cloud_config: Set(volume.cloud_config.as_ref().map(|c| c.to_string())),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ pub async fn detect_non_apfs_volumes(
|
||||
hardware_id: Some(filesystem.to_string()),
|
||||
backend: None,
|
||||
cloud_identifier: None,
|
||||
cloud_config: None,
|
||||
apfs_container: None,
|
||||
container_volume_id: None,
|
||||
path_mappings: Vec::new(),
|
||||
|
||||
@@ -81,16 +81,12 @@ async fn test_job_resumption_at_various_points() {
|
||||
.await
|
||||
.expect("Failed to prepare test data");
|
||||
|
||||
// Define interruption points to test with realistic event counts for large datasets
|
||||
// With 500k files, we expect many more progress events in each phase
|
||||
// For quick testing during development, comment out all but one interruption point
|
||||
// Define interruption points to test with realistic event counts for smaller datasets
|
||||
// For Downloads folder, use lower event counts since there are fewer files
|
||||
let interruption_points = vec![
|
||||
// InterruptionPoint::DiscoveryAfterEvents(50), // Interrupt after 50 discovery events (should hit with 500k files)
|
||||
InterruptionPoint::ProcessingAfterEvents(10), // Interrupt after 10 processing events (reduced for faster testing)
|
||||
// InterruptionPoint::ProcessingAfterEvents(10), // Interrupt after 10 processing events (reduced for faster testing)
|
||||
// InterruptionPoint::ContentIdentificationAfterEvents(200), // Interrupt after 200 content ID events (most likely to hit)
|
||||
// InterruptionPoint::ContentIdentificationAfterEvents(500), // Interrupt later in content ID phase
|
||||
// InterruptionPoint::Aggregation, // Interrupt immediately when aggregation starts
|
||||
InterruptionPoint::DiscoveryAfterEvents(2), // Interrupt early in discovery
|
||||
InterruptionPoint::ProcessingAfterEvents(2), // Interrupt early in processing
|
||||
InterruptionPoint::ContentIdentificationAfterEvents(2), // Interrupt early in content ID
|
||||
];
|
||||
|
||||
let mut results = Vec::new();
|
||||
@@ -134,57 +130,18 @@ async fn test_job_resumption_at_various_points() {
|
||||
|
||||
/// Generate test data using benchmark data generation
|
||||
async fn generate_test_data() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
info!("Current directory: {}", current_dir.display());
|
||||
// Use Downloads folder instead of benchmark data
|
||||
let home_dir = std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|_| std::env::current_dir())?;
|
||||
|
||||
// Use relative path from workspace root (tests run from core/ directory)
|
||||
let indexing_data_path = if current_dir.ends_with("core") {
|
||||
// When running from core/, the path is relative to parent (workspace root)
|
||||
current_dir.parent().unwrap().join(TEST_INDEXING_DATA_PATH)
|
||||
} else {
|
||||
// When running from workspace root, use path as-is
|
||||
current_dir.join(TEST_INDEXING_DATA_PATH)
|
||||
};
|
||||
let indexing_data_path = home_dir.join("Downloads");
|
||||
|
||||
// Check if data already exists
|
||||
if indexing_data_path.exists() && indexing_data_path.is_dir() {
|
||||
// Check if directory has files
|
||||
let entries: Vec<_> =
|
||||
std::fs::read_dir(&indexing_data_path)?.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if !entries.is_empty() {
|
||||
info!(
|
||||
"Test data already exists at: {}, skipping generation",
|
||||
indexing_data_path.display()
|
||||
);
|
||||
return Ok(indexing_data_path);
|
||||
}
|
||||
if !indexing_data_path.exists() {
|
||||
return Err(format!("Downloads folder does not exist at: {}", indexing_data_path.display()).into());
|
||||
}
|
||||
|
||||
// Run benchmark data generation using existing recipe
|
||||
// info!("Generating test data using recipe: {}", TEST_RECIPE_NAME);
|
||||
// let recipe_path = current_dir.join("benchmarks/recipes").join(format!("{}.yaml", TEST_RECIPE_NAME));
|
||||
// info!("Recipe path: {}", recipe_path.display());
|
||||
|
||||
// let output = Command::new("cargo")
|
||||
// .args([
|
||||
// "run", "-p", "sd-bench", "--bin", "sd-bench", "--",
|
||||
// "mkdata",
|
||||
// "--recipe", recipe_path.to_str().unwrap(),
|
||||
// ])
|
||||
// .current_dir(¤t_dir)
|
||||
// .output()?;
|
||||
|
||||
// if !output.status.success() {
|
||||
// let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// return Err(format!(
|
||||
// "Benchmark data generation failed:\nSTDOUT: {}\nSTDERR: {}",
|
||||
// stdout, stderr
|
||||
// ).into());
|
||||
// }
|
||||
|
||||
// info!("Generated test data at: {}", indexing_data_path.display());
|
||||
info!("Using Downloads folder at: {}", indexing_data_path.display());
|
||||
Ok(indexing_data_path)
|
||||
}
|
||||
|
||||
@@ -225,8 +182,8 @@ async fn test_single_interruption_point(
|
||||
let interrupt_result =
|
||||
start_and_interrupt_job(&test_setup, indexing_data_path, &interruption_point).await;
|
||||
|
||||
let job_id = match interrupt_result {
|
||||
Ok(job_id) => job_id,
|
||||
let (job_id, library_id) = match interrupt_result {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
return TestResult {
|
||||
interruption_point,
|
||||
@@ -238,11 +195,36 @@ async fn test_single_interruption_point(
|
||||
}
|
||||
};
|
||||
|
||||
// Brief pause to ensure clean shutdown
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
// Clean up SQLite lock files to ensure clean restart
|
||||
info!("Cleaning up database lock files...");
|
||||
let library_dir = test_setup.data_dir().join("libraries");
|
||||
if library_dir.exists() {
|
||||
for entry in std::fs::read_dir(&library_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// Remove SQLite WAL and SHM files
|
||||
let db_path = path.join("library.db");
|
||||
let wal_path = path.join("library.db-wal");
|
||||
let shm_path = path.join("library.db-shm");
|
||||
|
||||
for lock_file in [wal_path, shm_path] {
|
||||
if lock_file.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&lock_file) {
|
||||
warn!("Failed to remove {}: {}", lock_file.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Longer pause to ensure all database locks are released
|
||||
info!("Waiting for database locks to release...");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Phase 2: Resume and complete the job
|
||||
let resume_result = resume_and_complete_job(&test_setup, indexing_data_path, job_id).await;
|
||||
let resume_result = resume_and_complete_job(&test_setup, indexing_data_path, job_id, library_id).await;
|
||||
|
||||
match resume_result {
|
||||
Ok((job_log_path, test_log_path)) => TestResult {
|
||||
@@ -267,7 +249,7 @@ async fn start_and_interrupt_job(
|
||||
test_setup: &IntegrationTestSetup,
|
||||
indexing_data_path: &PathBuf,
|
||||
interruption_point: &InterruptionPoint,
|
||||
) -> Result<Uuid, Box<dyn std::error::Error>> {
|
||||
) -> Result<(Uuid, Uuid), Box<dyn std::error::Error>> {
|
||||
info!(
|
||||
"Starting job and waiting for interruption point: {:?}",
|
||||
interruption_point
|
||||
@@ -284,11 +266,14 @@ async fn start_and_interrupt_job(
|
||||
.create_library("Test Library".to_string(), None, core_context.clone())
|
||||
.await?;
|
||||
|
||||
let library_id = library.id();
|
||||
|
||||
// Create location add action to automatically trigger indexing
|
||||
let location_input = LocationAddInput {
|
||||
path: SdPath::local(indexing_data_path.clone()),
|
||||
name: Some("Test Location".to_string()),
|
||||
mode: IndexMode::Content,
|
||||
job_policies: None,
|
||||
};
|
||||
|
||||
let location_action = LocationAddAction::from_input(location_input)
|
||||
@@ -451,15 +436,40 @@ async fn start_and_interrupt_job(
|
||||
if phase_order_failed.load(Ordering::Relaxed) {
|
||||
// Force kill the core immediately
|
||||
core.shutdown().await?;
|
||||
|
||||
// Delete the redb database to release file locks (test workaround)
|
||||
let secrets_db_path = test_setup.data_dir().join("secrets.redb");
|
||||
if secrets_db_path.exists() {
|
||||
if let Err(e) = tokio::fs::remove_file(&secrets_db_path).await {
|
||||
warn!("Failed to remove secrets database: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
Err(
|
||||
"Phase order failure: reached a later phase before hitting interruption point"
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
info!("Interruption point reached, shutting down core");
|
||||
let result_job_id = job_id;
|
||||
|
||||
// Shutdown core gracefully
|
||||
core.shutdown().await?;
|
||||
Ok(job_id)
|
||||
|
||||
// Delete the redb database to release file locks (test workaround)
|
||||
let secrets_db_path = test_setup.data_dir().join("secrets.redb");
|
||||
if secrets_db_path.exists() {
|
||||
if let Err(e) = tokio::fs::remove_file(&secrets_db_path).await {
|
||||
warn!("Failed to remove secrets database: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Brief delay to ensure cleanup
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
Ok((result_job_id, library_id))
|
||||
}
|
||||
}
|
||||
Ok(None) => Err("Interrupt channel closed unexpectedly".into()),
|
||||
@@ -472,6 +482,7 @@ async fn resume_and_complete_job(
|
||||
test_setup: &IntegrationTestSetup,
|
||||
_indexing_data_path: &PathBuf,
|
||||
job_id: Uuid,
|
||||
library_id: Uuid,
|
||||
) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
|
||||
info!("Resuming job {} and waiting for completion", job_id);
|
||||
|
||||
@@ -479,9 +490,12 @@ async fn resume_and_complete_job(
|
||||
let core = test_setup.create_core().await?;
|
||||
let core_context = core.context.clone();
|
||||
|
||||
// Get the library (should auto-load)
|
||||
// Get the specific library by ID (don't rely on auto-load which may fail due to locks)
|
||||
let libraries = core_context.libraries().await.list().await;
|
||||
let library = libraries.first().ok_or("No library found after restart")?;
|
||||
let library = libraries
|
||||
.iter()
|
||||
.find(|lib| lib.id() == library_id)
|
||||
.ok_or_else(|| format!("Library {} not found after restart. Found {} libraries", library_id, libraries.len()))?;
|
||||
|
||||
// Check job status immediately after core initialization
|
||||
// Jobs may have already completed during the core startup process
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
"name": "@sd/assets",
|
||||
"version": "1.0.0",
|
||||
"license": "GPL-3.0-only",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"name": "@spacedriveapp/assets"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"icons",
|
||||
"images",
|
||||
"lottie",
|
||||
"svgs",
|
||||
"videos",
|
||||
"util"
|
||||
],
|
||||
"scripts": {
|
||||
"gen": "node ./scripts/generate.mjs"
|
||||
},
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@phosphor-icons/react": "^2.1.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
|
||||
@@ -29,9 +29,14 @@ import {
|
||||
PREVIEW_LAYER_ID,
|
||||
} from "./components/QuickPreview";
|
||||
import { createExplorerRouter } from "./router";
|
||||
import { useNormalizedCache } from "./context";
|
||||
import { useNormalizedCache, useLibraryMutation } from "./context";
|
||||
import { usePlatform } from "./platform";
|
||||
import type { LocationInfo } from "@sd/ts-client";
|
||||
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, pointerWithin, rectIntersection } from "@dnd-kit/core";
|
||||
import type { CollisionDetection } from "@dnd-kit/core";
|
||||
import { useState } from "react";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { File as FileComponent } from "./components/Explorer/File";
|
||||
|
||||
interface AppProps {
|
||||
client: SpacedriveClient;
|
||||
@@ -215,18 +220,164 @@ export function ExplorerLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DndWrapper - Global drag-and-drop coordinator
|
||||
*
|
||||
* Handles all drag-and-drop operations in the Explorer using @dnd-kit/core.
|
||||
*
|
||||
* Drop Actions:
|
||||
*
|
||||
* 1. insert-before / insert-after
|
||||
* - Pins a file to the sidebar before/after an existing item
|
||||
* - Shows a blue line indicator
|
||||
* - Data: { action, itemId }
|
||||
*
|
||||
* 2. move-into
|
||||
* - Moves a file into a location/volume/folder
|
||||
* - Shows a blue ring around the target
|
||||
* - Data: { action, targetType, targetId, targetPath? }
|
||||
* - targetType: "location" | "volume" | "folder"
|
||||
* - targetPath: SdPath (for locations, directly usable)
|
||||
*
|
||||
* 3. type: "space" | "group"
|
||||
* - Legacy: Drops on the space root or group area (no specific item)
|
||||
* - Adds item to space/group
|
||||
* - Data: { type, spaceId, groupId? }
|
||||
*/
|
||||
function DndWrapper({ children }: { children: React.ReactNode }) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // Require 8px movement before activating drag
|
||||
},
|
||||
})
|
||||
);
|
||||
const addItem = useLibraryMutation("spaces.add_item");
|
||||
const [activeItem, setActiveItem] = useState<any>(null);
|
||||
|
||||
// Custom collision detection: prefer -top zones over -bottom zones to avoid double lines
|
||||
const customCollision: CollisionDetection = (args) => {
|
||||
const collisions = pointerWithin(args);
|
||||
if (!collisions || collisions.length === 0) return collisions;
|
||||
|
||||
// If we have multiple collisions, prefer -top over -bottom
|
||||
const hasTop = collisions.find(c => String(c.id).endsWith('-top'));
|
||||
const hasMiddle = collisions.find(c => String(c.id).endsWith('-middle'));
|
||||
|
||||
if (hasMiddle) return [hasMiddle]; // Middle zone takes priority
|
||||
if (hasTop) return [hasTop]; // Top zone over bottom
|
||||
return [collisions[0]]; // First collision
|
||||
};
|
||||
|
||||
const handleDragStart = (event: any) => {
|
||||
setActiveItem(event.active.data.current);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: any) => {
|
||||
const { active, over } = event;
|
||||
|
||||
setActiveItem(null);
|
||||
|
||||
if (!over || !active.data.current) return;
|
||||
|
||||
const dragData = active.data.current;
|
||||
const dropData = over.data.current;
|
||||
|
||||
if (!dragData || dragData.type !== "explorer-file") return;
|
||||
|
||||
// Insert before/after sidebar items (adds item to space/group)
|
||||
if (dropData?.action === "insert-before" || dropData?.action === "insert-after") {
|
||||
if (!dropData.spaceId) return;
|
||||
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: dropData.groupId || null,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
// TODO: Implement proper ordering relative to itemId
|
||||
} catch (err) {
|
||||
console.error("Failed to add item:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Move file into location/volume/folder
|
||||
if (dropData?.action === "move-into") {
|
||||
// TODO: Implement with files.move mutation based on targetType
|
||||
// - location: Use targetPath
|
||||
// - volume: Look up volume root path
|
||||
// - folder: Use targetPath from Path item
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop on space root area (adds to space)
|
||||
if (dropData?.type === "space" && dragData.type === "explorer-file") {
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: null,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to add item:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop on group area (adds to group)
|
||||
if (dropData?.type === "group" && dragData.type === "explorer-file") {
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: dropData.groupId,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to add item to group:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={customCollision}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeItem?.file && activeItem.gridSize ? (
|
||||
<div style={{ width: activeItem.gridSize }}>
|
||||
<div className="flex flex-col items-center gap-2 p-1 rounded-lg">
|
||||
<div className="rounded-lg p-2">
|
||||
<FileComponent.Thumb file={activeItem.file} size={Math.max(activeItem.gridSize * 0.6, 60)} />
|
||||
</div>
|
||||
<div className="text-sm truncate px-2 py-0.5 rounded-md bg-accent text-white max-w-full">
|
||||
{activeItem.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function Explorer({ client }: AppProps) {
|
||||
const router = createExplorerRouter();
|
||||
|
||||
return (
|
||||
<SpacedriveProvider client={client}>
|
||||
<TopBarProvider>
|
||||
<SelectionProvider>
|
||||
<ExplorerProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ExplorerProvider>
|
||||
</SelectionProvider>
|
||||
</TopBarProvider>
|
||||
<DndWrapper>
|
||||
<TopBarProvider>
|
||||
<SelectionProvider>
|
||||
<ExplorerProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ExplorerProvider>
|
||||
</SelectionProvider>
|
||||
</TopBarProvider>
|
||||
</DndWrapper>
|
||||
<Dialogs />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</SpacedriveProvider>
|
||||
|
||||
168
packages/interface/src/Settings/index.tsx
Normal file
168
packages/interface/src/Settings/index.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
currentPage: string;
|
||||
onPageChange: (page: string) => void;
|
||||
}
|
||||
|
||||
function SettingsSidebar({ currentPage, onPageChange }: SettingsSidebarProps) {
|
||||
const sections = [
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "library", label: "Library" },
|
||||
{ id: "privacy", label: "Privacy" },
|
||||
{ id: "about", label: "About" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => onPageChange(section.id)}
|
||||
className={clsx(
|
||||
"w-full text-left px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
currentPage === section.id
|
||||
? "bg-sidebar-selected text-sidebar-ink"
|
||||
: "text-sidebar-inkDull hover:text-sidebar-ink hover:bg-sidebar-box"
|
||||
)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsContentProps {
|
||||
page: string;
|
||||
}
|
||||
|
||||
function SettingsContent({ page }: SettingsContentProps) {
|
||||
switch (page) {
|
||||
case "general":
|
||||
return <GeneralSettings />;
|
||||
case "library":
|
||||
return <LibrarySettings />;
|
||||
case "privacy":
|
||||
return <PrivacySettings />;
|
||||
case "about":
|
||||
return <AboutSettings />;
|
||||
default:
|
||||
return <GeneralSettings />;
|
||||
}
|
||||
}
|
||||
|
||||
function GeneralSettings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">General</h2>
|
||||
<p className="text-sm text-ink-dull">
|
||||
Configure general application settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-app-box rounded-lg border border-app-line">
|
||||
<h3 className="text-sm font-medium text-ink mb-1">Theme</h3>
|
||||
<p className="text-xs text-ink-dull">Choose your preferred theme</p>
|
||||
</div>
|
||||
<div className="p-4 bg-app-box rounded-lg border border-app-line">
|
||||
<h3 className="text-sm font-medium text-ink mb-1">Language</h3>
|
||||
<p className="text-xs text-ink-dull">Select your language</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrarySettings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">Library</h2>
|
||||
<p className="text-sm text-ink-dull">
|
||||
Manage your Spacedrive libraries.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-app-box rounded-lg border border-app-line">
|
||||
<h3 className="text-sm font-medium text-ink mb-1">
|
||||
Current Library
|
||||
</h3>
|
||||
<p className="text-xs text-ink-dull">View and switch libraries</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivacySettings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">Privacy</h2>
|
||||
<p className="text-sm text-ink-dull">
|
||||
Control your privacy and data sharing preferences.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-app-box rounded-lg border border-app-line">
|
||||
<h3 className="text-sm font-medium text-ink mb-1">Telemetry</h3>
|
||||
<p className="text-xs text-ink-dull">
|
||||
Help improve Spacedrive by sharing anonymous usage data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutSettings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">About</h2>
|
||||
<p className="text-sm text-ink-dull">
|
||||
Information about Spacedrive.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-app-box rounded-lg border border-app-line">
|
||||
<h3 className="text-sm font-medium text-ink mb-1">Version</h3>
|
||||
<p className="text-xs text-ink-dull">Spacedrive v0.1.0</p>
|
||||
</div>
|
||||
<div className="p-4 bg-app-box rounded-lg border border-app-line">
|
||||
<h3 className="text-sm font-medium text-ink mb-1">License</h3>
|
||||
<p className="text-xs text-ink-dull">AGPL-3.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const pathname = window.location.pathname;
|
||||
const initialPage = pathname.split("/").filter(Boolean)[1] || "general";
|
||||
const [currentPage, setCurrentPage] = useState(initialPage);
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-app flex">
|
||||
{/* Sidebar */}
|
||||
<nav className="w-48 bg-sidebar border-r border-sidebar-line p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold text-sidebar-ink">Settings</h1>
|
||||
</div>
|
||||
<SettingsSidebar
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
<SettingsContent page={currentPage} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,17 +22,20 @@ export const TopBar = memo(function TopBar({ sidebarWidth = 0, inspectorWidth =
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-[60] h-12"
|
||||
className="absolute top-0 z-[60] h-12"
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
paddingLeft: sidebarWidth,
|
||||
paddingRight: inspectorWidth,
|
||||
left: sidebarWidth,
|
||||
right: inspectorWidth,
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center h-full px-3 gap-3 overflow-hidden">
|
||||
<div ref={leftRef} className="flex items-center gap-2" />
|
||||
<div ref={centerRef} className="flex-1 flex items-center justify-center gap-2" />
|
||||
<div ref={rightRef} className="flex items-center gap-2" />
|
||||
<div
|
||||
className="relative flex items-center h-full px-3 gap-3 overflow-hidden"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div ref={leftRef} data-tauri-drag-region className="flex items-center gap-2" />
|
||||
<div ref={centerRef} data-tauri-drag-region className="flex-1 flex items-center justify-center gap-2" />
|
||||
<div ref={rightRef} data-tauri-drag-region className="flex items-center gap-2" />
|
||||
|
||||
{/* Right fade mask - hide when preview active */}
|
||||
{!isPreviewActive && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useRef } from "react";
|
||||
import { memo } from "react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Copy,
|
||||
@@ -29,7 +29,7 @@ import { useLibraryMutation } from "../../../../context";
|
||||
import { usePlatform } from "../../../../platform";
|
||||
import { formatBytes } from "../../utils";
|
||||
import { TagDot } from "../../../Tags";
|
||||
import { setDragData, type SidebarDragData } from "../../../SpacesSidebar/dnd";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
@@ -443,86 +443,34 @@ export const FileCard = memo(function FileCard({ file, fileIndex, allFiles, sele
|
||||
await contextMenu.show(e);
|
||||
};
|
||||
|
||||
// Track mouse position for native drag initiation
|
||||
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
// Only track left mouse button
|
||||
if (e.button === 0) {
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = async (e: React.MouseEvent) => {
|
||||
if (!dragStartPos.current || isDraggingRef.current) return;
|
||||
if (!platform.startDrag) return;
|
||||
|
||||
// Calculate distance moved
|
||||
const dx = e.clientX - dragStartPos.current.x;
|
||||
const dy = e.clientY - dragStartPos.current.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Start native drag after moving 8px (drag threshold)
|
||||
if (distance > 8) {
|
||||
isDraggingRef.current = true;
|
||||
|
||||
// Store our drag data globally for drop handlers
|
||||
const dragData: SidebarDragData = {
|
||||
type: "explorer-file",
|
||||
sdPath: file.sd_path,
|
||||
name: file.name,
|
||||
};
|
||||
setDragData(dragData);
|
||||
console.log("[FileCard] Starting native drag:", dragData);
|
||||
|
||||
try {
|
||||
// Get the file path for native drag
|
||||
let filePath = "";
|
||||
if ("Physical" in file.sd_path) {
|
||||
filePath = file.sd_path.Physical.path;
|
||||
}
|
||||
|
||||
await platform.startDrag({
|
||||
items: [{
|
||||
id: file.id,
|
||||
kind: filePath ? { type: "file", path: filePath } : { type: "text", content: file.name },
|
||||
}],
|
||||
allowedOperations: ["copy", "move"],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to start drag:", err);
|
||||
} finally {
|
||||
isDraggingRef.current = false;
|
||||
dragStartPos.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
dragStartPos.current = null;
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
const { attributes, listeners, setNodeRef, isDragging: dndIsDragging } = useDraggable({
|
||||
id: file.id,
|
||||
data: {
|
||||
type: "explorer-file",
|
||||
sdPath: file.sd_path,
|
||||
name: file.name,
|
||||
file: file,
|
||||
gridSize: gridSize, // Pass grid size for overlay
|
||||
},
|
||||
});
|
||||
|
||||
const thumbSize = Math.max(gridSize * 0.6, 60);
|
||||
|
||||
return (
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
layout="column"
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-2 p-1 rounded-lg transition-all",
|
||||
focused && !selected && "ring-2 ring-accent/50"
|
||||
)}
|
||||
>
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
layout="column"
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-2 p-1 rounded-lg transition-all",
|
||||
focused && !selected && "ring-2 ring-accent/50",
|
||||
dndIsDragging && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-lg p-2",
|
||||
@@ -568,6 +516,7 @@ export const FileCard = memo(function FileCard({ file, fileIndex, allFiles, sele
|
||||
)}
|
||||
</div>
|
||||
</FileComponent>
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Custom comparison - rerender if file object, selection, or focus changed
|
||||
|
||||
@@ -38,8 +38,13 @@ export function LocationsGroup({ isCollapsed, onToggle }: LocationsGroupProps) {
|
||||
{/* Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5">
|
||||
{locations.map((location) => (
|
||||
<SpaceItem key={location.id} item={location} />
|
||||
{locations.map((location, index) => (
|
||||
<SpaceItem
|
||||
key={location.id}
|
||||
item={location}
|
||||
allowInsertion={false}
|
||||
isLastItem={index === locations.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { CaretRight } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type {
|
||||
SpaceGroup as SpaceGroupType,
|
||||
SpaceItem as SpaceItemType,
|
||||
} from '@sd/ts-client';
|
||||
import { useSidebarStore, useLibraryMutation } from '@sd/ts-client';
|
||||
import { useSidebarStore } from '@sd/ts-client';
|
||||
import { SpaceItem } from './SpaceItem';
|
||||
import { DeviceGroup } from './DeviceGroup';
|
||||
import { DevicesGroup } from './DevicesGroup';
|
||||
import { LocationsGroup } from './LocationsGroup';
|
||||
import { VolumesGroup } from './VolumesGroup';
|
||||
import { TagsGroup } from './TagsGroup';
|
||||
import { getDragData, clearDragData, subscribeToDragState } from './dnd';
|
||||
import { usePlatform } from '../../platform';
|
||||
|
||||
interface SpaceGroupProps {
|
||||
group: SpaceGroupType;
|
||||
@@ -23,70 +20,12 @@ interface SpaceGroupProps {
|
||||
|
||||
export function SpaceGroup({ group, items, spaceId }: SpaceGroupProps) {
|
||||
const { collapsedGroups, toggleGroup } = useSidebarStore();
|
||||
const platform = usePlatform();
|
||||
// Use backend's is_collapsed value as the source of truth, fallback to local state
|
||||
const isCollapsed = group.is_collapsed ?? collapsedGroups.has(group.id);
|
||||
|
||||
// Drag-drop state for custom groups
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const addItem = useLibraryMutation("spaces.add_item");
|
||||
const groupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Only QuickAccess and Custom groups can accept drops
|
||||
const canAcceptDrop = group.group_type === 'QuickAccess' || group.group_type === 'Custom';
|
||||
|
||||
// Subscribe to drag state changes
|
||||
useEffect(() => {
|
||||
if (!canAcceptDrop) return;
|
||||
return subscribeToDragState(setIsDragging);
|
||||
}, [canAcceptDrop]);
|
||||
|
||||
// Listen for native drag events to track position and handle drop
|
||||
useEffect(() => {
|
||||
if (!platform.onDragEvent || !canAcceptDrop) return;
|
||||
|
||||
const unlisteners: Array<() => void> = [];
|
||||
|
||||
// Track drag position to detect when over this group
|
||||
platform.onDragEvent("moved", (payload: { x: number; y: number }) => {
|
||||
if (!groupRef.current) return;
|
||||
|
||||
const rect = groupRef.current.getBoundingClientRect();
|
||||
const isOver = (
|
||||
payload.x >= rect.left &&
|
||||
payload.x <= rect.right &&
|
||||
payload.y >= rect.top &&
|
||||
payload.y <= rect.bottom
|
||||
);
|
||||
setIsHovering(isOver);
|
||||
}).then(fn => unlisteners.push(fn));
|
||||
|
||||
// Handle drag end - check if dropped on this group
|
||||
platform.onDragEvent("ended", async (payload: { result?: { type: string } }) => {
|
||||
if (payload.result?.type === "Dropped" && isHovering && spaceId) {
|
||||
const dragData = getDragData();
|
||||
if (dragData) {
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: spaceId,
|
||||
group_id: group.id,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
console.log("[SpaceGroup] Added item to group:", group.name);
|
||||
} catch (err) {
|
||||
console.error("Failed to add item to group:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsDragging(false);
|
||||
setIsHovering(false);
|
||||
}).then(fn => unlisteners.push(fn));
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach(fn => fn());
|
||||
};
|
||||
}, [platform, canAcceptDrop, spaceId, group.id, group.name, addItem, isHovering]);
|
||||
// System groups (Locations, Volumes, etc.) are dynamic - don't allow insertion/reordering
|
||||
// Custom/QuickAccess groups allow insertion
|
||||
const allowInsertion = group.group_type === 'QuickAccess' || group.group_type === 'Custom';
|
||||
|
||||
// Device groups are special - they show device info with children
|
||||
if (typeof group.group_type === 'object' && 'Device' in group.group_type) {
|
||||
@@ -122,14 +61,7 @@ export function SpaceGroup({ group, items, spaceId }: SpaceGroupProps) {
|
||||
|
||||
// QuickAccess and Custom groups render stored items
|
||||
return (
|
||||
<div
|
||||
ref={groupRef}
|
||||
className={clsx(
|
||||
"rounded-lg transition-colors",
|
||||
isDragging && canAcceptDrop && "bg-accent/10 ring-2 ring-accent/50 ring-inset",
|
||||
isDragging && isHovering && canAcceptDrop && "bg-accent/20 ring-accent"
|
||||
)}
|
||||
>
|
||||
<div className="rounded-lg">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
@@ -146,18 +78,16 @@ export function SpaceGroup({ group, items, spaceId }: SpaceGroupProps) {
|
||||
{/* Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<SpaceItem key={item.id} item={item} />
|
||||
{items.map((item, index) => (
|
||||
<SpaceItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLastItem={index === items.length - 1}
|
||||
allowInsertion={allowInsertion}
|
||||
spaceId={spaceId}
|
||||
groupId={group.id}
|
||||
/>
|
||||
))}
|
||||
{/* Drop hint */}
|
||||
{isDragging && canAcceptDrop && (
|
||||
<div className={clsx(
|
||||
"flex items-center justify-center py-2 text-xs font-medium transition-colors",
|
||||
isHovering ? "text-accent" : "text-accent/70"
|
||||
)}>
|
||||
{isHovering ? "Release to add" : "Drop here"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
House,
|
||||
Clock,
|
||||
@@ -21,6 +22,7 @@ import { Thumb } from "../Explorer/File/Thumb";
|
||||
import { useContextMenu } from "../../hooks/useContextMenu";
|
||||
import { usePlatform } from "../../platform";
|
||||
import { useLibraryMutation } from "../../context";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
|
||||
interface SpaceItemProps {
|
||||
item: SpaceItemType;
|
||||
@@ -36,6 +38,14 @@ interface SpaceItemProps {
|
||||
volumeData?: { device_slug: string; mount_path: string };
|
||||
/** Optional custom icon (as image path) to override default icon */
|
||||
customIcon?: string;
|
||||
/** Whether this is the last item in the list (for showing bottom insertion line) */
|
||||
isLastItem?: boolean;
|
||||
/** Whether this item supports insertion (reordering) - false for system groups */
|
||||
allowInsertion?: boolean;
|
||||
/** The space ID this item belongs to (for adding items on insertion) */
|
||||
spaceId?: string;
|
||||
/** The group ID this item belongs to (for adding items on insertion) */
|
||||
groupId?: string | null;
|
||||
}
|
||||
|
||||
function getItemIcon(itemType: ItemType): any {
|
||||
@@ -78,7 +88,11 @@ function getItemLabel(itemType: ItemType): string {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function getItemPath(itemType: ItemType, volumeData?: { device_slug: string; mount_path: string }): string | null {
|
||||
function getItemPath(
|
||||
itemType: ItemType,
|
||||
volumeData?: { device_slug: string; mount_path: string },
|
||||
resolvedFile?: File
|
||||
): string | null {
|
||||
if (itemType === "Overview") return "/";
|
||||
if (itemType === "Recents") return "/recents";
|
||||
if (itemType === "Favorites") return "/favorites";
|
||||
@@ -99,6 +113,14 @@ function getItemPath(itemType: ItemType, volumeData?: { device_slug: string; mou
|
||||
}
|
||||
if (typeof itemType === "object" && "Tag" in itemType)
|
||||
return `/tag/${itemType.Tag.tag_id}`;
|
||||
if (typeof itemType === "object" && "Path" in itemType) {
|
||||
// If it's a directory, navigate to explorer
|
||||
if (resolvedFile?.kind === "Directory") {
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
|
||||
}
|
||||
// Regular files don't have a path to navigate to (could open/preview in future)
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -110,6 +132,10 @@ export function SpaceItem({
|
||||
onClick,
|
||||
volumeData,
|
||||
customIcon,
|
||||
isLastItem = false,
|
||||
allowInsertion = true,
|
||||
spaceId,
|
||||
groupId,
|
||||
}: SpaceItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -135,7 +161,7 @@ export function SpaceItem({
|
||||
iconData = getItemIcon(item.item_type);
|
||||
// Use resolved file name if available, otherwise parse from item_type
|
||||
label = resolvedFile?.name || getItemLabel(item.item_type);
|
||||
path = getItemPath(item.item_type, volumeData);
|
||||
path = getItemPath(item.item_type, volumeData, resolvedFile);
|
||||
}
|
||||
|
||||
// Override with custom icon if provided
|
||||
@@ -224,27 +250,156 @@ export function SpaceItem({
|
||||
await contextMenu.show(e);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop Target Detection
|
||||
*
|
||||
* SpaceItems can be drop targets in two ways:
|
||||
*
|
||||
* 1. Insertion Points (all items):
|
||||
* - Show blue line above/below
|
||||
* - Allows reordering sidebar items
|
||||
* - Top/bottom zones (25% or 50% of height)
|
||||
*
|
||||
* 2. Move-Into Targets (locations/volumes/folders only):
|
||||
* - Show blue ring around entire item
|
||||
* - Allows moving files into that location
|
||||
* - Middle zone (50% of height, only for drop targets)
|
||||
*
|
||||
* Target Types:
|
||||
* - "location": Indexed location (raw or ItemType::Location)
|
||||
* - "volume": Storage volume (ItemType::Volume)
|
||||
* - "folder": Directory path (ItemType::Path with kind=Directory)
|
||||
*/
|
||||
const isDropTarget =
|
||||
isRawLocation ||
|
||||
(typeof item.item_type === "object" &&
|
||||
("Location" in item.item_type ||
|
||||
"Volume" in item.item_type ||
|
||||
("Path" in item.item_type && resolvedFile?.kind === "Directory")));
|
||||
|
||||
let targetType: "location" | "volume" | "folder" | "other" = "other";
|
||||
if (isRawLocation) {
|
||||
targetType = "location";
|
||||
} else if (typeof item.item_type === "object") {
|
||||
if ("Location" in item.item_type) targetType = "location";
|
||||
else if ("Volume" in item.item_type) targetType = "volume";
|
||||
else if ("Path" in item.item_type && resolvedFile?.kind === "Directory") targetType = "folder";
|
||||
}
|
||||
|
||||
const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({
|
||||
id: `space-item-${item.id}-top`,
|
||||
disabled: !allowInsertion,
|
||||
data: {
|
||||
action: "insert-before",
|
||||
itemId: item.id,
|
||||
spaceId,
|
||||
groupId,
|
||||
},
|
||||
});
|
||||
|
||||
const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({
|
||||
id: `space-item-${item.id}-bottom`,
|
||||
disabled: !allowInsertion,
|
||||
data: {
|
||||
action: "insert-after",
|
||||
itemId: item.id,
|
||||
spaceId,
|
||||
groupId,
|
||||
},
|
||||
});
|
||||
|
||||
const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({
|
||||
id: `space-item-${item.id}-middle`,
|
||||
disabled: !isDropTarget,
|
||||
data: {
|
||||
action: "move-into",
|
||||
targetType,
|
||||
targetId: item.id,
|
||||
// For raw locations, include the sd_path directly
|
||||
targetPath: isRawLocation ? (item as any).sd_path : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
|
||||
className ||
|
||||
(isActive
|
||||
? "bg-sidebar-selected/30 text-sidebar-ink"
|
||||
: "text-sidebar-inkDull"),
|
||||
<div className="relative">
|
||||
{/* Insertion line indicator - only show top (bottom of previous item handles gaps) */}
|
||||
{isOverTop && (
|
||||
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
|
||||
)}
|
||||
>
|
||||
{resolvedFile ? (
|
||||
<Thumb file={resolvedFile} size={16} className="shrink-0" />
|
||||
) : iconData.type === "image" ? (
|
||||
<img src={iconData.icon} alt="" className="size-4" />
|
||||
) : (
|
||||
<iconData.icon className="size-4" weight={iconWeight} />
|
||||
|
||||
{/* Ring highlight for drop-into */}
|
||||
{isOverMiddle && isDropTarget && (
|
||||
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
{rightComponent}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
{/* Drop zones - invisible overlays, only active during drag */}
|
||||
{isDropTarget ? (
|
||||
<>
|
||||
{/* Top zone - insertion above */}
|
||||
<div
|
||||
ref={setTopRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "-2px", height: "calc(25% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
{/* Middle zone - drop into folder */}
|
||||
<div
|
||||
ref={setMiddleRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "25%", height: "50%", zIndex: 11 }}
|
||||
/>
|
||||
{/* Bottom zone - insertion below */}
|
||||
<div
|
||||
ref={setBottomRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ bottom: "-2px", height: "calc(25% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Top zone - insertion above */}
|
||||
<div
|
||||
ref={setTopRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ top: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
{/* Bottom zone - insertion below */}
|
||||
<div
|
||||
ref={setBottomRef}
|
||||
className="absolute left-0 right-0 pointer-events-none"
|
||||
style={{ bottom: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default",
|
||||
className ||
|
||||
(isActive
|
||||
? "bg-sidebar-selected/30 text-sidebar-ink"
|
||||
: "text-sidebar-inkDull"),
|
||||
isOverMiddle && isDropTarget && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
{resolvedFile ? (
|
||||
<Thumb file={resolvedFile} size={16} className="shrink-0" />
|
||||
) : iconData.type === "image" ? (
|
||||
<img src={iconData.icon} alt="" className="size-4" />
|
||||
) : (
|
||||
<iconData.icon className="size-4" weight={iconWeight} />
|
||||
)}
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
{rightComponent}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Insertion line indicator - bottom (only for last item to allow dropping at end) */}
|
||||
{isOverBottom && isLastItem && (
|
||||
<div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function VolumesGroup({
|
||||
No volumes
|
||||
</div>
|
||||
) : (
|
||||
volumes.map((volume) => (
|
||||
volumes.map((volume, index) => (
|
||||
<SpaceItem
|
||||
key={volume.id}
|
||||
item={
|
||||
@@ -82,6 +82,8 @@ export function VolumesGroup({
|
||||
}}
|
||||
rightComponent={getVolumeBadges(volume)}
|
||||
customIcon={getVolumeIcon(volume)}
|
||||
allowInsertion={false}
|
||||
isLastItem={index === volumes.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -15,13 +15,15 @@ type DragStateListener = (isDragging: boolean) => void;
|
||||
const dragStateListeners = new Set<DragStateListener>();
|
||||
|
||||
export function setDragData(data: SidebarDragData | null) {
|
||||
const wasDragging = currentDragData !== null;
|
||||
console.log("[DnD] setDragData called, data:", data, "listeners:", dragStateListeners.size);
|
||||
currentDragData = data;
|
||||
const isDragging = data !== null;
|
||||
|
||||
if (wasDragging !== isDragging) {
|
||||
dragStateListeners.forEach(listener => listener(isDragging));
|
||||
}
|
||||
// Always notify listeners immediately (sync)
|
||||
dragStateListeners.forEach(listener => {
|
||||
console.log("[DnD] Calling listener with isDragging:", isDragging);
|
||||
listener(isDragging);
|
||||
});
|
||||
}
|
||||
|
||||
export function getDragData(): SidebarDragData | null {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { GearSix } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSidebarStore, useLibraryMutation } from "@sd/ts-client";
|
||||
import { useSpaces, useSpaceLayout } from "./hooks/useSpaces";
|
||||
import { SpaceSwitcher } from "./SpaceSwitcher";
|
||||
@@ -12,8 +11,8 @@ import { useLibraries } from "../../hooks/useLibraries";
|
||||
import { usePlatform } from "../../platform";
|
||||
import { JobManagerPopover } from "../JobManager/JobManagerPopover";
|
||||
import { SyncMonitorPopover } from "../SyncMonitor";
|
||||
import { getDragData, clearDragData, subscribeToDragState } from "./dnd";
|
||||
import clsx from "clsx";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
|
||||
interface SpacesSidebarProps {
|
||||
isPreviewActive?: boolean;
|
||||
@@ -23,7 +22,6 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
const client = useSpacedriveClient();
|
||||
const platform = usePlatform();
|
||||
const { data: libraries } = useLibraries();
|
||||
const navigate = useNavigate();
|
||||
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(
|
||||
() => client.getCurrentLibraryId(),
|
||||
);
|
||||
@@ -73,66 +71,7 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
|
||||
const { data: layout } = useSpaceLayout(currentSpace?.id ?? null);
|
||||
|
||||
// Drag-drop state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const addItem = useLibraryMutation("spaces.add_item");
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Subscribe to drag state changes (from setDragData)
|
||||
useEffect(() => {
|
||||
return subscribeToDragState(setIsDragging);
|
||||
}, []);
|
||||
|
||||
// Listen for native drag events to track position and handle drop
|
||||
useEffect(() => {
|
||||
if (!platform.onDragEvent) return;
|
||||
|
||||
const unlisteners: Array<() => void> = [];
|
||||
|
||||
// Track drag position to detect when over sidebar
|
||||
platform.onDragEvent("moved", (payload: { x: number; y: number }) => {
|
||||
if (!dropZoneRef.current) return;
|
||||
|
||||
const rect = dropZoneRef.current.getBoundingClientRect();
|
||||
const isOver = (
|
||||
payload.x >= rect.left &&
|
||||
payload.x <= rect.right &&
|
||||
payload.y >= rect.top &&
|
||||
payload.y <= rect.bottom
|
||||
);
|
||||
setIsHovering(isOver);
|
||||
}).then(fn => unlisteners.push(fn));
|
||||
|
||||
// Handle drag end - check if dropped on sidebar
|
||||
platform.onDragEvent("ended", async (payload: { result?: { type: string } }) => {
|
||||
const dragData = getDragData(); // Get BEFORE clearing
|
||||
|
||||
// Check for "dropped" (lowercase from backend)
|
||||
const wasDropped = payload.result?.type?.toLowerCase() === "dropped";
|
||||
|
||||
// If dropped and we have drag data from our app, add it to the space
|
||||
if (wasDropped && currentSpace && dragData) {
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: currentSpace.id,
|
||||
group_id: null,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to add item to space:", err);
|
||||
}
|
||||
}
|
||||
|
||||
clearDragData();
|
||||
setIsDragging(false);
|
||||
setIsHovering(false);
|
||||
}).then(fn => unlisteners.push(fn));
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach(fn => fn());
|
||||
};
|
||||
}, [platform, currentSpace, addItem, isHovering]);
|
||||
|
||||
return (
|
||||
<div className="w-[220px] min-w-[176px] max-w-[300px] flex flex-col h-full p-2 bg-transparent">
|
||||
@@ -150,34 +89,24 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
onSwitch={setCurrentSpace}
|
||||
/>
|
||||
|
||||
{/* Scrollable Content - Drop Zone */}
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
className={clsx(
|
||||
"no-scrollbar mt-3 mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10 transition-colors rounded-lg",
|
||||
isDragging && "bg-accent/10 ring-2 ring-accent/50 ring-inset",
|
||||
isDragging && isHovering && "bg-accent/20 ring-accent"
|
||||
)}
|
||||
>
|
||||
{/* Scrollable Content */}
|
||||
<div className="no-scrollbar mt-3 mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
|
||||
{/* Space-level items (pinned shortcuts) */}
|
||||
{layout?.space_items && layout.space_items.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{layout.space_items.map((item) => (
|
||||
<SpaceItem key={item.id} item={item} />
|
||||
{layout.space_items.map((item, index) => (
|
||||
<SpaceItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLastItem={index === layout.space_items.length - 1}
|
||||
allowInsertion={true}
|
||||
spaceId={currentSpace?.id}
|
||||
groupId={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop hint when dragging */}
|
||||
{isDragging && (
|
||||
<div className={clsx(
|
||||
"flex items-center justify-center py-4 text-xs font-medium transition-colors",
|
||||
isHovering ? "text-accent" : "text-accent/70"
|
||||
)}>
|
||||
{isHovering ? "Release to add shortcut" : "Drop to add shortcut"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
{layout?.groups.map(({ group, items }) => (
|
||||
<SpaceGroup key={group.id} group={group} items={items} spaceId={currentSpace?.id} />
|
||||
@@ -192,7 +121,13 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
<SyncMonitorPopover />
|
||||
<JobManagerPopover />
|
||||
<button
|
||||
onClick={() => navigate("/settings")}
|
||||
onClick={() => {
|
||||
if (platform.showWindow) {
|
||||
platform.showWindow({ type: "Settings", page: "general" }).catch(err =>
|
||||
console.error("Failed to open settings:", err)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
|
||||
"text-sidebar-inkDull hover:text-sidebar-ink hover:bg-sidebar-selected",
|
||||
|
||||
@@ -13,6 +13,7 @@ export { LocationCacheDemo } from './LocationCacheDemo';
|
||||
export { Inspector, PopoutInspector } from './Inspector';
|
||||
export type { InspectorVariant } from './Inspector';
|
||||
export { QuickPreview } from './components/QuickPreview';
|
||||
export { Settings } from './Settings';
|
||||
export { Spacedrop } from './Spacedrop';
|
||||
export { PairingModal } from './components/PairingModal';
|
||||
export { TopBarProvider, TopBarPortal, useTopBar } from './TopBar';
|
||||
|
||||
@@ -81,4 +81,4 @@
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/ui/publish.sh
Executable file
15
packages/ui/publish.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Temporarily rename for publishing
|
||||
sed -i.bak 's/"name": "@sd\/ui"/"name": "@spacedriveapp\/ui"/' package.json
|
||||
sed -i.bak 's/"@sd\/assets": "workspace:\*"/"@spacedriveapp\/assets": "^1.0.2"/' package.json
|
||||
|
||||
# Build and publish
|
||||
bun run build
|
||||
npm publish --access public
|
||||
|
||||
# Revert changes
|
||||
mv package.json.bak package.json
|
||||
|
||||
echo "Published successfully!"
|
||||
Reference in New Issue
Block a user