feat: update cloud credential management

This commit is contained in:
Jamie Pine
2025-12-03 15:55:04 -08:00
parent 9143a8aca5
commit e5cb6baaba
54 changed files with 4425 additions and 1182 deletions

3
.gitignore vendored
View File

@@ -478,3 +478,6 @@ whitepaper/*.log
# GitHub Actions build artifacts
.github/actions/*/dist/
test_data

View File

@@ -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

View File

@@ -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",

View File

@@ -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"]}}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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>
);
}

3103
bun.lock
View File

File diff suppressed because it is too large Load Diff

BIN
core/:memory: Normal file
View File

Binary file not shown.

View File

@@ -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"] }

View File

@@ -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)),

View File

@@ -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) {
(

View File

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

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

View File

@@ -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);
}
}

View File

@@ -1,3 +1,2 @@
pub mod cloud_credentials;
pub mod device_key_manager;
pub mod library_key_manager;
pub mod key_manager;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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,
}

View File

@@ -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),
]
}
}

View File

@@ -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()?;

View File

@@ -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: {}",

View File

@@ -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)
}

View File

@@ -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.",

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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());

View File

@@ -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))
})?;

View File

@@ -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))?;

View File

@@ -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> {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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,

View File

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

View File

@@ -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(),

View File

@@ -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(&current_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

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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>

View 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>
);
}

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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}
/>
))
)}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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';

View File

@@ -81,4 +81,4 @@
"tsup": "^8.3.5",
"typescript": "^5.6.2"
}
}
}

15
packages/ui/publish.sh Executable file
View 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!"