diff --git a/Cargo.lock b/Cargo.lock index 161f9cf6e..c91114e95 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/Cargo.toml b/Cargo.toml index 70d6e1c0f..d1751929d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ # "apps/mobile/modules/sd-core/android/crate", # "apps/mobile/modules/sd-core/core", # "apps/mobile/modules/sd-core/ios/crate", - # "apps/server", + "apps/server", "apps/cli", "apps/gpui-photo-grid", "apps/tauri/sd-tauri-core", diff --git a/apps/server/.dockerignore b/apps/server/.dockerignore new file mode 100644 index 000000000..bb2219dad --- /dev/null +++ b/apps/server/.dockerignore @@ -0,0 +1,68 @@ +# Git +.git +.gitignore +.gitattributes + +# Build artifacts +target/ +**/target/ +dist/ +**/dist/ +build/ +**/build/ + +# Dependencies +node_modules/ +**/node_modules/ +.pnpm-store/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ + +# Test files +coverage/ +.nyc_output/ + +# Documentation (not needed in container) +docs/ +*.md +!README.md + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Development files +.env +.env.local +.env.*.local + +# Rust +**/*.rs.bk +Cargo.lock.orig + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Linux +*~ +.fuse_hidden* +.directory +.Trash-* + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini diff --git a/apps/server/ARCHITECTURE.md b/apps/server/ARCHITECTURE.md new file mode 100644 index 000000000..4f26432db --- /dev/null +++ b/apps/server/ARCHITECTURE.md @@ -0,0 +1,380 @@ +# Spacedrive Server Architecture + +## Overview + +The Spacedrive Server is a production-ready HTTP server that embeds the Spacedrive daemon and serves the web interface. It's designed for headless deployments, NAS systems, and container environments. + +## Design Principles + +1. **Embedded Daemon** - No separate process management needed +2. **Single Binary** - Web assets bundled via `include_dir` when built with `--features assets` +3. **Platform Abstraction** - Uses same `@sd/interface` as Tauri, with web-specific platform impl +4. **Security First** - HTTP Basic Auth for all endpoints (except health check) +5. **Container Native** - Docker-first design with distroless runtime image + +## Components + +### 1. HTTP Server (`apps/server/src/main.rs`) + +Built with Axum, provides: +- **`GET /health`** - Health check (no auth required) +- **`POST /rpc`** - JSON-RPC proxy to daemon Unix socket +- **`GET /*`** - Static asset serving (SPA fallback to index.html) + +**Flow:** +``` +Browser → HTTP Request → Axum Router → Basic Auth Middleware → Handler + ↓ + ┌─────────────────────────┴────────┐ + ↓ ↓ + Static Assets RPC Proxy + (serve from ↓ + ASSETS_DIR) Unix Socket → Daemon +``` + +### 2. Embedded Daemon + +Unlike Tauri (which spawns `sd-daemon` as a child process), the server runs the daemon in-process: + +```rust +tokio::spawn(async move { + sd_core::infra::daemon::bootstrap::start_default_server( + socket_path, + data_dir, + enable_p2p, + ).await +}); +``` + +**Benefits:** +- Single container image +- Simplified lifecycle management +- Shared memory space (more efficient) +- Graceful shutdown via `tokio::select!` + +**Daemon lifecycle:** +1. Check if socket already exists (reuse existing daemon) +2. If not, spawn daemon in background task +3. Wait for socket file to appear (max 3s) +4. Return handle for graceful shutdown + +### 3. Web Client (`apps/web/`) + +Minimal React app using `@sd/interface`: + +```tsx +// apps/web/src/main.tsx + + + +``` + +**Platform implementation:** +```typescript +// apps/web/src/platform.ts +export const platform: Platform = { + platform: "web", + openLink(url) { window.open(url) }, + confirm(msg, cb) { cb(window.confirm(msg)) }, + // No native file pickers, daemon control, etc. +}; +``` + +**Build process:** +1. Vite bundles React app → `apps/web/dist/` +2. `build.rs` runs `pnpm build` before compiling server +3. `include_dir!` macro embeds `dist/` into binary at compile time +4. Axum serves embedded files from memory + +### 4. RPC Proxy + +Browsers can't connect to Unix sockets, so the server proxies: + +``` +Browser Server Daemon + │ │ │ + │ POST /rpc │ │ + ├────────────────────>│ │ + │ (JSON-RPC) │ Unix Socket Write │ + │ ├────────────────────────>│ + │ │ │ + │ │ Unix Socket Read │ + │ │<────────────────────────┤ + │ 200 OK │ │ + │<────────────────────┤ │ + │ (JSON-RPC result) │ │ +``` + +**Implementation:** +```rust +async fn daemon_rpc( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let mut stream = UnixStream::connect(&state.socket_path).await?; + stream.write_all(format!("{}\n", serde_json::to_string(&payload)?).as_bytes()).await?; + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader.read_line(&mut response).await?; + + Ok(Json(serde_json::from_str(&response)?)) +} +``` + +## Comparison: Server vs Tauri vs CLI + +| Aspect | Server | Tauri | CLI | +|--------|--------|-------|-----| +| **Process Model** | Embedded daemon | Spawned daemon | Connects to daemon | +| **UI** | Web (React in browser) | WebView (React) | Terminal (TUI) | +| **Daemon Communication** | Unix socket (proxied) | Unix socket (direct) | Unix socket (direct) | +| **Platform Abstraction** | `platform: "web"` | `platform: "tauri"` | N/A | +| **Access Model** | Remote (HTTP) | Local only | Local only | +| **Auth** | HTTP Basic Auth | Not needed | Not needed | +| **Deployment** | Docker, systemd | App bundle | Binary | + +## Authentication Flow + +``` +1. Browser makes request without auth + ↓ +2. basic_auth middleware checks state.auth + ↓ +3. If empty → allow (auth disabled) + If populated → require Basic Auth header + ↓ +4. Extract credentials from Authorization header + ↓ +5. Compare with state.auth HashMap + ↓ +6. Match → proceed to handler + No match → 401 Unauthorized +``` + +**Security considerations:** +- Credentials stored in memory as `SecStr` (zeroed on drop) +- Basic Auth over HTTPS recommended for production +- Socket file has filesystem permissions (only accessible to server user) + +## Docker Architecture + +### Multi-stage Build + +```dockerfile +# Stage 1: Builder (Debian + Rust + Node) +FROM debian:bookworm-slim AS builder +RUN install Rust, Node, pnpm +COPY workspace +RUN pnpm build (web) +RUN cargo build --release --features assets + +# Stage 2: Runtime (Distroless) +FROM gcr.io/distroless/cc-debian12:nonroot +COPY --from=builder /build/target/release/sd-server +ENTRYPOINT ["/usr/bin/sd-server"] +``` + +**Benefits:** +- **Small image** - Distroless base (~50MB vs ~1GB for full Debian) +- **Secure** - No shell, no package manager, minimal attack surface +- **Fast** - Cached layers for dependencies +- **Reproducible** - Locked versions via `Cargo.lock` and `pnpm-lock.yaml` + +### Volume Mounts + +```yaml +volumes: + - spacedrive-data:/data # Persistent library data + - /mnt/storage:/storage:ro # Optional: Read-only media access +``` + +**Data layout:** +``` +/data/ +├── daemon/ +│ └── daemon.sock # Unix socket for RPC +├── libraries/ +│ └── *.sdlibrary/ # SQLite databases +│ ├── library.db +│ └── sidecars/ # Thumbnails, previews +├── logs/ +│ ├── daemon.log +│ └── indexing.log +└── current_library_id.txt +``` + +## Development vs Production + +### Development Mode + +```bash +# Terminal 1: Web dev server (hot reload) +cd apps/web +pnpm dev # → http://localhost:3000 + +# Terminal 2: API server +cargo run -p sd-server +# → http://localhost:8080 +# Vite proxies /rpc to 8080 +``` + +**Workflow:** +1. Edit React components → Vite hot reloads +2. Edit server code → `cargo run` rebuilds +3. No need to rebuild web assets during development + +### Production Build + +```bash +# Build with bundled assets +cargo build --release -p sd-server --features assets + +# Single binary contains: +# - Axum HTTP server +# - Embedded daemon +# - Bundled web UI (React app) +``` + +**Deployment:** +```bash +./target/release/sd-server \ + --data-dir /var/lib/spacedrive \ + --port 8080 +``` + +## Platform Abstraction + +Both Tauri and Web use `@sd/interface`, but with different platform implementations: + +### Tauri Platform (`apps/tauri/src/platform.ts`) + +```typescript +{ + platform: "tauri", + openDirectoryPickerDialog: async () => open({ directory: true }), + revealFile: async (path) => invoke("reveal_file", { path }), + getCurrentLibraryId: async () => invoke("get_current_library_id"), + getDaemonStatus: async () => invoke("get_daemon_status"), + // Full native capabilities +} +``` + +### Web Platform (`apps/web/src/platform.ts`) + +```typescript +{ + platform: "web", + openLink: (url) => window.open(url), + confirm: (msg, cb) => cb(window.confirm(msg)), + // Minimal browser-only capabilities +} +``` + +**Interface components adapt:** +```tsx +function FilePickerButton() { + const platform = usePlatform(); + + if (platform.platform === "tauri") { + // Show native picker button + return ; + } else { + // Web: no native picker, show manual path input + return ; + } +} +``` + +## Error Handling + +### HTTP Errors + +```rust +async fn daemon_rpc(...) -> Result, (StatusCode, String)> { + let stream = UnixStream::connect(&socket_path) + .await + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Daemon not available: {}", e)))?; + // ... +} +``` + +**Responses:** +- `503 Service Unavailable` - Daemon not running +- `400 Bad Request` - Invalid JSON +- `500 Internal Server Error` - RPC failed +- `401 Unauthorized` - Auth failed + +### Daemon Errors + +Daemon errors are passed through RPC response: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32603, + "message": "Library not found" + } +} +``` + +## Performance Considerations + +1. **Static Assets** - Served from memory (embedded via `include_dir`) +2. **Socket Pooling** - Each RPC request opens new socket (TODO: connection pool) +3. **Async I/O** - Tokio runtime handles concurrent requests +4. **Graceful Shutdown** - Waits for in-flight requests before terminating + +## Future Enhancements + +1. **WebSocket Support** - Real-time event streaming (vs polling) +2. **HTTPS** - TLS termination (currently expects reverse proxy) +3. **Connection Pool** - Reuse Unix sockets for RPC +4. **Multi-tenancy** - Separate libraries per user +5. **SSE Events** - Server-sent events for daemon notifications + +## Security Model + +**Trust Boundaries:** +``` +Internet ←[TLS]→ Reverse Proxy ←[HTTP+Auth]→ Server ←[Unix Socket]→ Daemon + (❌) (✅) (✅) (✅) (✅) +``` + +**Assumptions:** +- Server runs on trusted network OR behind reverse proxy with TLS +- Unix socket accessible only to server process (filesystem permissions) +- HTTP Basic Auth sufficient for home/NAS use +- For public internet: Use nginx/Caddy with Let's Encrypt + +## Monitoring + +**Health Check:** +```bash +curl http://localhost:8080/health +# → "OK" +``` + +**Logs:** +```bash +# Docker +docker logs spacedrive -f + +# Systemd +journalctl -u spacedrive -f + +# Native +RUST_LOG=debug ./sd-server +``` + +**Metrics:** (TODO) +- Request count/latency +- Daemon socket errors +- Active connections + +## Related Documentation + +- [README.md](./README.md) - Setup and usage +- [../../docs/core/architecture.md](../../docs/core/architecture.md) - Core VDFS design +- [../tauri/DAEMON_SETUP.md](../tauri/DAEMON_SETUP.md) - Tauri daemon integration diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml new file mode 100644 index 000000000..d949e337d --- /dev/null +++ b/apps/server/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "sd-server" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +# Include bundled web assets (production builds) +assets = [] + +[dependencies] +# Spacedrive core +sd-core = { path = "../../core", features = ["ffmpeg", "heif"] } + +# HTTP server +axum = "0.7" +axum-extra = { version = "0.9", features = ["typed-header"] } +http = "1.1" +tokio = { version = "1", features = ["rt-multi-thread", "signal", "sync", "io-util"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["fs", "cors"] } + +# Auth +secstr = "0.5" + +# Static assets +include_dir = "0.7" +mime_guess = "2.0" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +anyhow = "1" +thiserror = "1" + +# CLI +clap = { version = "4", features = ["derive"] } + +# Dev dependencies +tempfile = "3" + +[[bin]] +name = "sd-server" +path = "src/main.rs" diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 000000000..00416eabf --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,100 @@ +# Spacedrive Server Docker Image +# Multi-stage build for minimal production image + +ARG RUST_VERSION=1.83 +ARG NODE_VERSION=22 + +#-- +# Base image with common dependencies +#-- +FROM debian:bookworm-slim AS base + +# Configure apt for non-interactive use and caching +RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections +RUN rm -f /etc/apt/apt.conf.d/docker-clean + +RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ + apt-get update && apt-get upgrade -y + +#-- +# Build environment +#-- +FROM base AS builder + +# Install build dependencies +RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ + apt-get install -y \ + build-essential \ + curl \ + git \ + pkg-config \ + libssl-dev \ + ca-certificates + +# Install Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal +ENV PATH="/root/.cargo/bin:$PATH" + +# Install Node.js and pnpm +RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + apt-get install -y nodejs +RUN npm install -g pnpm@latest + +WORKDIR /build + +# Copy workspace configuration +COPY Cargo.toml Cargo.lock ./ +COPY .cargo ./.cargo + +# Copy source code +COPY core ./core +COPY crates ./crates +COPY apps/server ./apps/server +COPY apps/web ./apps/web +COPY packages ./packages + +# Build web assets first +WORKDIR /build/apps/web +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile +RUN pnpm build + +# Build server with embedded assets +WORKDIR /build +RUN --mount=type=cache,target=/root/.cargo/registry \ + --mount=type=cache,target=/root/.cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build --release --features assets -p sd-server && \ + cp target/release/sd-server /usr/local/bin/sd-server + +#-- +# Runtime image +#-- +FROM gcr.io/distroless/cc-debian12:nonroot + +# Copy binary +COPY --from=builder /usr/local/bin/sd-server /usr/bin/sd-server + +# Environment +ENV DATA_DIR=/data \ + PORT=8080 \ + RUST_LOG=info,sd_core=debug + +# Expose HTTP port +EXPOSE 8080 + +# Expose P2P port +EXPOSE 7373 + +# Volume for persistent data +VOLUME ["/data"] + +# Run as non-root user +USER nonroot:nonroot + +# Start server +ENTRYPOINT ["/usr/bin/sd-server"] +CMD ["--data-dir", "/data"] diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 000000000..5b35e2f75 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,319 @@ +# Spacedrive Server + +HTTP server for Spacedrive with embedded daemon and web interface. + +## Overview + +`sd-server` runs the Spacedrive daemon and serves a web interface over HTTP. Perfect for: +- **NAS deployments** (TrueNAS, Unraid, Synology, etc.) +- **Headless servers** +- **Remote access** to your Spacedrive libraries +- **Docker/container environments** + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ sd-server (HTTP Server) │ +│ ┌───────────────────────────────────┐ │ +│ │ Axum HTTP Server (Port 8080) │ │ +│ │ ├─ /health (healthcheck) │ │ +│ │ ├─ /rpc (proxy to daemon) │ │ +│ │ └─ /* (web UI assets) │ │ +│ └───────────────────────────────────┘ │ +│ ↓ │ +│ ┌───────────────────────────────────┐ │ +│ │ Embedded Daemon │ │ +│ │ (Unix socket: daemon.sock) │ │ +│ │ ├─ Core VDFS │ │ +│ │ ├─ Indexing │ │ +│ │ ├─ P2P Networking │ │ +│ │ └─ File Operations │ │ +│ └───────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +Unlike Tauri (desktop app), the server: +- Embeds the daemon instead of spawning a separate process +- Serves the web UI as static assets (when built with `--features assets`) +- Proxies RPC requests from browser to daemon via Unix socket +- Provides basic auth for security + +## Quick Start + +### Development (without Docker) + +1. **Build the server:** + ```bash + # Without web assets (for API-only usage) + cargo build -p sd-server + + # With bundled web UI + cargo build -p sd-server --features assets + ``` + +2. **Run the server:** + ```bash + # Development mode (creates temp data dir) + cargo run -p sd-server + + # Production mode (requires DATA_DIR) + DATA_DIR=/path/to/data cargo run -p sd-server --release --features assets + ``` + +3. **Access the web UI:** + - Open http://localhost:8080 + - Default auth: disabled in dev mode + +### Docker Deployment (Recommended) + +Perfect for TrueNAS, Unraid, or any Docker-compatible NAS. + +1. **Create a `.env` file:** + ```bash + # REQUIRED: Set your credentials + SD_AUTH=admin:your-secure-password + + # Optional: Change port + PORT=8080 + + # Optional: Disable auth (NOT RECOMMENDED) + # SD_AUTH=disabled + ``` + +2. **Start with docker-compose:** + ```bash + cd apps/server + docker-compose up -d + ``` + +3. **Access the server:** + - Navigate to `http://your-nas-ip:8080` + - Login with credentials from `.env` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `DATA_DIR` | Path to Spacedrive data directory | `/data` (in Docker) | Yes (production) | +| `PORT` | HTTP server port | `8080` | No | +| `SD_AUTH` | Authentication credentials (format: `user:pass,user2:pass2`) | None | Recommended | +| `SD_P2P` | Enable P2P networking | `true` | No | +| `RUST_LOG` | Log level | `info,sd_core=debug` | No | + +### Authentication + +**IMPORTANT:** Always set `SD_AUTH` in production! + +```bash +# Single user +SD_AUTH=admin:securepassword123 + +# Multiple users +SD_AUTH=admin:pass1,user:pass2,readonly:pass3 + +# Disable (NOT RECOMMENDED - only for trusted networks) +SD_AUTH=disabled +``` + +Uses HTTP Basic Authentication. The server will return `401 Unauthorized` if credentials don't match. + +### Data Storage + +The server stores all data in `DATA_DIR`: +``` +$DATA_DIR/ +├── daemon/ +│ └── daemon.sock # Unix socket for RPC +├── libraries/ +│ └── *.sdlibrary/ # Library databases +├── logs/ # Application logs +└── current_library_id.txt # Last opened library +``` + +**Docker volumes:** Mounted at `/data` inside the container. + +## TrueNAS Setup + +### Using TrueNAS SCALE (Docker) + +1. **Navigate to Apps** in TrueNAS web UI +2. **Click "Launch Docker Image"** +3. **Configure:** + - **Image:** Build locally or use pre-built image + - **Port:** Map `8080` to host + - **Volume:** Mount `/mnt/pool/spacedrive` to `/data` + - **Environment:** + - `SD_AUTH=admin:yourpassword` + - `TZ=America/New_York` (your timezone) + +4. **Add storage pools** (optional): + - Mount your datasets as read-only volumes + - Example: `/mnt/tank/photos` → `/photos` in container + +### Manual Docker Run + +```bash +docker run -d \ + --name spacedrive \ + -p 8080:8080 \ + -p 7373:7373 \ + -v /mnt/pool/spacedrive:/data \ + -v /mnt/pool/media:/media:ro \ + -e SD_AUTH=admin:password \ + -e TZ=UTC \ + --restart unless-stopped \ + spacedrive/server:latest +``` + +## Building + +### With Web Assets (Production) + +```bash +# Build everything (web UI + server) +cargo build --release -p sd-server --features assets + +# The binary includes bundled web assets +./target/release/sd-server --data-dir /path/to/data +``` + +### Without Assets (API Only) + +```bash +# Build server without web UI +cargo build --release -p sd-server + +# Serve API endpoints only +./target/release/sd-server --data-dir /path/to/data +``` + +In this mode, you can connect with: +- `sd-cli` (CLI client) +- Custom HTTP clients via `/rpc` +- Tauri desktop app configured to connect to this server + +## Development Workflow + +1. **Run web dev server:** + ```bash + cd apps/web + pnpm dev + ``` + This starts Vite on http://localhost:3000 with hot reload. + +2. **Run API server:** + ```bash + cargo run -p sd-server + ``` + This starts the HTTP server on http://localhost:8080. + +3. **Develop:** + - Edit React components in `apps/web/src` + - Edit server code in `apps/server/src` + - Vite proxies `/rpc` requests to the server + +## API Endpoints + +### `GET /health` +Health check endpoint. + +**Response:** `200 OK` with body `"OK"` + +### `POST /rpc` +JSON-RPC proxy to daemon. + +**Request:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "query:libraries.list", + "params": { "include_stats": false } +} +``` + +**Response:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": [...] +} +``` + +### `GET /*` (with `--features assets`) +Serves the bundled web UI. All non-API routes fallback to `index.html` for SPA routing. + +## Comparison: Server vs Tauri + +| Feature | Server | Tauri | +|---------|--------|-------| +| **Platform** | Linux/Docker | macOS/Windows/Linux | +| **UI** | Web (React in browser) | Native webview | +| **Daemon** | Embedded in process | Spawned as child process | +| **Access** | Remote over HTTP | Local only | +| **Auth** | HTTP Basic Auth | Not needed (local) | +| **Use Case** | NAS, headless servers | Desktop workstations | + +Both use the same Spacedrive core and `@sd/interface` package! + +## Troubleshooting + +### Server won't start +- Check `DATA_DIR` exists and is writable +- Verify port 8080 is not in use: `lsof -i :8080` +- Check logs: `RUST_LOG=debug cargo run -p sd-server` + +### Can't connect to daemon +- Ensure `daemon.sock` exists in `$DATA_DIR/daemon/` +- Check daemon logs in `$DATA_DIR/logs/` +- Try removing stale socket: `rm $DATA_DIR/daemon/daemon.sock` + +### Authentication failing +- Verify `SD_AUTH` format: `username:password` +- Check browser is sending Basic Auth header +- Test with curl: + ```bash + curl -u admin:password http://localhost:8080/health + ``` + +### Docker build failing +- Ensure you're building from repository root: + ```bash + docker build -f apps/server/Dockerfile . + ``` +- Check Docker has enough memory (4GB+ recommended) + +## Contributing + +The server app is part of the Spacedrive v2 monorepo. + +**Project structure:** +``` +apps/server/ +├── src/ +│ └── main.rs # Server implementation +├── Cargo.toml # Dependencies +├── build.rs # Web bundling script +├── Dockerfile # Container image +└── docker-compose.yml # Docker setup + +apps/web/ +├── src/ +│ ├── main.tsx # Web entry point +│ └── platform.ts # Web platform implementation +├── package.json +└── vite.config.ts +``` + +**Making changes:** +1. Server code: Edit `apps/server/src/main.rs` +2. Web UI: Edit `apps/web/src/*` (uses `@sd/interface`) +3. Platform integration: Edit `apps/web/src/platform.ts` + +## License + +AGPL-3.0 - See LICENSE file in repository root. diff --git a/apps/server/TRUENAS_SETUP.md b/apps/server/TRUENAS_SETUP.md new file mode 100644 index 000000000..1f4be8f1d --- /dev/null +++ b/apps/server/TRUENAS_SETUP.md @@ -0,0 +1,406 @@ +# TrueNAS SCALE Deployment Guide + +Quick reference for deploying Spacedrive Server on TrueNAS SCALE using the GUI. + +## Quick Start + +### 1. Build and Transfer (on your Mac) + +```bash +cd ~/Projects/spacedrive/apps/server + +# Build for TrueNAS +./build-for-truenas.sh + +# Transfer to TrueNAS (replace with your IP) +scp spacedrive-server-*.tar.gz root@192.168.1.100:/tmp/ +``` + +### 2. Load Image (on TrueNAS) + +```bash +# SSH into TrueNAS +ssh root@192.168.1.100 + +# Load the image +gunzip -c /tmp/spacedrive-server-*.tar.gz | docker load + +# Verify +docker images | grep spacedrive +# Should show: spacedrive-server latest ... +``` + +### 3. Deploy via GUI + +Go to: **Apps** → **Discover Apps** → **Launch Docker Image** + +--- + +## GUI Configuration + +### Container Images + +| Field | Value | +|-------|-------| +| Image Repository | `spacedrive-server` | +| Image Tag | `latest` | +| **Image Pull Policy** | **Never** ⚠️ (Use local image!) | + +### Container Settings + +| Field | Value | +|-------|-------| +| Container Name | `spacedrive` | +| Restart Policy | Unless Stopped | + +### Networking + +#### Port Forwarding + +| Container Port | Protocol | Node Port | Description | +|---------------|----------|-----------|-------------| +| `8080` | TCP | `8080` | Web UI & API | +| `7373` | TCP | `7373` | P2P Networking | + +**Access your server at:** `http://TRUENAS-IP:8080` + +### Storage + +#### Primary Data Volume + +| Field | Value | +|-------|-------| +| Type | Host Path (or ixVolume) | +| Host Path | `/mnt/your-pool/spacedrive` | +| Mount Path | `/data` | +| Read Only | ❌ (needs write access) | + +**This stores:** +- Library databases (`.sdlibrary/`) +- Daemon socket +- Logs +- Thumbnails/sidecars + +#### Optional: Media Access + +Mount your existing media as read-only: + +| Field | Value | +|-------|-------| +| Type | Host Path | +| Host Path | `/mnt/your-pool/media` | +| Mount Path | `/media` | +| Read Only | ✅ | + +Repeat for other datasets: +- `/mnt/your-pool/photos` → `/photos` +- `/mnt/your-pool/documents` → `/documents` + +### Environment Variables + +**Required:** + +| Name | Value | Description | +|------|-------|-------------| +| `SD_AUTH` | `admin:CHANGE_THIS_PASSWORD` | Authentication (username:password) | + +**Recommended:** + +| Name | Value | Description | +|------|-------|-------------| +| `TZ` | `America/New_York` | Your timezone | +| `RUST_LOG` | `info,sd_core=debug` | Log level | + +**Optional:** + +| Name | Value | Description | +|------|-------|-------------| +| `PORT` | `8080` | HTTP port (if you want to change it) | +| `SD_P2P` | `true` | Enable P2P (default: true) | + +### Health Check (Optional) + +| Field | Value | +|-------|-------| +| Type | HTTP | +| Port | `8080` | +| Path | `/health` | +| Initial Delay | `30` seconds | +| Timeout | `10` seconds | +| Period | `30` seconds | + +### Resource Limits (Optional) + +**Recommended for NAS stability:** + +| Resource | Limit | Reservation | +|----------|-------|-------------| +| Memory | `2 GB` | `512 MB` | +| CPU | - | - | + +--- + +## Post-Installation + +### 1. Verify Container is Running + +In TrueNAS GUI: +- **Apps** → **Installed** → Should see **spacedrive** with green status + +Or via shell: +```bash +docker ps | grep spacedrive +``` + +### 2. Check Logs + +In GUI: Click **spacedrive** → **Logs** + +Or via shell: +```bash +docker logs spacedrive +``` + +Should see: +``` +🚀 Spacedrive Server listening on http://localhost:8080 +✓ Daemon started successfully +``` + +### 3. Access Web UI + +Open browser: `http://YOUR-TRUENAS-IP:8080` + +Login with credentials from `SD_AUTH`: +- Username: `admin` +- Password: (whatever you set) + +--- + +## Updating the Server + +When you rebuild on your Mac: + +### 1. Build new image + +```bash +cd ~/Projects/spacedrive/apps/server +./build-for-truenas.sh +``` + +### 2. Transfer and load + +```bash +# Transfer new tar +scp spacedrive-server-*.tar.gz root@TRUENAS-IP:/tmp/ + +# SSH and load +ssh root@TRUENAS-IP +gunzip -c /tmp/spacedrive-server-*.tar.gz | docker load +``` + +### 3. Restart container in GUI + +**Apps** → **Installed** → **spacedrive** → **Stop** → **Start** + +Or via shell: +```bash +docker restart spacedrive +``` + +The container will use the updated `spacedrive-server:latest` image. + +--- + +## Troubleshooting + +### Container won't start + +**Check logs:** +```bash +docker logs spacedrive +``` + +**Common issues:** +- Permission denied on `/data` → Check host path exists and is writable +- Port already in use → Change `8080` to something else +- Auth error → Verify `SD_AUTH` format is `username:password` + +### Can't connect to web UI + +1. Verify container is running: `docker ps | grep spacedrive` +2. Check port mapping: Should show `0.0.0.0:8080->8080/tcp` +3. Test from TrueNAS shell: `curl http://localhost:8080/health` +4. Check firewall rules (TrueNAS should allow by default) + +### Daemon not starting + +**Check socket:** +```bash +docker exec spacedrive ls -la /data/daemon/ +``` + +Should see `daemon.sock` + +**Check daemon logs:** +```bash +docker exec spacedrive cat /data/logs/daemon.log +``` + +### Wrong architecture error + +Make sure you built with `--platform linux/amd64`: +```bash +docker inspect spacedrive-server:latest | grep Architecture +# Should show: "Architecture": "amd64" +``` + +If not, rebuild: +```bash +./build-for-truenas.sh +``` + +--- + +## File Locations + +**On TrueNAS host:** +``` +/mnt/your-pool/spacedrive/ +├── daemon/ +│ └── daemon.sock # Unix socket +├── libraries/ +│ └── My Library.sdlibrary/ +│ ├── library.db # SQLite database +│ └── sidecars/ # Thumbnails +└── logs/ + └── daemon.log +``` + +**Inside container:** +``` +/data/ # Maps to host path above +/media/ # Your media mounts (if configured) +/photos/ +/documents/ +``` + +--- + +## Advanced Configuration + +### Custom Port + +If port 8080 is taken: + +1. Change environment variable: `PORT=9000` +2. Update port forwarding: `9000 → 9000` +3. Access at: `http://TRUENAS-IP:9000` + +### Multiple Instances + +Run multiple Spacedrive instances with different data dirs: + +**Instance 1 (Personal):** +- Container name: `spacedrive-personal` +- Host path: `/mnt/pool/spacedrive-personal` +- Ports: `8080:8080`, `7373:7373` + +**Instance 2 (Work):** +- Container name: `spacedrive-work` +- Host path: `/mnt/pool/spacedrive-work` +- Ports: `8081:8080`, `7374:7373` +- Env: `INSTANCE=work` + +### Reverse Proxy (HTTPS) + +If you want HTTPS access, put behind nginx/Caddy: + +**Caddy example:** +``` +spacedrive.yourdomain.com { + reverse_proxy localhost:8080 + basicauth { + admin $2a$14$... # hashed password + } +} +``` + +--- + +## Security Notes + +**⚠️ IMPORTANT:** +- **Always set SD_AUTH** - never use `SD_AUTH=disabled` on a network-accessible server +- **Use strong passwords** - not `admin:changeme` +- Consider **firewall rules** if exposing to internet +- Run behind **reverse proxy with HTTPS** for public access +- **Read-only mounts** for media you don't want Spacedrive to modify + +**Network access:** +- `8080` → Web UI (needs auth) +- `7373` → P2P (encrypted via QUIC/TLS) + +--- + +## Backup + +**What to backup:** +``` +/mnt/your-pool/spacedrive/libraries/ +``` + +This contains your library databases and metadata. + +**How:** +- TrueNAS snapshots (recommended) +- Or periodic `rsync`/`tar` backup + +**Not needed:** +- `daemon.sock` (recreated on start) +- `logs/` (optional) + +--- + +## Support + +**View logs:** +```bash +docker logs -f spacedrive +``` + +**Shell access:** +```bash +docker exec -it spacedrive sh +``` + +**Check daemon status:** +```bash +curl -u admin:yourpassword http://TRUENAS-IP:8080/health +``` + +Should return: `OK` + +--- + +## Quick Reference Card + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TrueNAS SCALE: Spacedrive Server │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Image: spacedrive-server:latest │ +│ Pull: Never (use local image) │ +│ │ +│ Ports: 8080 (Web UI), 7373 (P2P) │ +│ │ +│ Volume: /mnt/pool/spacedrive → /data │ +│ │ +│ Env: SD_AUTH=admin:password (REQUIRED) │ +│ TZ=America/New_York │ +│ │ +│ Access: http://TRUENAS-IP:8080 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` diff --git a/apps/server/build-for-truenas.sh b/apps/server/build-for-truenas.sh new file mode 100755 index 000000000..f7d662b1c --- /dev/null +++ b/apps/server/build-for-truenas.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Build Spacedrive Server for TrueNAS deployment +# Usage: ./build-for-truenas.sh [truenas-ip] + +set -e + +TRUENAS_IP="${1:-}" +IMAGE_NAME="spacedrive-server" +IMAGE_TAG="latest" +TAR_FILE="spacedrive-server-$(date +%Y%m%d-%H%M%S).tar.gz" + +echo "🏗️ Building Spacedrive Server for linux/amd64..." +echo "" + +# Check if buildx is available +if ! docker buildx version &> /dev/null; then + echo "❌ docker buildx not found. Installing..." + docker buildx create --use +fi + +# Build the image for linux/amd64 (TrueNAS architecture) +echo "📦 Building Docker image..." +cd ../.. # Go to repository root + +docker buildx build \ + --platform linux/amd64 \ + -f apps/server/Dockerfile \ + -t ${IMAGE_NAME}:${IMAGE_TAG} \ + --load \ + . + +echo "" +echo "✅ Build complete!" +echo "" + +# Save the image to a tar file +echo "💾 Saving image to ${TAR_FILE}..." +docker save ${IMAGE_NAME}:${IMAGE_TAG} | gzip > "apps/server/${TAR_FILE}" + +IMAGE_SIZE=$(du -h "apps/server/${TAR_FILE}" | cut -f1) +echo "✅ Image saved: apps/server/${TAR_FILE} (${IMAGE_SIZE})" +echo "" + +# If TrueNAS IP provided, offer to transfer +if [ -n "$TRUENAS_IP" ]; then + echo "📤 Transfer to TrueNAS?" + echo " Target: root@${TRUENAS_IP}" + echo "" + read -p "Continue? (y/n) " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Transferring..." + scp "apps/server/${TAR_FILE}" "root@${TRUENAS_IP}:/tmp/" + + echo "" + echo "✅ Transfer complete!" + echo "" + echo "📋 Next steps on TrueNAS:" + echo " 1. SSH into TrueNAS: ssh root@${TRUENAS_IP}" + echo " 2. Load the image: gunzip -c /tmp/${TAR_FILE} | docker load" + echo " 3. Deploy via TrueNAS SCALE Apps UI (see instructions below)" + echo "" + fi +else + echo "📋 Manual deployment steps:" + echo "" + echo "1. Transfer the image to TrueNAS:" + echo " scp apps/server/${TAR_FILE} root@YOUR-TRUENAS-IP:/tmp/" + echo "" + echo "2. SSH into TrueNAS:" + echo " ssh root@YOUR-TRUENAS-IP" + echo "" + echo "3. Load the image:" + echo " gunzip -c /tmp/${TAR_FILE} | docker load" + echo "" + echo "4. Verify it loaded:" + echo " docker images | grep spacedrive" + echo "" +fi + +echo "═══════════════════════════════════════════════════════════════" +echo "🎯 TrueNAS SCALE GUI Deployment Instructions" +echo "═══════════════════════════════════════════════════════════════" +echo "" +echo "After loading the image on TrueNAS:" +echo "" +echo "1. Go to: Apps → Discover Apps → Launch Docker Image" +echo "" +echo "2. Container Settings:" +echo " • Image Repository: ${IMAGE_NAME}" +echo " • Image Tag: ${IMAGE_TAG}" +echo " • Image Pull Policy: Never (important - use local image!)" +echo " • Container Name: spacedrive" +echo "" +echo "3. Port Forwarding:" +echo " • Container Port: 8080 → Node Port: 8080" +echo " • Container Port: 7373 → Node Port: 7373" +echo "" +echo "4. Storage (Host Path Volumes):" +echo " • Host Path: /mnt/YOUR-POOL/spacedrive" +echo " Mount Path: /data" +echo " Type: ixVolume (or Host Path)" +echo "" +echo " Optional - Mount your media:" +echo " • Host Path: /mnt/YOUR-POOL/media" +echo " Mount Path: /media" +echo " Read Only: ✓" +echo "" +echo "5. Environment Variables:" +echo " • SD_AUTH = admin:changeme (CHANGE THIS!)" +echo " • TZ = America/New_York (your timezone)" +echo " • RUST_LOG = info,sd_core=debug" +echo "" +echo "6. Health Check (optional but recommended):" +echo " • Type: HTTP" +echo " • Port: 8080" +echo " • Path: /health" +echo "" +echo "7. Click 'Install'" +echo "" +echo "═══════════════════════════════════════════════════════════════" +echo "" +echo "🌐 Access: http://YOUR-TRUENAS-IP:8080" +echo "🔐 Login with credentials from SD_AUTH" +echo "" diff --git a/apps/server/build.rs b/apps/server/build.rs new file mode 100644 index 000000000..7aaea3db6 --- /dev/null +++ b/apps/server/build.rs @@ -0,0 +1,50 @@ +fn main() { + // Only build web assets if the assets feature is enabled + #[cfg(feature = "assets")] + { + use std::process::Command; + println!("cargo:rerun-if-changed=../web/src"); + println!("cargo:rerun-if-changed=../web/index.html"); + println!("cargo:rerun-if-changed=../web/package.json"); + + // Build the web app + let web_dir = std::env::current_dir() + .expect("Failed to get current dir") + .join("../web"); + + // Check if pnpm is available + let pnpm_check = Command::new("pnpm") + .arg("--version") + .output(); + + if pnpm_check.is_err() { + panic!("pnpm is required to build web assets. Install it with: npm install -g pnpm"); + } + + // Install dependencies + println!("Installing web dependencies..."); + let install = Command::new("pnpm") + .arg("install") + .current_dir(&web_dir) + .status() + .expect("Failed to run pnpm install"); + + if !install.success() { + panic!("pnpm install failed"); + } + + // Build the web app + println!("Building web app..."); + let build = Command::new("pnpm") + .arg("build") + .current_dir(&web_dir) + .status() + .expect("Failed to run pnpm build"); + + if !build.success() { + panic!("pnpm build failed"); + } + + println!("Web assets built successfully"); + } +} diff --git a/apps/server/docker-compose.yml b/apps/server/docker-compose.yml new file mode 100644 index 000000000..1f29edb65 --- /dev/null +++ b/apps/server/docker-compose.yml @@ -0,0 +1,60 @@ +services: + spacedrive: + build: + context: ../.. + dockerfile: apps/server/Dockerfile + container_name: spacedrive-server + restart: unless-stopped + + # Ports + ports: + - "8080:8080" # HTTP server + - "7373:7373" # P2P networking + + # Volumes + volumes: + - spacedrive-data:/data + # Optional: Mount your file systems here + # - /mnt/storage:/storage:ro + + # Environment variables + environment: + # Data directory (inside container) + - DATA_DIR=/data + + # HTTP server port + - PORT=8080 + + # Authentication (REQUIRED for security!) + # Format: "username:password,username2:password2" + # Or set to "disabled" to disable auth (NOT RECOMMENDED) + - SD_AUTH=${SD_AUTH:-admin:changeme} + + # Enable P2P networking + - SD_P2P=true + + # Logging + - RUST_LOG=info,sd_core=debug + + # Timezone + - TZ=UTC + + # Health check + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Resource limits (adjust as needed) + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 512M + +volumes: + spacedrive-data: + driver: local diff --git a/apps/server/docker-run.sh b/apps/server/docker-run.sh new file mode 100755 index 000000000..53941bac9 --- /dev/null +++ b/apps/server/docker-run.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Quick start script for running Spacedrive Server in Docker +# Usage: ./docker-run.sh + +set -e + +echo "🚀 Starting Spacedrive Server with Docker Compose..." + +# Check if .env exists +if [ ! -f .env ]; then + echo "⚠️ No .env file found. Creating from template..." + cp .env.example .env + echo "" + echo "📝 IMPORTANT: Edit .env and set your SD_AUTH credentials!" + echo " Default is 'admin:changeme' - please change this." + echo "" + read -p "Press enter to continue or Ctrl+C to abort..." +fi + +# Check if docker-compose is available +if ! command -v docker-compose &> /dev/null; then + if ! command -v docker &> /dev/null; then + echo "❌ Docker not found. Please install Docker first." + exit 1 + fi + # Try docker compose (newer syntax) + COMPOSE_CMD="docker compose" +else + COMPOSE_CMD="docker-compose" +fi + +echo "🏗️ Building and starting container..." +$COMPOSE_CMD up -d --build + +echo "" +echo "✅ Spacedrive Server is running!" +echo "" +echo "📍 Access your server at: http://localhost:8080" +echo "🔐 Login credentials: Check your .env file (SD_AUTH)" +echo "" +echo "Useful commands:" +echo " - View logs: $COMPOSE_CMD logs -f spacedrive" +echo " - Stop server: $COMPOSE_CMD down" +echo " - Restart: $COMPOSE_CMD restart" +echo " - Shell access: $COMPOSE_CMD exec spacedrive sh" +echo "" diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs new file mode 100644 index 000000000..3428a0946 --- /dev/null +++ b/apps/server/src/main.rs @@ -0,0 +1,427 @@ +use axum::{ + body::Body, + extract::{FromRequestParts, Request, State}, + http::{header, HeaderValue, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use axum_extra::{headers::authorization::Basic, headers::Authorization, TypedHeader}; +use clap::Parser; +use secstr::SecStr; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::UnixStream, + signal, + sync::RwLock, +}; +use tracing::{info, warn}; + +#[cfg(feature = "assets")] +static ASSETS_DIR: include_dir::Dir<'static> = + include_dir::include_dir!("$CARGO_MANIFEST_DIR/../web/dist"); + +#[derive(Clone)] +struct AppState { + auth: HashMap, + socket_path: PathBuf, +} + +/// Basic auth middleware +async fn basic_auth(State(state): State, request: Request, next: Next) -> Response { + let request = if !state.auth.is_empty() { + let (mut parts, body) = request.into_parts(); + + let Ok(TypedHeader(Authorization(hdr))) = + TypedHeader::>::from_request_parts(&mut parts, &()).await + else { + return Response::builder() + .status(401) + .header("WWW-Authenticate", "Basic realm=\"Spacedrive\"") + .body("Unauthorized".into_response().into_body()) + .expect("hardcoded response will be valid"); + }; + let request = Request::from_parts(parts, body); + + if state + .auth + .get(hdr.username()) + .map(|pass| *pass == SecStr::from(hdr.password())) + != Some(true) + { + return Response::builder() + .status(401) + .header("WWW-Authenticate", "Basic realm=\"Spacedrive\"") + .body("Unauthorized".into_response().into_body()) + .expect("hardcoded response will be valid"); + } + + request + } else { + request + }; + + next.run(request).await +} + +/// Health check endpoint +async fn health() -> &'static str { + "OK" +} + +/// Proxy RPC requests to the daemon via Unix socket +async fn daemon_rpc( + State(state): State, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + // Connect to daemon + let mut stream = UnixStream::connect(&state.socket_path) + .await + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Daemon not available: {}", e)))?; + + // Send request + let request_line = serde_json::to_string(&payload) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)))?; + + stream + .write_all(format!("{}\n", request_line).as_bytes()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write failed: {}", e)))?; + + // Read response + let mut reader = BufReader::new(stream); + let mut response_line = String::new(); + + reader + .read_line(&mut response_line) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Read failed: {}", e)))?; + + // Parse and return + let response: serde_json::Value = serde_json::from_str(&response_line) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid response: {}", e)))?; + + Ok(Json(response)) +} + +#[cfg(feature = "assets")] +async fn serve_asset(path: String) -> Response { + let path = path.trim_start_matches('/'); + + match ASSETS_DIR.get_file(path) { + Some(file) => Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + HeaderValue::from_str( + mime_guess::from_path(path) + .first_or_text_plain() + .as_ref(), + ) + .unwrap(), + ) + .body(Body::from(file.contents())) + .unwrap(), + None => { + // Fallback to index.html for SPA routing + match ASSETS_DIR.get_file("index.html") { + Some(file) => Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + HeaderValue::from_str("text/html").unwrap(), + ) + .body(Body::from(file.contents())) + .unwrap(), + None => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .unwrap(), + } + } + } +} + +#[cfg(feature = "assets")] +async fn serve_index() -> Response { + match ASSETS_DIR.get_file("index.html") { + Some(file) => Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + HeaderValue::from_str("text/html").unwrap(), + ) + .body(Body::from(file.contents())) + .unwrap(), + None => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .unwrap(), + } +} + +#[derive(Parser, Debug)] +#[command(name = "spacedrive-server", about = "Spacedrive HTTP server")] +struct Args { + /// Path to spacedrive data directory + #[arg(long, env = "DATA_DIR")] + data_dir: Option, + + /// Port to bind HTTP server (default: 8080) + #[arg(long, env = "PORT", default_value = "8080")] + port: u16, + + /// Authentication credentials (format: "username:password,username2:password2") + /// Set to "disabled" to disable auth (not recommended in production) + #[arg(long, env = "SD_AUTH")] + auth: Option, + + /// Daemon instance name (for running multiple instances) + #[arg(long)] + instance: Option, + + /// Enable P2P networking + #[arg(long, env = "SD_P2P", default_value = "true")] + p2p: bool, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,sd_core=debug,sd_server=debug".into()), + ) + .init(); + + let args = Args::parse(); + + // Resolve data directory + let base_data_dir = args + .data_dir + .unwrap_or_else(|| { + #[cfg(not(debug_assertions))] + { + std::env::var("DATA_DIR") + .expect("DATA_DIR must be set in production") + .into() + } + #[cfg(debug_assertions)] + { + std::env::var("DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + let temp = tempfile::tempdir().expect("Failed to create temp dir"); + temp.path().to_path_buf() + }) + } + }); + + // Calculate instance-specific paths + let (data_dir, socket_path) = if let Some(instance) = &args.instance { + let instance_data_dir = base_data_dir.join("instances").join(instance); + let socket_path = base_data_dir + .join("daemon") + .join(format!("daemon-{}.sock", instance)); + (instance_data_dir, socket_path) + } else { + let socket_path = base_data_dir.join("daemon/daemon.sock"); + (base_data_dir.clone(), socket_path) + }; + + info!("Data directory: {:?}", data_dir); + info!("Socket path: {:?}", socket_path); + + // Parse authentication + let (auth, _disabled) = parse_auth(args.auth.as_deref()); + + // Require credentials in production builds (unless explicitly disabled) + #[cfg(not(debug_assertions))] + if auth.is_empty() && !_disabled { + warn!("The 'SD_AUTH' environment variable is not set!"); + warn!("If you want to disable auth set 'SD_AUTH=disabled', or"); + warn!("Provide your credentials in the following format 'SD_AUTH=username:password,username2:password2'"); + std::process::exit(1); + } + + // Ensure daemon directory exists + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Start the daemon if not already running + let daemon_handle = start_daemon_if_needed(socket_path.clone(), data_dir.clone(), args.p2p).await?; + + // Build HTTP router + let state = AppState { + auth, + socket_path: socket_path.clone(), + }; + + let app = Router::new() + .route("/health", get(health)) + .route("/rpc", post(daemon_rpc)); + + // Add asset serving routes if assets feature is enabled + #[cfg(feature = "assets")] + let app = app + .route("/", get(serve_index)) + .route("/*path", get(|axum::extract::Path(path): axum::extract::Path| serve_asset(path))); + + #[cfg(not(feature = "assets"))] + let app = app.route("/", get(|| async { "Spacedrive Server - Use with web client" })); + + let app = app + .fallback(|| async { + ( + StatusCode::NOT_FOUND, + "404 Not Found: We're past the event horizon...", + ) + }) + .layer(middleware::from_fn_with_state(state.clone(), basic_auth)) + .with_state(state); + + // Bind server + let mut addr = "[::]:8080".parse::().unwrap(); + addr.set_port(args.port); + + info!("🚀 Spacedrive Server listening on http://localhost:{}", args.port); + #[cfg(feature = "assets")] + info!("📦 Serving bundled web assets"); + #[cfg(not(feature = "assets"))] + info!("📦 Asset serving disabled (use --features assets)"); + + // Setup graceful shutdown + let shutdown_signal = shutdown_signal(daemon_handle); + + // Start server + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal) + .await?; + + Ok(()) +} + +/// Parse authentication credentials from env var +fn parse_auth(auth_str: Option<&str>) -> (HashMap, bool) { + let Some(input) = auth_str else { + return (HashMap::new(), false); + }; + + if input == "disabled" { + return (HashMap::new(), true); + } + + let credentials = input + .split(',') + .enumerate() + .filter_map(|(i, s)| { + if s.is_empty() { + return None; + } + + let mut parts = s.split(':'); + let result = parts + .next() + .and_then(|user| parts.next().map(|pass| (user.to_string(), SecStr::from(pass)))); + + if result.is_none() { + warn!("Found invalid credential {i}. Skipping..."); + } + result + }) + .collect(); + + (credentials, false) +} + +/// Start the daemon if it's not already running +async fn start_daemon_if_needed( + socket_path: PathBuf, + data_dir: PathBuf, + enable_p2p: bool, +) -> Result>>>, Box> { + // Check if daemon is already running + if socket_path.exists() { + match UnixStream::connect(&socket_path).await { + Ok(_) => { + info!("✓ Daemon already running"); + return Ok(None); + } + Err(_) => { + warn!("Stale socket file found, removing..."); + std::fs::remove_file(&socket_path).ok(); + } + } + } + + info!("Starting embedded daemon..."); + + // Start daemon in background task + let socket_path_clone = socket_path.clone(); + let data_dir_clone = data_dir.clone(); + + let handle = tokio::spawn(async move { + if let Err(e) = sd_core::infra::daemon::bootstrap::start_default_server( + socket_path_clone, + data_dir_clone, + enable_p2p, + ) + .await + { + tracing::error!("Daemon failed: {}", e); + } + }); + + // Wait for socket to be created (daemon startup) + for i in 0..30 { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + if socket_path.exists() { + info!("✓ Daemon started successfully"); + return Ok(Some(Arc::new(RwLock::new(handle)))); + } + if i == 10 { + warn!("Daemon taking longer than expected to start..."); + } + } + + Err("Daemon failed to start (socket not created after 3 seconds)".into()) +} + +/// Graceful shutdown handler +async fn shutdown_signal(daemon_handle: Option>>>) { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => { + info!("Received Ctrl+C, shutting down gracefully..."); + } + () = terminate => { + info!("Received SIGTERM, shutting down gracefully..."); + } + } + + // Abort daemon task if we started it + if let Some(handle) = daemon_handle { + handle.write().await.abort(); + } +} diff --git a/apps/tauri/src-tauri/src/main.rs b/apps/tauri/src-tauri/src/main.rs index f9db1006c..ccb0fe9c4 100644 --- a/apps/tauri/src-tauri/src/main.rs +++ b/apps/tauri/src-tauri/src/main.rs @@ -915,24 +915,6 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box> { .accelerator("Cmd+O") .build(app)?; - let copy_item = MenuItemBuilder::with_id("copy", "Copy") - .accelerator("Cmd+C") - .enabled(false) - .build(app)?; - menu_items_map.insert("copy".to_string(), copy_item.clone()); - - let paste_item = MenuItemBuilder::with_id("paste", "Paste") - .accelerator("Cmd+V") - .enabled(false) - .build(app)?; - menu_items_map.insert("paste".to_string(), paste_item.clone()); - - let cut_item = MenuItemBuilder::with_id("cut", "Cut") - .accelerator("Cmd+X") - .enabled(false) - .build(app)?; - menu_items_map.insert("cut".to_string(), cut_item.clone()); - let duplicate_item = MenuItemBuilder::with_id("duplicate", "Duplicate") .accelerator("Cmd+D") .enabled(false) @@ -954,9 +936,6 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box> { let file_menu = SubmenuBuilder::new(app, "File") .item(&open_library_item) .separator() - .item(©_item) - .item(&paste_item) - .item(&cut_item) .item(&duplicate_item) .separator() .item(&rename_item) @@ -964,6 +943,17 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box> { .item(&delete_item) .build()?; + // Edit menu with native clipboard operations + let edit_menu = SubmenuBuilder::new(app, "Edit") + .item(&PredefinedMenuItem::undo(app, None)?) + .item(&PredefinedMenuItem::redo(app, None)?) + .separator() + .item(&PredefinedMenuItem::cut(app, None)?) + .item(&PredefinedMenuItem::copy(app, None)?) + .item(&PredefinedMenuItem::paste(app, None)?) + .item(&PredefinedMenuItem::select_all(app, None)?) + .build()?; + let view_menu = SubmenuBuilder::new(app, "View") .item( &MenuItemBuilder::with_id("drag-demo", "Drag Demo") @@ -980,6 +970,7 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box> { let menu = MenuBuilder::new(app) .item(&app_menu) .item(&file_menu) + .item(&edit_menu) .item(&view_menu) .build()?; @@ -1152,7 +1143,7 @@ fn setup_menu(app: &AppHandle) -> Result<(), Box> { }); } // File menu actions - emit events to frontend - "copy" | "paste" | "cut" | "duplicate" | "rename" | "delete" => { + "duplicate" | "rename" | "delete" => { if let Err(e) = app_handle.emit("menu-action", event_id) { tracing::error!("Failed to emit menu action: {}", e); } diff --git a/apps/tauri/src-tauri/tauri.conf.json b/apps/tauri/src-tauri/tauri.conf.json index 1a4ae7f6a..03768884b 100644 --- a/apps/tauri/src-tauri/tauri.conf.json +++ b/apps/tauri/src-tauri/tauri.conf.json @@ -50,13 +50,30 @@ "active": true, "targets": "all", "publisher": "Spacedrive Technology Inc.", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "fileAssociations": [ + { + "ext": ["memory"], + "name": "Spacedrive Memory", + "description": "Spacedrive Memory File", + "role": "Editor", + "mimeType": "application/x-spacedrive-memory" + } + ], "linux": { "deb": { "depends": ["libc6", "libxdo3", "libwebkit2gtk-4.1-0", "libgtk-3-0"] } }, "macOS": { - "minimumSystemVersion": "10.15" + "minimumSystemVersion": "10.15", + "signingIdentity": "Apple Development: James Pine (TU2EDB6U4N)" }, "windows": { "webviewInstallMode": { diff --git a/apps/tauri/src/platform.ts b/apps/tauri/src/platform.ts index 0dcf5fc91..ba7bdfbab 100644 --- a/apps/tauri/src/platform.ts +++ b/apps/tauri/src/platform.ts @@ -135,4 +135,25 @@ export const platform: Platform = { }); return unlisten; }, + + async getDaemonStatus() { + return await invoke<{ + is_running: boolean; + socket_path: string; + server_url: string | null; + started_by_us: boolean; + }>("get_daemon_status"); + }, + + async startDaemonProcess() { + await invoke("start_daemon_process"); + }, + + async stopDaemonProcess() { + await invoke("stop_daemon_process"); + }, + + async openMacOSSettings() { + await invoke("open_macos_settings"); + }, }; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 000000000..a8e400e0c --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Spacedrive + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..45aea4f09 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "@sd/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@sd/interface": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.0" + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 000000000..16ac20fb3 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { PlatformProvider } from "@sd/interface/platform"; +import { Explorer } from "@sd/interface"; +import { platform } from "./platform"; +import "@sd/interface/styles.css"; + +/** + * Web entry point for Spacedrive server interface + */ +function App() { + return ( + + + + ); +} + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/apps/web/src/platform.ts b/apps/web/src/platform.ts new file mode 100644 index 000000000..697c39973 --- /dev/null +++ b/apps/web/src/platform.ts @@ -0,0 +1,22 @@ +import type { Platform } from "@sd/interface/platform"; + +/** + * Web platform implementation for Spacedrive server + * + * This provides a minimal platform abstraction for the web client. + * Unlike Tauri, web platform cannot access native file system or daemon state directly. + */ +export const platform: Platform = { + platform: "web", + + openLink(url: string) { + window.open(url, "_blank", "noopener,noreferrer"); + }, + + confirm(message: string, callback: (result: boolean) => void) { + callback(window.confirm(message)); + }, + + // Web-specific implementations (no native capabilities) + // File pickers, daemon control, etc. are not available on web +}; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 000000000..aca48955d --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 000000000..eca66688d --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 000000000..3da66324d --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + // Proxy RPC requests to server + "/rpc": { + target: "http://localhost:8080", + changeOrigin: true, + }, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + }, +}); diff --git a/core/src/location/manager.rs b/core/src/location/manager.rs index fdb973abc..af9f85532 100644 --- a/core/src/location/manager.rs +++ b/core/src/location/manager.rs @@ -214,24 +214,14 @@ impl LocationManager { watch_enabled: true, }; - // Emit location added event (legacy) + // Emit legacy event for backwards compatibility self.events.emit(Event::LocationAdded { library_id: library.id(), location_id, path: location_path.clone(), }); - // Emit resource events via ResourceManager (not from sync system) - let resource_manager = crate::domain::ResourceManager::new( - std::sync::Arc::new(library.db().conn().clone()), - std::sync::Arc::new(self.events.clone()), - ); - if let Err(e) = resource_manager - .emit_resource_events("location", vec![location_id]) - .await - { - warn!("Failed to emit location resource events: {}", e); - } + // Resource events are now automatically emitted by sync_model_with_db above // Also emit indexing started event self.events.emit(Event::IndexingStarted { location_id }); diff --git a/docs/core/memory.mdx b/docs/core/memory.mdx new file mode 100644 index 000000000..ce958f1f5 --- /dev/null +++ b/docs/core/memory.mdx @@ -0,0 +1,252 @@ +--- +title: Memory Files +sidebarTitle: Memory Files +--- + +Memory files are Spacedrive's knowledge management primitive. They make AI context portable, persistent, and owned by you. Create a memory file for any task—analyzing financial records, organizing research, refactoring code, understanding email archives—and the knowledge stays with your files forever. + +A memory file is a single-file archive containing document references, learned facts, and vector embeddings. Open it and your AI agent has perfect context instantly. Return to a project months later and continue exactly where you left off. Share a memory file and transfer weeks of accumulated knowledge in seconds. + +## The Problem + +Traditional AI tools store your knowledge in their cloud. Cursor, ChatGPT, and others keep conversation history and context on their servers. You can't export it, version it, or control where it lives. Your knowledge is trapped in their infrastructure. + +Memory files solve this by making knowledge a first-class file type. They live in your filesystem, sync through peer-to-peer connections, and work entirely offline. You own the data. + +## File Format + +Memory files use a custom archive format optimized for incremental updates. The format stores MessagePack-encoded data with an append-only design. + +``` +my-task.memory (single file) +├─ Header (64 bytes) +│ ├─ Magic: "SDMEMORY" +│ ├─ Version: u32 +│ └─ Index offset: u64 +├─ Data section (append-only) +│ ├─ metadata.msgpack +│ ├─ documents.msgpack +│ ├─ facts.msgpack +│ └─ embeddings.msgpack +└─ Index (at end) + └─ File locations +``` + +Updates work by appending new versions of files. The index at the end points to the latest version of each file. Reading requires a single seek operation to the index, then another seek to the file data. + + +Memory files are recognized by magic bytes `SDMEMORY` and the `.memory` extension. They appear as document files in Spacedrive. + + +## Structure + +### Documents + +Document references track files relevant to your task. Each document includes a title, optional summary, and relevance score. + +```rust +Document { + id: 1, + title: "library_sync.mdx", + summary: "Explains dual sync protocols", + doc_type: Documentation, + relevance_score: 1.0, +} +``` + +Documents can reference Spacedrive content via UUID or point to external files via path. The summary helps agents understand document purpose without reading the entire file. + +### Facts + +Facts capture learned knowledge extracted from documents and conversations. Each fact includes a type, confidence score, and optional source reference. + +```rust +Fact { + id: 1, + text: "Device-owned data uses state-based sync", + fact_type: Principle, + confidence: 1.0, + verified: true, +} +``` + +Fact types include Principle, Decision, Pattern, Issue, and Detail. Agents prioritize verified facts with high confidence scores when preparing context. + +### Embeddings + +Vector embeddings enable semantic search within the memory. Each document can have an associated embedding vector for similarity-based retrieval. + +The current implementation uses MessagePack-serialized vectors with cosine similarity search. This works efficiently for memories containing hundreds of documents. Larger memories will migrate to LanceDB for sub-linear search performance. + +### Scope + +Memories can be scoped to different parts of your filesystem. + +**Directory scope** attaches the memory to a specific folder: +```rust +MemoryScope::Directory { + path: "/core/src/sync" +} +``` + +**Project scope** covers an entire repository: +```rust +MemoryScope::Project { + root_path: "/Projects/spacedrive" +} +``` + +**Standalone memories** are portable knowledge packages independent of location: +```rust +MemoryScope::Standalone +``` + +## Usage + +Memory files integrate with AI agents through a loading mechanism. When an agent loads a memory, it receives curated context instead of discovering it through search. + + + +Create a memory file for your task or domain. This can happen automatically during agent conversations or manually through the UI. + + + +Documents and facts accumulate as you work. High-quality conversations automatically generate facts. Manual curation refines the knowledge base. + + + +Open the memory file or load it into a chat session. The agent receives instant context without searching your filesystem. + + + +As you continue working, the memory grows. Facts get verified, new documents added, relevance scores adjusted. + + + +## Creating Memories + +Memory files can be created for any task. Research projects accumulate papers and notes. Accounting work gathers receipts and transactions. Code refactoring collects relevant modules and design decisions. + +```rust +let memory = MemoryFile::create( + "tax-preparation".to_string(), + MemoryScope::Directory { + path: "/Documents/Finance/2024".to_string() + }, + &output_path, +).await?; +``` + +The create operation initializes an empty archive with the standard file structure. New memories contain no documents or facts until you add them. Extensions can create memories automatically during analysis workflows. + +## Adding Knowledge + +Add documents to track relevant files: + +```rust +let doc_id = memory.add_document( + Some(content_uuid), + "library_sync.mdx".to_string(), + Some("Complete sync protocol documentation".to_string()), + DocumentType::Documentation, +).await?; +``` + +Extract facts from those documents: + +```rust +memory.add_fact( + "Shared resources use HLC ordering".to_string(), + FactType::Principle, + 1.0, + Some(doc_id), +).await?; +``` + +Add embeddings for semantic search: + +```rust +let vector = embedding_model.encode(&document_text)?; +memory.add_embedding(doc_id, vector).await?; +``` + +## Searching + +Search for similar documents by vector similarity: + +```rust +let query_vector = embedding_model.encode("sync protocols")?; +let similar_docs = memory.search_similar(query_vector, 10).await?; +``` + +The search returns document IDs ranked by relevance. You can then retrieve the full document information or load the referenced files. + +## Composition + +Load multiple memories for work spanning different domains. A business analysis might combine financial records, email context, and project documentation. Development work might load architecture, implementation, and testing knowledge. + +```rust +agent.load_memories(vec![ + "quarterly-finances.memory", + "client-communications.memory", + "project-timeline.memory", +]).await?; +``` + +The agent combines knowledge from all loaded memories. This enables reasoning across domains without maintaining a single monolithic knowledge base. + + +Create focused memories for specific tasks. Compose them as needed rather than building one large memory for everything. + + +## Performance + +Memory file operations complete quickly due to the indexed format. + +Opening a memory with 100 documents takes under 100ms. This includes loading metadata, documents, facts, and initializing the vector store. + +Searching 500 embeddings completes in 20-50ms using cosine similarity. For memories exceeding 1000 documents, migration to LanceDB provides sub-20ms search through HNSW indexing. + +Updates append to the archive without rewriting existing data. Adding a document or fact takes 5-10ms. The index update is the only write to existing file regions. + +## Storage + +Memory files store data efficiently through MessagePack encoding. A typical memory with 100 documents, 50 facts, and embeddings occupies 5-10MB on disk. + +The archive format allows files to grow incrementally. Adding new knowledge appends data without reading or rewriting the entire file. Periodic compaction removes old versions of updated files, though this is rarely needed. + +## Ownership + +Memory files are your data. They live in your filesystem, sync through Spacedrive's peer-to-peer network, and work entirely offline. No cloud service processes your knowledge. No API tracks your conversations. + +You can copy memory files like any document. Share them with colleagues to transfer domain expertise. Back them up with your files. Version them in git. The knowledge is yours. + + +Memory files are regular Spacedrive content. They sync across devices, appear in search results, and can be tagged and organized like any other file. + + +## Use Cases + +**Research** - Papers, notes, and extracted insights for academic or business research. Query your accumulated knowledge semantically across hundreds of documents. + +**Financial Analysis** - Receipts, statements, and transaction patterns for accounting or tax preparation. Facts capture tax rules and categorization decisions. + +**Email Archives** - Conversations, contacts, and relationship timelines. Search semantically across years of correspondence. + +**Development** - Code, documentation, and architectural decisions for software projects. Context that would take hours to rebuild loads in milliseconds. + +**Knowledge Management** - Any domain where you need to understand large document collections. Medical records, legal cases, historical research, personal archives. + +## The Difference + +Traditional AI tools offer powerful capabilities but keep your knowledge trapped. Cursor provides excellent code assistance but conversations disappear. ChatGPT stores your data in the cloud. Notion AI requires internet and vendor trust. + +Spacedrive makes knowledge a file type. Memory files live alongside the documents they describe. They sync through your devices using the same infrastructure as your photos and files. Extensions can create and use memories without special permissions. Agents load them like opening a document. + +This approach enables capabilities impossible with cloud services. Share a memory file and instantly transfer domain expertise. Version a memory file to track knowledge evolution. Merge memories to combine research from multiple projects. Back up memories with your regular backup strategy. + +## Related Documentation + +- [Virtual Sidecars](/docs/core/virtual-sidecars) - Pre-analyzed file data +- [Extensions](/docs/extensions/introduction) - Building extensions with memory support +- [Library Sync](/docs/core/library-sync) - How memories sync across devices diff --git a/docs/mint.json b/docs/mint.json index 4d4f67fdf..307dc4e2e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1,144 +1,145 @@ { - "$schema": "https://mintlify.com/schema.json", - "name": "Spacedrive", - "logo": { - "light": "/logo/spacedrive-logo.png", - "dark": "/logo/spacedrive-logo.png" - }, - "favicon": "/public/favicon.png", - "colors": { - "primary": "#36A3FF", - "light": "#36A3FF", - "dark": "#36A3FF" - }, - "styles": { - "css": ["/custom.css"] - }, - "anchors": [ - { - "name": "Overview", - "icon": "book-open", - "url": "overview" - }, - { - "name": "Developer", - "icon": "code", - "url": "core" - }, - { - "name": "SDK", - "icon": "puzzle-piece", - "url": "extensions" - }, - { - "name": "CLI", - "icon": "terminal", - "url": "cli" - }, - { - "name": "Interface", - "icon": "palette", - "url": "react" - } - ], - "navigation": [ - { - "group": "Getting Started", - "icon": "book-open", - "pages": [ - "overview/introduction", - "overview/whitepaper", - "overview/philosophy", - "overview/history" - ] - }, - { - "group": "User Guides", - "icon": "compass", - "pages": [ - "overview/get-started", - "overview/backup-photos-ios", - "overview/manage-libraries", - "overview/add-index-locations" - ] - }, - { - "group": "Architecture", - "icon": "cube", - "pages": [ - "core/architecture", - "core/library", - "core/data-model", - "core/addressing", - "core/jobs", - "core/ops", - "core/api", - "core/events" - ] - }, - { - "group": "File Management", - "icon": "folder", - "pages": [ - "core/indexing", - "core/locations", - "core/devices", - "core/volumes", - "core/file-copy-operations", - "core/tagging", - "core/virtual-sidecars" - ] - }, - { - "group": "Sync & Network", - "icon": "network-wired", - "pages": [ - "core/networking", - "core/pairing", - "core/library-sync", - "core/file-sync", - "core/cloud-integration" - ] - }, - { - "group": "Development", - "icon": "flask", - "pages": ["core/database", "core/testing", "core/cli"] - }, - { - "group": "Extension SDK", - "icon": "code", - "pages": [ - "extensions/introduction", - "extensions/getting-started", - "extensions/core-concepts", - "extensions/data-storage", - "extensions/security-and-sync", - "extensions/ui-integration", - "extensions/examples" - ] - }, - { - "group": "CLI Reference", - "icon": "terminal", - "pages": [ - "cli/overview", - "cli/linux-deployment", - "cli/library-sync-setup", - "cli/multi-instance", - "cli/index-verify" - ] - }, - { - "group": "React UI", - "icon": "palette", - "pages": [ - "react/ui/colors", - "react/ui/primitives", - "react/ui/hooks", - "react/ui/normalized-cache", - "react/ui/platform" - ] - } - ] + "$schema": "https://mintlify.com/schema.json", + "name": "Spacedrive", + "logo": { + "light": "/logo/spacedrive-logo.png", + "dark": "/logo/spacedrive-logo.png" + }, + "favicon": "/public/favicon.png", + "colors": { + "primary": "#36A3FF", + "light": "#36A3FF", + "dark": "#36A3FF" + }, + "styles": { + "css": ["/custom.css"] + }, + "anchors": [ + { + "name": "Overview", + "icon": "book-open", + "url": "overview" + }, + { + "name": "Developer", + "icon": "code", + "url": "core" + }, + { + "name": "SDK", + "icon": "puzzle-piece", + "url": "extensions" + }, + { + "name": "CLI", + "icon": "terminal", + "url": "cli" + }, + { + "name": "Interface", + "icon": "palette", + "url": "react" + } + ], + "navigation": [ + { + "group": "Getting Started", + "icon": "book-open", + "pages": [ + "overview/introduction", + "overview/whitepaper", + "overview/philosophy", + "overview/history" + ] + }, + { + "group": "User Guides", + "icon": "compass", + "pages": [ + "overview/get-started", + "overview/backup-photos-ios", + "overview/manage-libraries", + "overview/add-index-locations" + ] + }, + { + "group": "Architecture", + "icon": "cube", + "pages": [ + "core/architecture", + "core/library", + "core/data-model", + "core/addressing", + "core/jobs", + "core/ops", + "core/api", + "core/events", + "core/memory" + ] + }, + { + "group": "File Management", + "icon": "folder", + "pages": [ + "core/indexing", + "core/locations", + "core/devices", + "core/volumes", + "core/file-copy-operations", + "core/tagging", + "core/virtual-sidecars" + ] + }, + { + "group": "Sync & Network", + "icon": "network-wired", + "pages": [ + "core/networking", + "core/pairing", + "core/library-sync", + "core/file-sync", + "core/cloud-integration" + ] + }, + { + "group": "Development", + "icon": "flask", + "pages": ["core/database", "core/testing", "core/cli"] + }, + { + "group": "Extension SDK", + "icon": "code", + "pages": [ + "extensions/introduction", + "extensions/getting-started", + "extensions/core-concepts", + "extensions/data-storage", + "extensions/security-and-sync", + "extensions/ui-integration", + "extensions/examples" + ] + }, + { + "group": "CLI Reference", + "icon": "terminal", + "pages": [ + "cli/overview", + "cli/linux-deployment", + "cli/library-sync-setup", + "cli/multi-instance", + "cli/index-verify" + ] + }, + { + "group": "React UI", + "icon": "palette", + "pages": [ + "react/ui/colors", + "react/ui/primitives", + "react/ui/hooks", + "react/ui/normalized-cache", + "react/ui/platform" + ] + } + ] } diff --git a/packages/interface/src/components/Explorer/components/AddStorageModal.tsx b/packages/interface/src/components/Explorer/components/AddStorageModal.tsx index 02e1a5d48..0e888cbfd 100644 --- a/packages/interface/src/components/Explorer/components/AddStorageModal.tsx +++ b/packages/interface/src/components/Explorer/components/AddStorageModal.tsx @@ -521,18 +521,58 @@ function AddStorageDialog(props: { throw new Error("Unsupported cloud provider"); } - const input: VolumeAddCloudInput = { + const volumeInput: VolumeAddCloudInput = { service: provider.cloudServiceType, display_name: data.display_name, config, }; try { - const result = await addCloudVolume.mutateAsync(input); + // Step 1: Add the cloud volume and get fingerprint + const volumeResult = await addCloudVolume.mutateAsync(volumeInput); + + // Determine the cloud identifier based on provider type + let cloudIdentifier: string; + if ( + provider.cloudServiceType === "s3" || + provider.cloudServiceType === "b2" || + provider.cloudServiceType === "wasabi" || + provider.cloudServiceType === "spaces" + ) { + cloudIdentifier = data.bucket!; + } else if (provider.cloudServiceType === "azblob") { + cloudIdentifier = data.container!; + } else if (provider.cloudServiceType === "gcs") { + cloudIdentifier = data.bucket!; + } else if ( + provider.cloudServiceType === "gdrive" || + provider.cloudServiceType === "dropbox" || + provider.cloudServiceType === "onedrive" + ) { + cloudIdentifier = data.root || "root"; + } else { + cloudIdentifier = "root"; + } + + // Step 2: Create a location for the cloud volume so it gets indexed + const locationInput: LocationAddInput = { + path: { + Cloud: { + service: provider.cloudServiceType, + identifier: cloudIdentifier, + path: "", + }, + }, + name: data.display_name, + mode: "Deep", + job_policies: {}, + }; + + const locationResult = await addLocation.mutateAsync(locationInput); dialog.state.open = false; - if (result?.fingerprint && props.onStorageAdded) { - props.onStorageAdded(result.fingerprint); + if (locationResult?.id && props.onStorageAdded) { + props.onStorageAdded(locationResult.id); } } catch (error) { console.error("Failed to add cloud storage:", error); diff --git a/packages/interface/src/platform.tsx b/packages/interface/src/platform.tsx index 33d49b961..484e3f660 100644 --- a/packages/interface/src/platform.tsx +++ b/packages/interface/src/platform.tsx @@ -84,6 +84,23 @@ export type Platform = { /** Listen for selected file changes across all windows (Tauri only) */ onSelectedFilesChanged?(callback: (fileIds: string[]) => void): Promise<() => void>; + + /** Get daemon status (Tauri only) */ + getDaemonStatus?(): Promise<{ + is_running: boolean; + socket_path: string; + server_url: string | null; + started_by_us: boolean; + }>; + + /** Start daemon process (Tauri only) */ + startDaemonProcess?(): Promise; + + /** Stop daemon process (Tauri only) */ + stopDaemonProcess?(): Promise; + + /** Open macOS system settings (Tauri/macOS only) */ + openMacOSSettings?(): Promise; }; /** Menu item state for native menus */ diff --git a/packages/interface/src/routes/DaemonManager.tsx b/packages/interface/src/routes/DaemonManager.tsx index 3d95cc707..26a9a17ec 100644 --- a/packages/interface/src/routes/DaemonManager.tsx +++ b/packages/interface/src/routes/DaemonManager.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Power, Check, Warning, CircleNotch } from '@phosphor-icons/react'; -import { invoke } from '@tauri-apps/api/core'; +import { usePlatform } from '../platform'; interface DaemonStatus { is_running: boolean; @@ -10,6 +10,7 @@ interface DaemonStatus { } export function DaemonManager() { + const platform = usePlatform(); const [status, setStatus] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -23,10 +24,12 @@ export function DaemonManager() { }, []); async function checkDaemonStatus() { + if (!platform.getDaemonStatus) return; + setIsLoading(true); setError(null); try { - const daemonStatus = await invoke('get_daemon_status'); + const daemonStatus = await platform.getDaemonStatus(); setStatus(daemonStatus); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -37,10 +40,12 @@ export function DaemonManager() { } async function handleStartDaemon() { + if (!platform.startDaemonProcess) return; + setIsStarting(true); setError(null); try { - await invoke('start_daemon_process'); + await platform.startDaemonProcess(); await checkDaemonStatus(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -50,10 +55,12 @@ export function DaemonManager() { } async function handleStopDaemon() { + if (!platform.stopDaemonProcess) return; + setIsStopping(true); setError(null); try { - await invoke('stop_daemon_process'); + await platform.stopDaemonProcess(); await checkDaemonStatus(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -63,8 +70,10 @@ export function DaemonManager() { } async function handleOpenSettings() { + if (!platform.openMacOSSettings) return; + try { - await invoke('open_macos_settings'); + await platform.openMacOSSettings(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); }