diff --git a/.github/assets/screenshot1.png b/.github/assets/screenshot1.png new file mode 100644 index 0000000..185c7db Binary files /dev/null and b/.github/assets/screenshot1.png differ diff --git a/.github/assets/screenshot2.png b/.github/assets/screenshot2.png new file mode 100644 index 0000000..787e912 Binary files /dev/null and b/.github/assets/screenshot2.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7f0b7c..79dbf5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,103 +11,92 @@ env: jobs: test: - name: Test + name: Test — ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-22.04, macos-latest, windows-latest] - rust: [stable] steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - - name: Install system dependencies (Linux) + - name: Install Linux system dependencies if: matrix.os == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - - - name: Install Rust - uses: actions-rs/toolchain@v1 + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.0-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + pkg-config + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable with: - profile: minimal - toolchain: ${{ matrix.rust }} - override: true components: rustfmt, clippy - - name: Cache cargo registry - uses: actions/cache@v3 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v3 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v3 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Run cargo check - run: cargo check --all --verbose + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 - - name: Run cargo test - run: cargo test --all --verbose + # Run tests on all workspace crates except the Tauri desktop app + # (it requires a display server / windowing environment to build) + - name: Run tests + run: cargo test --workspace --exclude openpaste-desktop - - name: Run cargo clippy - run: cargo clippy --all -- -D warnings + - name: Run clippy + run: cargo clippy --workspace --exclude openpaste-desktop -- -D warnings - - name: Run cargo fmt + - name: Check formatting run: cargo fmt --all -- --check - build: - name: Build + build-desktop: + name: Build desktop — ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-22.04, macos-latest, windows-latest] - rust: [stable] steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v4 - - name: Install system dependencies (Linux) + - name: Install Linux system dependencies if: matrix.os == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf - - - name: Install Rust - uses: actions-rs/toolchain@v1 + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.0-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + pkg-config + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: - profile: minimal - toolchain: ${{ matrix.rust }} - override: true - - - name: Cache cargo registry - uses: actions/cache@v3 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo index - uses: actions/cache@v3 - with: - path: ~/.cargo/git - key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v3 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - name: Run cargo build - run: cargo build --all --release --verbose + node-version: '20' + cache: 'npm' + cache-dependency-path: apps/desktop/package-lock.json + + - name: Install frontend dependencies + working-directory: apps/desktop + run: npm ci + + # cargo check is enough for CI — tauri-action handles the full build in release.yml + - name: Check Tauri app + working-directory: apps/desktop/src-tauri + run: cargo check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..803be8f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,117 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + # ── Build + release matrix ───────────────────────────────────────────────── + build: + name: Build — ${{ matrix.label }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # macOS Apple Silicon + - os: macos-latest + label: macOS-arm64 + target: aarch64-apple-darwin + tauri_args: --target aarch64-apple-darwin + + # macOS Intel + - os: macos-13 + label: macOS-x64 + target: x86_64-apple-darwin + tauri_args: --target x86_64-apple-darwin + + # Windows x64 + - os: windows-latest + label: Windows-x64 + target: x86_64-pc-windows-msvc + tauri_args: '' + + # Linux x64 (AppImage + deb) + - os: ubuntu-22.04 + label: Linux-x64 + target: x86_64-unknown-linux-gnu + tauri_args: '' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # ── System dependencies ───────────────────────────────────────────── + - name: Install Linux system dependencies + if: matrix.os == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.0-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + pkg-config + + # ── Rust ──────────────────────────────────────────────────────────── + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + # ── Node ──────────────────────────────────────────────────────────── + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: apps/desktop/package-lock.json + + - name: Install frontend dependencies + working-directory: apps/desktop + run: npm ci + + # ── Build daemon (must exist before Tauri build so the app can spawn it) + - name: Build daemon + run: cargo build --bin openpaste-daemon --release --target ${{ matrix.target }} + + # ── Tauri build ───────────────────────────────────────────────────── + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Code-signing (optional — set these secrets to sign your builds) + # macOS + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Windows + TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + with: + projectPath: apps/desktop + tagName: ${{ github.ref_name }} + releaseName: 'OpenPaste ${{ github.ref_name }}' + releaseBody: | + See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details. + releaseDraft: true + prerelease: false + args: ${{ matrix.tauri_args }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e99adf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to OpenPaste are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +OpenPaste uses [Semantic Versioning](https://semver.org/). + +--- + +## [Unreleased] + +## [0.1.0] — 2026-07-10 + +### Added +- Clipboard history capture with 500 ms polling (text + images) +- SQLite storage with FTS5 full-text search +- AES-256-GCM encryption with Argon2id key derivation +- Auto-lock vault after configurable inactivity timeout +- Pin and favorite items +- Color-coded tags with sidebar filter +- WASM plugin system (wasmtime sandbox) — auto-loads `.wasm` files from the plugins directory +- Cross-device sync via HTTP push/pull relay server (`clipboard-api`) +- Global shortcuts: `⌘⇧V` show/hide, `⌘⇧C` quick-paste +- System tray with Show, Quick Paste, and Quit menu items +- Hide-to-tray on window close +- Launch at login (macOS LaunchAgent) +- `openpaste` CLI: list, search, copy, get, pin, favorite, delete, status +- Daemon auto-spawned by the desktop app; probe-before-spawn avoids double-start +- Settings panel: max items, retention days, refresh interval, notifications, encryption toggle +- Sync configuration UI (server URL, API token, enable toggle, Sync Now) +- Plugin Manager UI (load from file picker, list loaded plugins, unload) +- 26 automated tests (clipboard-db + clipboard-encryption) +- GitHub Actions CI (test + build-desktop, all platforms) +- GitHub Actions release workflow (macOS arm64/x64, Windows x64, Linux x64) diff --git a/Cargo.lock b/Cargo.lock index b7fa0ee..a3d6787 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,6 +207,126 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -390,6 +510,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "7.0.0" @@ -470,7 +612,7 @@ dependencies = [ "cairo-sys-rs", "glib", "libc", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -559,7 +701,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -621,7 +763,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -632,15 +774,20 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", + "chrono", "clipboard-core", "clipboard-db", "clipboard-search", + "dirs", + "md5", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tower-http", "tracing", + "tracing-subscriber", ] [[package]] @@ -652,7 +799,7 @@ dependencies = [ "clipboard-core", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -662,11 +809,12 @@ name = "clipboard-core" version = "0.1.0" dependencies = [ "anyhow", + "arboard", "chrono", "md5", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", @@ -677,20 +825,26 @@ name = "clipboard-daemon" version = "0.1.0" dependencies = [ "anyhow", + "chrono", + "clipboard-api", "clipboard-core", "clipboard-db", "clipboard-encryption", "clipboard-events", "clipboard-ipc", "clipboard-platform", + "clipboard-plugin", "clipboard-storage", + "clipboard-sync", "dirs", + "notify-rust", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -703,7 +857,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", @@ -719,7 +873,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -732,7 +886,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", @@ -745,7 +899,7 @@ dependencies = [ "anyhow", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -760,10 +914,11 @@ dependencies = [ "clipboard-core", "clipboard-win 4.5.0", "cocoa 0.25.0", + "image 0.25.10", "objc", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "wl-clipboard-rs", @@ -778,7 +933,7 @@ dependencies = [ "clipboard-core", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "wasmtime", @@ -793,7 +948,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -807,7 +962,7 @@ dependencies = [ "clipboard-core", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", @@ -819,11 +974,16 @@ name = "clipboard-sync" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", + "chrono", + "clipboard-db", + "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", + "urlencoding", ] [[package]] @@ -904,6 +1064,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -914,6 +1084,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1280,6 +1459,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + [[package]] name = "debugid" version = "0.8.0" @@ -1501,6 +1691,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1550,6 +1767,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1756,6 +1994,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1943,7 +2194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2026,7 +2277,7 @@ dependencies = [ "glib", "libc", "once_cell", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2059,7 +2310,7 @@ dependencies = [ "libc", "once_cell", "smallvec", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2070,7 +2321,7 @@ checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a" dependencies = [ "anyhow", "heck 0.4.1", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro-error", "proc-macro2", "quote", @@ -2165,7 +2416,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d" dependencies = [ "anyhow", - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro-error", "proc-macro2", "quote", @@ -2275,6 +2526,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2479,7 +2736,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2794,7 +3051,7 @@ dependencies = [ "combine", "jni-sys 0.3.1", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", ] @@ -2856,7 +3113,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2904,12 +3161,55 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libappindicator" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2d3cb96d092b4824cb306c9e544c856a4cb6210c1081945187f7f1924b47e8" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b3b6681973cea8cc3bce7391e6d7d5502720b80a581c9a95c9cbaf592826aa" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libm" version = "0.2.16" @@ -2993,6 +3293,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "mach" version = "0.3.2" @@ -3174,7 +3488,7 @@ dependencies = [ "jni-sys 0.3.1", "ndk-sys", "num_enum", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3247,6 +3561,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "dbus", + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3322,7 +3651,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", @@ -3338,6 +3667,17 @@ dependencies = [ "objc_exception", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3396,6 +3736,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", + "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3483,18 +3825,26 @@ name = "openpaste-cli" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", + "chrono", "clap", + "clipboard-ipc", + "colored", + "dirs", + "tokio", ] [[package]] name = "openpaste-desktop" version = "0.1.0" dependencies = [ + "arboard", "base64 0.22.1", "chrono", "clipboard-core", "clipboard-ipc", "dirs", + "notify-rust", "serde", "serde_json", "tauri", @@ -3550,6 +3900,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3585,6 +3945,12 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3605,7 +3971,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3797,6 +4163,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -3869,6 +4246,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3921,6 +4312,15 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -4145,7 +4545,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -4250,6 +4650,30 @@ dependencies = [ "winreg 0.50.0", ] +[[package]] +name = "rfd" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea" +dependencies = [ + "block", + "dispatch", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "lazy_static", + "log", + "objc", + "objc-foundation", + "objc_id", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.37.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -4799,7 +5223,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 2.5.3", "futures-channel", "futures-core", "futures-intrusive", @@ -4818,7 +5242,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tracing", @@ -4902,7 +5326,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -4941,7 +5365,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -5144,6 +5568,7 @@ dependencies = [ "core-foundation 0.9.4", "core-graphics 0.22.3", "crossbeam-channel", + "dirs-next", "dispatch", "gdk", "gdk-pixbuf", @@ -5158,6 +5583,7 @@ dependencies = [ "instant", "jni", "lazy_static", + "libappindicator", "libc", "log", "ndk", @@ -5236,6 +5662,7 @@ dependencies = [ "rand 0.8.6", "raw-window-handle", "regex", + "rfd", "semver", "serde", "serde_json", @@ -5248,7 +5675,7 @@ dependencies = [ "tauri-runtime-wry", "tauri-utils", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "url", "uuid", @@ -5296,7 +5723,7 @@ dependencies = [ "serde_json", "sha2", "tauri-utils", - "thiserror", + "thiserror 1.0.69", "time", "uuid", "walkdir", @@ -5330,7 +5757,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror", + "thiserror 1.0.69", "url", "uuid", "webview2-com", @@ -5381,7 +5808,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror", + "thiserror 1.0.69", "url", "walkdir", "windows-version", @@ -5397,6 +5824,17 @@ dependencies = [ "toml 0.7.8", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5433,7 +5871,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -5447,6 +5894,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -5604,7 +6062,7 @@ checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" dependencies = [ "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_edit 0.19.15", ] @@ -5616,7 +6074,7 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_edit 0.22.27", ] @@ -5629,6 +6087,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -5638,7 +6105,7 @@ dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -5651,11 +6118,32 @@ dependencies = [ "indexmap 2.14.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + [[package]] name = "toml_write" version = "0.1.2" @@ -5792,6 +6280,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -6198,7 +6697,7 @@ dependencies = [ "log", "object 0.32.2", "target-lexicon", - "thiserror", + "thiserror 1.0.69", "wasmparser 0.116.1", "wasmtime-cranelift-shared", "wasmtime-environ", @@ -6236,7 +6735,7 @@ dependencies = [ "serde", "serde_derive", "target-lexicon", - "thiserror", + "thiserror 1.0.69", "wasmparser 0.116.1", "wasmtime-types", ] @@ -6344,7 +6843,7 @@ dependencies = [ "cranelift-entity", "serde", "serde_derive", - "thiserror", + "thiserror 1.0.69", "wasmparser 0.116.1", ] @@ -6547,7 +7046,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "windows 0.39.0", "windows-bindgen", "windows-metadata", @@ -6609,6 +7108,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647" +dependencies = [ + "windows_aarch64_msvc 0.37.0", + "windows_i686_gnu 0.37.0", + "windows_i686_msvc 0.37.0", + "windows_x86_64_gnu 0.37.0", + "windows_x86_64_msvc 0.37.0", +] + [[package]] name = "windows" version = "0.39.0" @@ -6632,6 +7144,19 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + [[package]] name = "windows-bindgen" version = "0.39.0" @@ -6642,6 +7167,28 @@ dependencies = [ "windows-tokens", ] +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6650,9 +7197,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -6687,6 +7245,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -6699,13 +7263,41 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -6714,7 +7306,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6765,7 +7357,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6799,6 +7391,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-tokens" version = "0.39.0" @@ -6811,7 +7412,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -6832,6 +7433,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" + [[package]] name = "windows_aarch64_msvc" version = "0.39.0" @@ -6856,6 +7463,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" + [[package]] name = "windows_i686_gnu" version = "0.39.0" @@ -6886,6 +7499,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" + [[package]] name = "windows_i686_msvc" version = "0.39.0" @@ -6910,6 +7529,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" + [[package]] name = "windows_x86_64_gnu" version = "0.39.0" @@ -6952,6 +7577,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" + [[package]] name = "windows_x86_64_msvc" version = "0.39.0" @@ -6994,6 +7625,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -7049,7 +7689,7 @@ dependencies = [ "nix 0.24.3", "os_pipe", "tempfile", - "thiserror", + "thiserror 1.0.69", "tree_magic_mini", "wayland-client", "wayland-protocols", @@ -7090,7 +7730,7 @@ dependencies = [ "sha2", "soup2", "tao", - "thiserror", + "thiserror 1.0.69", "url", "webkit2gtk", "webkit2gtk-sys", @@ -7207,6 +7847,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.53" @@ -7354,3 +8055,43 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/README.md b/README.md index 7613b0f..2236ea7 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,210 @@ # OpenPaste -**OpenPaste** is a modern, open-source, cross-platform clipboard manager built for developers, power users, and professionals who rely on their clipboard every day. Designed with performance, privacy, and productivity at its core, OpenPaste transforms the traditional clipboard into a searchable, organized, and secure workspace. - -## Current Status - -**Version:** 0.1.0 (Development) - -**Status:** Active Development - -## Features Implemented - -### Phase 1: Foundation ✅ -- Project architecture with Rust workspace -- Tauri + React desktop application -- Core clipboard capture (macOS) -- SQLite database with migrations -- IPC communication (Unix domain sockets) -- Basic search functionality -- Metallic UI design - -### Phase 2: Core Features (In Progress) -- Clipboard history management -- Pin and favorite items -- Delete items -- Copy to clipboard -- Auto-refresh (2-second polling) -- Deduplication (hash-based) -- Custom title bar with window controls -- FTS5 full-text search with triggers -- Zstd compression for storage -- AES-256-GCM encryption with proper nonce handling -- Argon2id key derivation with secure parameters - -## Technology Stack - -**Backend:** -- Rust -- SQLite (with sqlx) -- Tokio async runtime -- Unix domain sockets (IPC) - -**Frontend:** -- React + TypeScript -- Tauri -- Tailwind CSS -- Lucide React icons - -## Running the Application +OpenPaste is a local-first, open-source clipboard manager for developers and power users. It captures everything you copy, keeps it searchable, encrypted, and organized — and stays entirely on your machine. + +Built with **Tauri + React + Rust + SQLite**. + +OpenPaste clipboard history + +OpenPaste settings and encryption + +--- + +## Key Features + +- **Unlimited clipboard history** — every copy is captured and stored locally with full-text search (FTS5) +- **AES-256-GCM encryption** — optional master password locks your vault; Argon2id key derivation +- **Auto-lock** — vault locks automatically after a configurable inactivity timeout +- **Tags** — organize items with color-coded tags; filter by tag from the sidebar +- **Pin & favorite** — surface important items instantly +- **WASM plugins** — extend clipboard processing with sandboxed WebAssembly modules +- **Cross-device sync** — optional HTTP sync to a self-hosted relay server +- **Global shortcuts** — `⌘⇧V` to show/hide, `⌘⇧C` to quick-paste the most recent item +- **System tray** — runs silently in the background +- **CLI** — full `openpaste` command-line interface for scripting and automation + +--- + +## Installation + +Download the correct package for your system from the latest [GitHub Releases](https://github.com/YOUR_USERNAME/openpaste/releases). + +### 🖥️ macOS + +1. **Download**: + - **Apple Silicon (M1/M2/M3/M4):** `OpenPaste_x.x.x_aarch64.dmg` + - **Intel Mac:** `OpenPaste_x.x.x_x64.dmg` + +2. **Install:** Double-click the `.dmg` and drag **OpenPaste** to your Applications folder. + +3. **First launch — bypass Gatekeeper** (unsigned build): + - Right-click `OpenPaste.app` → **Open** → **Open** in the warning dialog. + - Or run in Terminal: + ```bash + xattr -cr /Applications/OpenPaste.app + ``` + +### 🪟 Windows + +1. **Download:** `OpenPaste_x.x.x_x64-setup.exe` (installer) or `OpenPaste_x.x.x_x64.msi` +2. **Install:** Run the installer and follow the setup wizard. +3. **SmartScreen:** Click **More info → Run anyway** if Windows flags the unsigned build. + +### 🐧 Linux + +1. **Download:** `OpenPaste_x.x.x_amd64.deb` (Debian/Ubuntu) or `OpenPaste_x.x.x_amd64.AppImage` (universal) + +2. **Install `.deb`:** + ```bash + sudo dpkg -i OpenPaste_*.deb + ``` + +3. **Run `.AppImage`:** + ```bash + chmod +x OpenPaste_*.AppImage + ./OpenPaste_*.AppImage + ``` + +--- + +## CLI + +The `openpaste` CLI talks to the running daemon over a Unix socket: + +```bash +openpaste list # Show clipboard history +openpaste search "query" # Full-text search +openpaste copy # Copy an item back to the clipboard +openpaste get # Print item content to stdout +openpaste pin # Toggle pin +openpaste favorite # Toggle favorite +openpaste delete # Delete an item +openpaste status # Check daemon connection +``` + +--- + +## Sync + +OpenPaste supports optional push/pull sync via any server running the OpenPaste HTTP API. Open the Sync panel (wifi icon in the toolbar) and enter your server URL and optional bearer token. + +To run your own relay server: + +```bash +# Listens on 127.0.0.1:8080 by default +cargo run --bin clipboard-api + +# Custom address +cargo run --bin clipboard-api -- --addr 0.0.0.0:8080 + +# Or via environment variable +OPENPASTE_API_ADDR=0.0.0.0:8080 cargo run --bin clipboard-api +``` + +The relay server reads from and writes to the same SQLite database as the daemon, so you can run both on the same machine or deploy the API server separately with its own database. + +--- + +## Plugins + +Plugins are WebAssembly modules that transform clipboard text on capture. Drop a `.wasm` file into: + +- **macOS/Linux:** `~/.local/share/openpaste/plugins/` (or `~/Library/Application Support/openpaste/plugins/` on macOS) +- **Windows:** `%APPDATA%\openpaste\plugins\` + +Or load one at runtime from the Plugins panel (plug icon in the toolbar). + +**Plugin contract:** + +```rust +// Export this function from your WASM module. +// Receives the clipboard text at memory[ptr..ptr+len]. +// Write the transformed result back at ptr and return the new byte length. +// Return 0 to leave the content unchanged. +#[no_mangle] +pub extern "C" fn process_item(ptr: i32, len: i32) -> i32 { ... } + +// Optional: the host provides this import for logging. +extern "C" { fn log(ptr: i32, len: i32); } +``` + +--- + +## Developer Guide ### Prerequisites -- Rust (latest stable) -- Node.js (18+) -- pnpm + +- Rust (stable, via [rustup](https://rustup.rs)) +- Node.js 18+ and npm +- On Linux: `libgtk-3-dev`, `libwebkit2gtk-4.0-dev`, `libappindicator3-dev`, `librsvg2-dev` ### Development -1. Start the clipboard daemon: +Start the daemon and the desktop app in two terminals: + ```bash +# Terminal 1 — daemon cargo run --bin openpaste-daemon + +# Terminal 2 — desktop (Tauri dev server with hot reload) +cd apps/desktop +npm install +npm run tauri:dev ``` -2. Start the desktop app: +The Tauri app will auto-detect a running daemon and skip spawning its own. + +### Run tests + +```bash +cargo test --workspace --exclude openpaste-desktop +``` + +### Build for release + ```bash cd apps/desktop -pnpm install -pnpm tauri dev +npm run tauri:build ``` -## Project Structure +Installers are placed in `apps/desktop/src-tauri/target/release/bundle/`. + +### Project structure ``` openpaste/ ├── apps/ -│ └── desktop/ # Tauri desktop application -├── crates/ -│ ├── clipboard-core/ # Core clipboard types -│ ├── clipboard-db/ # Database operations -│ ├── clipboard-ipc/ # IPC communication -│ └── clipboard-daemon/ # Background daemon -└── docs/ # Documentation +│ ├── cli/ # openpaste CLI binary +│ └── desktop/ # Tauri desktop app (React + Rust) +│ └── src-tauri/ # Tauri backend +└── crates/ + ├── clipboard-api/ # Axum HTTP sync server + ├── clipboard-core/ # Core types and ClipboardManager + ├── clipboard-daemon/ # Background daemon (IPC + clipboard polling) + ├── clipboard-db/ # SQLite layer (sqlx) + ├── clipboard-encryption/ # AES-256-GCM + Argon2id + ├── clipboard-events/ # Tokio broadcast event bus + ├── clipboard-ipc/ # Unix socket IPC (client + server) + ├── clipboard-platform/ # Platform clipboard access (arboard) + ├── clipboard-plugin/ # WASM plugin runner (wasmtime) + └── clipboard-sync/ # HTTP sync client ``` -## Roadmap +--- + +## Releasing -See [ROADMAP.md](docs/ROADMAP.md) for detailed planning. +Push a version tag to trigger the release workflow: + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` -## Contributing +GitHub Actions builds `.dmg` (arm64 + x64), `.exe`/`.msi`, `.deb`, and `.AppImage`, then creates a draft release. Review and publish from the GitHub Releases page. -See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for contribution guidelines. +--- ## License -TBD +MIT diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index e381f69..46c8455 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -14,3 +14,9 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } anyhow = { workspace = true } +tokio = { workspace = true } +dirs = "5.0" +clipboard-ipc = { path = "../../crates/clipboard-ipc" } +colored = "2.1" +chrono = { workspace = true } +base64 = "0.22" diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 9d56d5e..57bac33 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1,11 +1,16 @@ //! OpenPaste CLI binary -use anyhow::Result; +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; use clap::{Parser, Subcommand}; +use clipboard_ipc::{IpcClient, IpcMessage}; +use colored::Colorize; +use std::path::PathBuf; #[derive(Parser)] #[command(name = "openpaste")] #[command(about = "OpenPaste clipboard manager CLI", long_about = None)] +#[command(version)] struct Cli { #[command(subcommand)] command: Commands, @@ -13,74 +18,258 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Search clipboard history - Search { query: String }, - /// Get clipboard item by ID - Get { id: String }, - /// Copy item to clipboard - Copy { id: String }, - /// List clipboard items + /// List clipboard history List { #[arg(short, long, default_value_t = 20)] limit: usize, }, - /// Pin an item - Pin { id: String }, - /// Favorite an item - Favorite { id: String }, + /// Search clipboard history + Search { + /// Search query + query: String, + }, + /// Copy a clipboard item back to the clipboard by ID + Copy { + /// Item ID + id: String, + }, + /// Print item content to stdout by ID + Get { + /// Item ID + id: String, + }, + /// Pin or unpin an item + Pin { + /// Item ID + id: String, + }, + /// Favorite or unfavorite an item + Favorite { + /// Item ID + id: String, + }, /// Delete an item - Delete { id: String }, - /// Show daemon status + Delete { + /// Item ID + id: String, + }, + /// Show daemon connection status Status, - /// Start daemon - DaemonStart, - /// Stop daemon - DaemonStop, } -fn main() -> Result<()> { +fn get_socket_path() -> String { + let data_dir = dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("openpaste"); + + data_dir + .join("openpaste.sock") + .to_string_lossy() + .to_string() +} + +async fn client() -> IpcClient { + IpcClient::new(get_socket_path()) +} + +fn truncate(s: &str, max: usize) -> String { + let s = s.trim(); + if s.chars().count() > max { + format!("{}…", s.chars().take(max).collect::()) + } else { + s.to_string() + } +} + +fn type_badge(content_type: &str) -> colored::ColoredString { + match content_type { + "image" => "IMAGE".magenta(), + "code" => "CODE".yellow(), + "html" => "HTML".cyan(), + _ => { + // check URL heuristic from content_type string alone + "TEXT".blue() + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { + Commands::Status => { + let c = client().await; + match c.send(IpcMessage::GetHistory).await { + Ok(IpcMessage::ClipboardHistory { items }) => { + println!("{} daemon is running — {} items in history", + "✓".green().bold(), + items.len().to_string().bold() + ); + } + Err(e) => { + eprintln!("{} daemon is not reachable: {}", "✗".red().bold(), e); + std::process::exit(1); + } + _ => { + eprintln!("{} unexpected response from daemon", "✗".red().bold()); + std::process::exit(1); + } + } + } + + Commands::List { limit } => { + let c = client().await; + match c.send(IpcMessage::GetHistory).await { + Ok(IpcMessage::ClipboardHistory { items }) => { + if items.is_empty() { + println!("{}", "No clipboard history.".dimmed()); + return Ok(()); + } + println!("{}", format!("{:<6} {:<8} {:<12} {}", "ID", "TYPE", "WHEN", "CONTENT").dimmed()); + println!("{}", "─".repeat(72).dimmed()); + for item in items.iter().take(limit) { + let content_str = if item.content_type == "image" { + "(image)".to_string() + } else { + truncate(&String::from_utf8_lossy(&item.content), 50) + }; + let when = item.created_at.get(..16).unwrap_or(&item.created_at); + let pin = if item.pinned { "📌 " } else { "" }; + let fav = if item.favorite { "⭐ " } else { "" }; + println!( + "{:<6} {:<8} {:<12} {}{}{}", + item.id.bold(), + type_badge(&item.content_type), + when.dimmed(), + pin, fav, + content_str + ); + } + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + } + } + Commands::Search { query } => { - println!("Searching for: {}", query); - // TODO: Implement search + let c = client().await; + match c.send(IpcMessage::SearchItems { query: query.clone() }).await { + Ok(IpcMessage::ClipboardHistory { items }) => { + if items.is_empty() { + println!("{}", format!("No results for '{}'.", query).dimmed()); + return Ok(()); + } + println!("{}", format!("{:<6} {:<8} {:<12} {}", "ID", "TYPE", "WHEN", "CONTENT").dimmed()); + println!("{}", "─".repeat(72).dimmed()); + for item in &items { + let content_str = if item.content_type == "image" { + "(image)".to_string() + } else { + truncate(&String::from_utf8_lossy(&item.content), 50) + }; + let when = item.created_at.get(..16).unwrap_or(&item.created_at); + println!( + "{:<6} {:<8} {:<12} {}", + item.id.bold(), + type_badge(&item.content_type), + when.dimmed(), + content_str + ); + } + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + } } + Commands::Get { id } => { - println!("Getting item: {}", id); - // TODO: Implement get + // Fetch full list and find by ID (no single-item IPC yet) + let c = client().await; + match c.send(IpcMessage::GetHistory).await { + Ok(IpcMessage::ClipboardHistory { items }) => { + let item = items.iter().find(|i| i.id == id) + .ok_or_else(|| anyhow!("Item {} not found", id))?; + if item.content_type == "image" { + // Write PNG to stdout (pipe to file if needed) + let bytes = STANDARD.decode(&item.content) + .unwrap_or_else(|_| item.content.clone()); + use std::io::Write; + std::io::stdout().write_all(&bytes)?; + } else { + print!("{}", String::from_utf8_lossy(&item.content)); + } + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + } } + Commands::Copy { id } => { - println!("Copying item: {}", id); - // TODO: Implement copy - } - Commands::List { limit } => { - println!("Listing {} items", limit); - // TODO: Implement list + // Fetch item content then push it back to clipboard via SetClipboard + let c = client().await; + let content = match c.send(IpcMessage::GetHistory).await { + Ok(IpcMessage::ClipboardHistory { items }) => { + items.into_iter().find(|i| i.id == id) + .map(|i| i.content) + .ok_or_else(|| anyhow!("Item {} not found", id))? + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + }; + + let c2 = client().await; + match c2.send(IpcMessage::SetClipboard { content }).await { + Ok(IpcMessage::ClipboardContent { .. }) => { + println!("{} Copied item {} to clipboard", "✓".green().bold(), id.bold()); + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + } } + Commands::Pin { id } => { - println!("Pinning item: {}", id); - // TODO: Implement pin + let id_i64: i64 = id.parse().map_err(|_| anyhow!("Invalid ID: {}", id))?; + let c = client().await; + match c.send(IpcMessage::TogglePin { id: id_i64 }).await { + Ok(IpcMessage::Success) => { + println!("{} Toggled pin on item {}", "✓".green().bold(), id.bold()); + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + } } + Commands::Favorite { id } => { - println!("Favoriting item: {}", id); - // TODO: Implement favorite + let id_i64: i64 = id.parse().map_err(|_| anyhow!("Invalid ID: {}", id))?; + let c = client().await; + match c.send(IpcMessage::ToggleFavorite { id: id_i64 }).await { + Ok(IpcMessage::Success) => { + println!("{} Toggled favorite on item {}", "✓".green().bold(), id.bold()); + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + } } + Commands::Delete { id } => { - println!("Deleting item: {}", id); - // TODO: Implement delete - } - Commands::Status => { - println!("Daemon status"); - // TODO: Implement status - } - Commands::DaemonStart => { - println!("Starting daemon"); - // TODO: Implement daemon start - } - Commands::DaemonStop => { - println!("Stopping daemon"); - // TODO: Implement daemon stop + let id_i64: i64 = id.parse().map_err(|_| anyhow!("Invalid ID: {}", id))?; + let c = client().await; + match c.send(IpcMessage::DeleteItem { id: id_i64 }).await { + Ok(IpcMessage::Success) => { + println!("{} Deleted item {}", "✓".green().bold(), id.bold()); + } + Ok(IpcMessage::Error { message }) => return Err(anyhow!("Daemon error: {}", message)), + Err(e) => return Err(anyhow!("IPC error: {}", e)), + _ => return Err(anyhow!("Unexpected response")), + } } } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index b354e6d..82f54a3 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ repository.workspace = true tauri-build = { version = "1.5", features = [] } [dependencies] -tauri = { version = "1.5", features = ["shell-open"] } +tauri = { version = "1.5", features = [ "window-hide", "global-shortcut-all", "window-show", "window-center", "window-set-focus", "shell-open", "system-tray", "global-shortcut", "dialog-open"] } serde = { workspace = true } serde_json = { workspace = true } clipboard-core = { path = "../../../crates/clipboard-core" } @@ -19,6 +19,8 @@ clipboard-ipc = { path = "../../../crates/clipboard-ipc" } chrono = { workspace = true } dirs = "5.0" base64 = "0.22" +arboard = "3.3" +notify-rust = { version = "4.11", features = ["d"] } [features] default = ["custom-protocol"] diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 60ae1d9..5006b73 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,8 +1,15 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use clipboard_ipc::{IpcClient, IpcMessage}; +use arboard; +use clipboard_ipc::{AppSettings, IpcClient, IpcMessage, PluginInfoItem, TagItem}; +use notify_rust; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tauri::{ + CustomMenuItem, GlobalShortcutManager, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, + SystemTrayMenuItem, +}; #[derive(Serialize, Deserialize)] struct ClipboardItem { @@ -19,16 +26,25 @@ struct ClipboardItem { /// Convert bytes to string, using base64 for binary content fn bytes_to_string(content_type: &str, bytes: &[u8]) -> String { let type_lower = content_type.to_lowercase(); - if type_lower.contains("text") || type_lower.contains("plain") || type_lower.contains("url") { + if type_lower.contains("text") + || type_lower.contains("plain") + || type_lower.contains("url") + || type_lower.contains("code") + || type_lower.contains("html") + || type_lower.contains("json") + { String::from_utf8_lossy(bytes).to_string() } else { - use base64::{Engine as _, engine::general_purpose::STANDARD}; + use base64::{engine::general_purpose::STANDARD, Engine as _}; STANDARD.encode(bytes) } } fn get_ipc_socket_path() -> PathBuf { + // On macOS, dirs::data_local_dir() returns ~/Library/Application Support + // which matches what the daemon uses. Fall back to ~/.local/share on Linux. let data_dir = dirs::data_local_dir() + .or_else(|| dirs::home_dir().map(|h| h.join(".local").join("share"))) .unwrap_or_else(|| PathBuf::from(".")) .join("openpaste"); @@ -43,13 +59,12 @@ fn get_ipc_socket_path() -> PathBuf { #[tauri::command] async fn get_clipboard_history() -> Result, String> { - // Connect to daemon via IPC let socket_path = get_ipc_socket_path(); let client = IpcClient::new(socket_path.to_string_lossy().to_string()); match client.send(IpcMessage::GetHistory).await { Ok(IpcMessage::ClipboardHistory { items }) => { - let clipboard_items: Vec = items + let clipboard_items = items .into_iter() .map(|item| ClipboardItem { id: item.id, @@ -77,7 +92,7 @@ async fn search_clipboard_items(query: String) -> Result, Str match client.send(IpcMessage::SearchItems { query }).await { Ok(IpcMessage::ClipboardHistory { items }) => { - let clipboard_items: Vec = items + let clipboard_items = items .into_iter() .map(|item| ClipboardItem { id: item.id, @@ -122,9 +137,7 @@ async fn get_current_clipboard() -> Result { let client = IpcClient::new(socket_path.to_string_lossy().to_string()); match client.send(IpcMessage::GetClipboard).await { - Ok(IpcMessage::ClipboardContent { content }) => { - Ok(bytes_to_string("text/plain", &content)) - } + Ok(IpcMessage::ClipboardContent { content }) => Ok(bytes_to_string("text/plain", &content)), Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), Err(e) => Err(format!("IPC error: {}", e)), _ => Err("Unexpected response from daemon".to_string()), @@ -173,8 +186,584 @@ async fn toggle_favorite_item(id: String) -> Result<(), String> { } } +#[tauri::command] +async fn get_settings() -> Result { + let socket_path = get_ipc_socket_path(); + let client = IpcClient::new(socket_path.to_string_lossy().to_string()); + + match client.send(IpcMessage::GetSettings).await { + Ok(IpcMessage::Settings { settings }) => Ok(settings), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response from daemon".to_string()), + } +} + +#[tauri::command] +async fn save_settings(settings: AppSettings) -> Result<(), String> { + let socket_path = get_ipc_socket_path(); + let client = IpcClient::new(socket_path.to_string_lossy().to_string()); + + match client.send(IpcMessage::SaveSettings { settings }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response from daemon".to_string()), + } +} + +#[tauri::command] +async fn set_master_password(password: String) -> Result<(), String> { + let socket_path = get_ipc_socket_path(); + let client = IpcClient::new(socket_path.to_string_lossy().to_string()); + + match client.send(IpcMessage::SetMasterPassword { password }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response from daemon".to_string()), + } +} + +#[tauri::command] +async fn unlock_vault(password: String) -> Result<(), String> { + let socket_path = get_ipc_socket_path(); + let client = IpcClient::new(socket_path.to_string_lossy().to_string()); + + match client.send(IpcMessage::UnlockVault { password }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response from daemon".to_string()), + } +} + +#[tauri::command] +async fn check_vault_status() -> Result { + let socket_path = get_ipc_socket_path(); + let client = IpcClient::new(socket_path.to_string_lossy().to_string()); + + match client.send(IpcMessage::CheckVaultStatus).await { + Ok(IpcMessage::VaultStatus { is_locked }) => Ok(is_locked), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response from daemon".to_string()), + } +} + +#[tauri::command] +async fn has_master_password() -> Result { + let socket_path = get_ipc_socket_path(); + let client = IpcClient::new(socket_path.to_string_lossy().to_string()); + + match client.send(IpcMessage::HasMasterPassword).await { + Ok(IpcMessage::PasswordConfigured { has_password }) => Ok(has_password), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response from daemon".to_string()), + } +} + +#[tauri::command] +async fn lock_vault() -> Result<(), String> { + let socket_path = get_ipc_socket_path(); + let client = IpcClient::new(socket_path.to_string_lossy().to_string()); + + match client.send(IpcMessage::LockVault).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response from daemon".to_string()), + } +} + +/// Returns the path to the macOS LaunchAgent plist for the Tauri app. +#[cfg(target_os = "macos")] +fn launch_agent_plist_path() -> Option { + let home = dirs::home_dir()?; + Some(home.join("Library/LaunchAgents/com.openpaste.desktop.plist")) +} + +#[tauri::command] +async fn get_launch_at_login() -> Result { + #[cfg(target_os = "macos")] + { + if let Some(plist) = launch_agent_plist_path() { + return Ok(plist.exists()); + } + return Ok(false); + } + #[cfg(not(target_os = "macos"))] + Ok(false) +} + +#[tauri::command] +async fn set_launch_at_login(enabled: bool) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let plist_path = launch_agent_plist_path() + .ok_or("Could not determine home directory".to_string())?; + + if !enabled { + if plist_path.exists() { + std::fs::remove_file(&plist_path) + .map_err(|e| format!("Failed to remove launch agent: {}", e))?; + } + return Ok(()); + } + + // Get path to the current executable + let exe = std::env::current_exe() + .map_err(|e| format!("Could not find executable path: {}", e))?; + + let plist_content = format!( + r#" + + + + Label + com.openpaste.desktop + ProgramArguments + + {} + + RunAtLoad + + KeepAlive + + StandardErrorPath + /tmp/com.openpaste.desktop.err + StandardOutPath + /tmp/com.openpaste.desktop.out + +"#, + exe.to_string_lossy() + ); + + // Ensure LaunchAgents directory exists + if let Some(parent) = plist_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create LaunchAgents dir: {}", e))?; + } + + std::fs::write(&plist_path, plist_content) + .map_err(|e| format!("Failed to write launch agent plist: {}", e))?; + + return Ok(()); + } + #[cfg(not(target_os = "macos"))] + { + let _ = enabled; + Err("Launch at login is only supported on macOS currently".to_string()) + } +} + +#[tauri::command] +async fn list_tags() -> Result, String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::ListTags).await { + Ok(IpcMessage::TagsList { tags }) => Ok(tags), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn create_tag(name: String, color: Option) -> Result<(), String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::CreateTag { name, color }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn delete_tag(id: i64) -> Result<(), String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::DeleteTag { id }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn get_item_tags(item_id: i64) -> Result, String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::GetItemTags { item_id }).await { + Ok(IpcMessage::ItemTags { tags }) => Ok(tags), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn add_tag_to_item(item_id: i64, tag_name: String, color: Option) -> Result<(), String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::AddTagToItem { item_id, tag_name, color }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn remove_tag_from_item(item_id: i64, tag_id: i64) -> Result<(), String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::RemoveTagFromItem { item_id, tag_id }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn list_items_by_tag(tag_id: i64) -> Result, String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::ListItemsByTag { tag_id }).await { + Ok(IpcMessage::ClipboardHistory { items }) => { + Ok(items.into_iter().map(|item| ClipboardItem { + id: item.id, + content_type: item.content_type.clone(), + content: bytes_to_string(&item.content_type, &item.content), + hash: item.hash, + created_at: item.created_at, + accessed_at: item.accessed_at, + pinned: item.pinned, + favorite: item.favorite, + }).collect()) + } + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +// ── Plugin commands ─────────────────────────────────────────────────────────── + +#[tauri::command] +async fn list_plugins() -> Result, String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::ListPlugins).await { + Ok(IpcMessage::PluginList { plugins }) => Ok(plugins), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn load_plugin(path: String) -> Result { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::LoadPlugin { path }).await { + // Daemon returns ClipboardContent { content: name.into_bytes() } on success + Ok(IpcMessage::ClipboardContent { content }) => { + String::from_utf8(content).map_err(|e| format!("UTF-8 error: {}", e)) + } + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn unload_plugin(name: String) -> Result<(), String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::UnloadPlugin { name }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +// ── Sync commands ───────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize)] +struct SyncConfig { + server_url: String, + api_token: Option, + enabled: bool, + last_sync_at: Option, +} + +#[tauri::command] +async fn get_sync_config() -> Result { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::GetSyncConfig).await { + Ok(IpcMessage::SyncConfig { server_url, api_token, enabled, last_sync_at }) => { + Ok(SyncConfig { server_url, api_token, enabled, last_sync_at }) + } + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn set_sync_config(server_url: String, api_token: Option, enabled: bool) -> Result<(), String> { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::SetSyncConfig { server_url, api_token, enabled }).await { + Ok(IpcMessage::Success) => Ok(()), + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +#[tauri::command] +async fn sync_now() -> Result { + let client = IpcClient::new(get_ipc_socket_path().to_string_lossy().to_string()); + match client.send(IpcMessage::SyncNow).await { + // Daemon returns ClipboardContent with "pushed=N, pulled=M" on success + Ok(IpcMessage::ClipboardContent { content }) => { + String::from_utf8(content).map_err(|e| format!("UTF-8 error: {}", e)) + } + Ok(IpcMessage::Error { message }) => Err(format!("Daemon error: {}", message)), + Err(e) => Err(format!("IPC error: {}", e)), + _ => Err("Unexpected response".to_string()), + } +} + +fn build_tray() -> SystemTray { + let show = CustomMenuItem::new("show".to_string(), "Show OpenPaste ⌘⇧V"); + let quick_paste = CustomMenuItem::new("quick_paste".to_string(), "Quick Paste ⌘⇧C"); + let quit = CustomMenuItem::new("quit".to_string(), "Quit"); + + let menu = SystemTrayMenu::new() + .add_item(show) + .add_item(quick_paste) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(quit); + + SystemTray::new().with_menu(menu) +} + +fn show_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + let _ = window.center(); + let _ = window.unminimize(); + } +} + fn main() { + let tray = build_tray(); + tauri::Builder::default() + .system_tray(tray) + .on_system_tray_event(|app, event| match event { + // Left-click on tray icon — toggle window + SystemTrayEvent::LeftClick { .. } => { + if let Some(window) = app.get_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + show_window(app); + } + } + } + // Tray menu items + SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() { + "show" => show_window(app), + "quick_paste" => { + let socket_path = get_ipc_socket_path().to_string_lossy().to_string(); + tauri::async_runtime::spawn(async move { + let client = IpcClient::new(socket_path); + if let Ok(IpcMessage::ClipboardHistory { items }) = + client.send(IpcMessage::GetHistory).await + { + if let Some(item) = items.into_iter().next() { + if item.content_type != "encrypted" { + let text = String::from_utf8_lossy(&item.content).to_string(); + if let Ok(mut ctx) = arboard::Clipboard::new() { + let _ = ctx.set_text(&text); + let preview: String = text.chars().take(40).collect(); + let preview = if text.len() > 40 { format!("{}…", preview) } else { preview }; + let _ = notify_rust::Notification::new() + .summary("OpenPaste — Quick Paste") + .body(&preview) + .timeout(notify_rust::Timeout::Milliseconds(1800)) + .show(); + } + } + } + } + }); + } + "quit" => { + let _ = app.global_shortcut_manager().unregister_all(); + // Kill the sidecar daemon if we spawned it + if let Some(state) = app.try_state::>>>() { + if let Ok(mut guard) = state.lock() { + if let Some(child) = guard.as_mut() { + let _ = child.kill(); + } + } + } + std::process::exit(0); + } + _ => {} + }, + _ => {} + }) + // Intercept close — hide to tray instead of quitting + .on_window_event(|event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event.event() { + api.prevent_close(); + let _ = event.window().hide(); + } + }) + .setup(|app| { + let app_handle = app.handle(); + + // ── Daemon sidecar ─────────────────────────────────────────────── + // Probe the IPC socket. If the daemon is already running (e.g. the + // developer started it manually) we skip spawning. Otherwise we + // locate and launch the daemon binary. + let daemon_child: Arc>> = + Arc::new(Mutex::new(None)); + + let socket_path = get_ipc_socket_path(); + let daemon_already_running = { + #[cfg(unix)] + { + use std::os::unix::net::UnixStream; + UnixStream::connect(&socket_path).is_ok() + } + #[cfg(not(unix))] + { false } + }; + + if !daemon_already_running { + // Find the daemon binary next to the current exe, or fall back + // to the workspace target directory for dev builds. + let daemon_path = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("openpaste-daemon"))) + .filter(|p| p.exists()) + .or_else(|| { + // dev: workspace root / target / debug / openpaste-daemon + std::env::current_exe().ok().and_then(|p| { + // walk up to find a directory containing "target" + let mut dir = p.parent()?.to_path_buf(); + for _ in 0..8 { + let candidate = dir.join("target").join("debug").join("openpaste-daemon"); + if candidate.exists() { + return Some(candidate); + } + dir = dir.parent()?.to_path_buf(); + } + None + }) + }); + + if let Some(bin) = daemon_path { + eprintln!("[openpaste] spawning daemon: {:?}", bin); + match std::process::Command::new(&bin).spawn() { + Ok(child) => { + *daemon_child.lock().unwrap() = Some(child); + // Give daemon time to bind the socket + std::thread::sleep(std::time::Duration::from_millis(400)); + } + Err(e) => eprintln!("[openpaste] failed to spawn daemon: {}", e), + } + } else { + eprintln!("[openpaste] daemon binary not found — please start it manually"); + } + } else { + eprintln!("[openpaste] daemon already running, skipping spawn"); + } + + // Store child handle so the quit handler can kill it + app_handle.manage(daemon_child.clone()); + + // ── Shortcut 1: Show/hide window (Cmd+Shift+V) ────────────────── + #[cfg(target_os = "macos")] + let show_shortcut = "Super+Shift+V"; + #[cfg(not(target_os = "macos"))] + let show_shortcut = "Ctrl+Shift+V"; + + let app_handle_show = app_handle.clone(); + app_handle + .global_shortcut_manager() + .register(show_shortcut, move || { + show_window(&app_handle_show); + }) + .unwrap_or_else(|e| eprintln!("Failed to register show shortcut: {}", e)); + + // ── Shortcut 2: Quick-paste most recent item (Cmd+Shift+C) ────── + // Fetches the top history item from the daemon and writes it + // directly to the system clipboard — no window shown. + #[cfg(target_os = "macos")] + let paste_shortcut = "Super+Shift+C"; + #[cfg(not(target_os = "macos"))] + let paste_shortcut = "Ctrl+Shift+C"; + + app_handle + .global_shortcut_manager() + .register(paste_shortcut, move || { + let socket_path = get_ipc_socket_path() + .to_string_lossy() + .to_string(); + + // Spawn async work on Tauri's runtime + tauri::async_runtime::spawn(async move { + let client = IpcClient::new(socket_path); + match client.send(IpcMessage::GetHistory).await { + Ok(IpcMessage::ClipboardHistory { items }) => { + if let Some(item) = items.into_iter().next() { + // Skip encrypted items — can't paste ciphertext + if item.content_type == "encrypted" { + let _ = notify_rust::Notification::new() + .summary("OpenPaste") + .body("Most recent item is encrypted. Unlock the vault first.") + .timeout(notify_rust::Timeout::Milliseconds(2500)) + .show(); + return; + } + + let text = String::from_utf8_lossy(&item.content).to_string(); + + // Write to system clipboard via arboard + match arboard::Clipboard::new() { + Ok(mut ctx) => { + let preview: String = text.chars().take(40).collect(); + let preview = if text.len() > 40 { + format!("{}…", preview) + } else { + preview + }; + + if ctx.set_text(&text).is_ok() { + let _ = notify_rust::Notification::new() + .summary("OpenPaste — Quick Paste") + .body(&preview) + .timeout(notify_rust::Timeout::Milliseconds(1800)) + .show(); + } + } + Err(e) => eprintln!("Quick-paste clipboard error: {}", e), + } + } + } + Err(e) => eprintln!("Quick-paste IPC error: {}", e), + _ => {} + } + }); + }) + .unwrap_or_else(|e| eprintln!("Failed to register quick-paste shortcut: {}", e)); + + Ok(()) + }) .invoke_handler(tauri::generate_handler![ get_clipboard_history, search_clipboard_items, @@ -182,7 +771,29 @@ fn main() { get_current_clipboard, delete_clipboard_item, toggle_pin_item, - toggle_favorite_item + toggle_favorite_item, + get_settings, + save_settings, + set_master_password, + unlock_vault, + check_vault_status, + lock_vault, + has_master_password, + get_launch_at_login, + set_launch_at_login, + list_tags, + create_tag, + delete_tag, + get_item_tags, + add_tag_to_item, + remove_tag_from_item, + list_items_by_tag, + list_plugins, + load_plugin, + unload_plugin, + get_sync_config, + set_sync_config, + sync_now ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 835bb1c..776a4c7 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -16,10 +16,24 @@ "shell": { "all": false, "open": true + }, + "globalShortcut": { + "all": true + }, + "window": { + "all": false, + "show": true, + "hide": true, + "setFocus": true, + "center": true + }, + "dialog": { + "all": false, + "open": true } }, "bundle": { - "active": false, + "active": true, "targets": "all", "identifier": "com.openpaste.desktop", "icon": [ @@ -33,6 +47,11 @@ "security": { "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;" }, + "systemTray": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true, + "menuOnLeftClick": false + }, "updater": { "active": false }, @@ -45,7 +64,8 @@ "width": 1000, "hiddenTitle": false, "titleBarStyle": "Visible", - "decorations": false + "decorations": false, + "skipTaskbar": true } ] } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 42aba36..c06fe18 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { invoke } from '@tauri-apps/api' +import { open as openDialog } from '@tauri-apps/api/dialog' import { appWindow } from '@tauri-apps/api/window' import { Clipboard, @@ -7,7 +8,6 @@ import { Pin, Settings, Menu, - Link, FileText, Image, Terminal, @@ -17,7 +17,14 @@ import { Check, Minimize, Maximize2, - X + X, + Plug, + RefreshCw, + Wifi, + WifiOff, + Upload, + Download, + Package } from 'lucide-react' import './App.css' @@ -32,28 +39,510 @@ interface ClipboardItem { favorite: boolean } +interface Tag { + id: number + name: string + color: string | null +} + +interface PluginInfo { + name: string + path: string + enabled: boolean +} + +interface SyncConfig { + server_url: string + api_token: string | null + enabled: boolean + last_sync_at: string | null +} + function App() { const [items, setItems] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [selectedItem, setSelectedItem] = useState(null) + const [selectedIndex, setSelectedIndex] = useState(-1) + const [showSettings, setShowSettings] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const itemsRef = useRef(items) + const searchTimeoutRef = useRef(null) + const searchInputRef = useRef(null) + + // Settings state + const [settings, setSettings] = useState({ + encryptionEnabled: false, + autoLockMinutes: 5, + maxItems: 10000, + retentionDays: 90, + refreshInterval: 2, + showNotifications: true + }) + const [launchAtLogin, setLaunchAtLogin] = useState(false) + + // Password management state + const [vaultLocked, setVaultLocked] = useState(false) + const [masterPassword, setMasterPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [unlockPassword, setUnlockPassword] = useState('') + const [hasPassword, setHasPassword] = useState(false) + const [unlockError, setUnlockError] = useState(null) + const [passwordError, setPasswordError] = useState(null) + const lastActivityRef = useRef(Date.now()) + const vaultLockedRef = useRef(false) + + // Tag state + const [allTags, setAllTags] = useState([]) + const [itemTags, setItemTags] = useState>({}) + const [activeTagFilter, setActiveTagFilter] = useState(null) + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: ClipboardItem } | null>(null) + const [tagInput, setTagInput] = useState('') + const [showTagInput, setShowTagInput] = useState(false) + + // Plugin state + const [showPlugins, setShowPlugins] = useState(false) + const [plugins, setPlugins] = useState([]) + const [pluginLoading, setPluginLoading] = useState(false) + const [pluginError, setPluginError] = useState(null) + + // Sync state + const [showSync, setShowSync] = useState(false) + const [syncConfig, setSyncConfig] = useState({ + server_url: '', + api_token: null, + enabled: false, + last_sync_at: null, + }) + const [syncLoading, setSyncLoading] = useState(false) + const [syncStatus, setSyncStatus] = useState(null) + const [syncError, setSyncError] = useState(null) + + const handleSettingsChange = (key: string, value: any) => { + setSettings(prev => ({ ...prev, [key]: value })) + lastActivityRef.current = Date.now() + } + + const loadSettings = async () => { + try { + const loadedSettings = await invoke('get_settings') + setSettings({ + encryptionEnabled: loadedSettings.encryption_enabled, + autoLockMinutes: loadedSettings.auto_lock_minutes, + maxItems: loadedSettings.max_items, + retentionDays: loadedSettings.retention_days, + refreshInterval: loadedSettings.refresh_interval, + showNotifications: loadedSettings.show_notifications + }) + + // Check both whether a password exists and whether vault is currently locked + const [pwExists, isLocked, loginEnabled] = await Promise.all([ + invoke('has_master_password'), + invoke('check_vault_status'), + invoke('get_launch_at_login'), + ]) + setHasPassword(pwExists) + setVaultLocked(isLocked) + vaultLockedRef.current = isLocked + setLaunchAtLogin(loginEnabled) + } catch (e) { + console.error('Failed to load settings:', e) + } + } + + const saveSettings = async () => { + try { + console.log('Saving settings:', settings) + const result = await invoke('save_settings', { + settings: { + encryption_enabled: settings.encryptionEnabled, + auto_lock_minutes: settings.autoLockMinutes, + max_items: settings.maxItems, + retention_days: settings.retentionDays, + refresh_interval: settings.refreshInterval, + show_notifications: settings.showNotifications + } + }) + console.log('Save result:', result) + setShowSettings(false) + } catch (e) { + console.error('Failed to save settings:', e) + alert('Failed to save settings: ' + e) + } + } + + const handleSetMasterPassword = async () => { + if (masterPassword !== confirmPassword) { + setPasswordError('Passwords do not match') + return + } + if (masterPassword.length < 8) { + setPasswordError('Password must be at least 8 characters') + return + } + setPasswordError(null) + + try { + await invoke('set_master_password', { password: masterPassword }) + setMasterPassword('') + setConfirmPassword('') + setHasPassword(true) + setVaultLocked(true) + vaultLockedRef.current = true + } catch (e) { + setPasswordError(String(e).replace('Daemon error: ', '')) + } + } + + const handleUnlockVault = async () => { + setUnlockError(null) + try { + await invoke('unlock_vault', { password: unlockPassword }) + setUnlockPassword('') + setVaultLocked(false) + vaultLockedRef.current = false + lastActivityRef.current = Date.now() + loadClipboardHistory(false) + } catch (e) { + setUnlockError(String(e).replace('Daemon error: ', '')) + } + } + + const handleLockVault = async () => { + try { + await invoke('lock_vault') + setVaultLocked(true) + vaultLockedRef.current = true + // Reload to show encrypted placeholders + loadClipboardHistory(false) + } catch (e) { + console.error('Failed to lock vault:', e) + } + } + + const updateActivity = () => { + lastActivityRef.current = Date.now() + } + + // ── Tag helpers ────────────────────────────────────────────────────────── + const loadTags = async () => { + try { + const tags = await invoke('list_tags') + setAllTags(tags) + } catch (e) { + console.error('Failed to load tags:', e) + } + } + + const loadItemTags = async (itemId: number) => { + try { + const tags = await invoke('get_item_tags', { itemId }) + setItemTags(prev => ({ ...prev, [itemId]: tags })) + } catch (e) { + console.error('Failed to load item tags:', e) + } + } + + const loadAllItemTags = async (itemList: ClipboardItem[]) => { + // Batch load tags for visible items + const results = await Promise.allSettled( + itemList.map(item => + invoke('get_item_tags', { itemId: item.id }) + .then(tags => ({ id: item.id, tags })) + ) + ) + const map: Record = {} + results.forEach(r => { + if (r.status === 'fulfilled') map[r.value.id] = r.value.tags + }) + setItemTags(map) + } + + const handleAddTag = async (item: ClipboardItem, tagName: string) => { + const name = tagName.trim() + if (!name) return + // Pick a color based on name hash + const colors = ['#6D7FFF', '#47C267', '#E5C76B', '#FF7A72', '#C47AFF', '#5BBFFF', '#FF9F5A'] + const color = colors[name.charCodeAt(0) % colors.length] + try { + await invoke('add_tag_to_item', { itemId: item.id, tagName: name, color }) + await Promise.all([loadItemTags(item.id), loadTags()]) + } catch (e) { + console.error('Failed to add tag:', e) + } + } + + const handleRemoveTag = async (itemId: number, tagId: number) => { + try { + await invoke('remove_tag_from_item', { itemId, tagId }) + await loadItemTags(itemId) + } catch (e) { + console.error('Failed to remove tag:', e) + } + } + + const handleDeleteTag = async (tagId: number) => { + try { + await invoke('delete_tag', { id: tagId }) + if (activeTagFilter === tagId) setActiveTagFilter(null) + await loadTags() + // Refresh item tags since some may have been removed + setItemTags({}) + } catch (e) { + console.error('Failed to delete tag:', e) + } + } + + // ── Plugin helpers ─────────────────────────────────────────────────────── + const loadPlugins = async () => { + setPluginLoading(true) + setPluginError(null) + try { + const list = await invoke('list_plugins') + setPlugins(list) + } catch (e) { + setPluginError(String(e)) + } finally { + setPluginLoading(false) + } + } + + const handleLoadPlugin = async () => { + setPluginError(null) + try { + const selected = await openDialog({ + title: 'Select a WASM plugin', + filters: [{ name: 'WASM Plugin', extensions: ['wasm'] }], + multiple: false, + directory: false, + }) + if (!selected || Array.isArray(selected)) return + const name = await invoke('load_plugin', { path: selected }) + setSyncStatus(null) + setPluginError(null) + await loadPlugins() + setPluginError(null) + console.log('Loaded plugin:', name) + } catch (e) { + setPluginError(String(e)) + } + } + + const handleUnloadPlugin = async (name: string) => { + setPluginError(null) + try { + await invoke('unload_plugin', { name }) + await loadPlugins() + } catch (e) { + setPluginError(String(e)) + } + } + + // ── Sync helpers ───────────────────────────────────────────────────────── + const loadSyncConfig = async () => { + setSyncLoading(true) + setSyncError(null) + try { + const config = await invoke('get_sync_config') + setSyncConfig(config) + } catch (e) { + setSyncError(String(e)) + } finally { + setSyncLoading(false) + } + } + + const handleSaveSyncConfig = async () => { + setSyncLoading(true) + setSyncError(null) + setSyncStatus(null) + try { + await invoke('set_sync_config', { + serverUrl: syncConfig.server_url, + apiToken: syncConfig.api_token || null, + enabled: syncConfig.enabled, + }) + setSyncStatus('Sync settings saved.') + } catch (e) { + setSyncError(String(e)) + } finally { + setSyncLoading(false) + } + } + + const handleSyncNow = async () => { + setSyncLoading(true) + setSyncError(null) + setSyncStatus(null) + try { + const result = await invoke('sync_now') + setSyncStatus(`Sync complete — ${result}`) + await loadSyncConfig() // refresh last_sync_at + } catch (e) { + setSyncError(String(e)) + } finally { + setSyncLoading(false) + } + } // Update ref whenever items changes useEffect(() => { itemsRef.current = items + if (items.length > 0) { + loadAllItemTags(items) + } }, [items]) + // Run once on mount useEffect(() => { loadClipboardHistory(true) + loadSettings() + loadTags() + }, []) - // Auto-refresh every 2 seconds + // Auto-refresh interval — recreates only when interval setting changes + useEffect(() => { const interval = setInterval(() => { + if (!searchQuery && activeTagFilter === null) { + loadClipboardHistory(false) + } + }, settings.refreshInterval * 1000) + return () => clearInterval(interval) + }, [searchQuery, settings.refreshInterval, activeTagFilter]) + + // When tag filter changes, reload items + useEffect(() => { + if (activeTagFilter !== null) { + invoke('list_items_by_tag', { tagId: activeTagFilter }) + .then(setItems) + .catch(console.error) + } else if (!searchQuery.trim()) { loadClipboardHistory(false) - }, 2000) + } + }, [activeTagFilter]) - return () => clearInterval(interval) + // Auto-lock timer — recreates when encryption/lock settings change + useEffect(() => { + const autoLockInterval = setInterval(() => { + if (settings.encryptionEnabled && !vaultLockedRef.current && settings.autoLockMinutes > 0) { + const inactiveTime = Date.now() - lastActivityRef.current + const lockThreshold = settings.autoLockMinutes * 60 * 1000 + if (inactiveTime > lockThreshold) { + handleLockVault() + } + } + }, 10_000) + return () => clearInterval(autoLockInterval) + }, [settings.encryptionEnabled, settings.autoLockMinutes]) + + // Debounced search + useEffect(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + + if (searchQuery.trim()) { + searchTimeoutRef.current = setTimeout(() => { + performSearch(searchQuery) + }, 300) + } else { + // Load full history when search is empty + loadClipboardHistory(false) + } + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + } + }, [searchQuery]) + + // Reset selected index when items change + useEffect(() => { + setSelectedIndex(-1) + }, [items]) + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't handle if typing in search input + if (document.activeElement === searchInputRef.current) { + if (e.key === 'Escape') { + searchInputRef.current?.blur() + setSearchQuery('') + } + return + } + + // Cmd/Ctrl+K to focus search + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + searchInputRef.current?.focus() + return + } + + // Escape to close modal or clear selection + if (e.key === 'Escape') { + if (selectedItem) { + setSelectedItem(null) + } else { + setSelectedIndex(-1) + } + return + } + + // Navigation + if (e.key === 'ArrowDown' || e.key === 'j') { + e.preventDefault() + setSelectedIndex(prev => { + const maxIndex = items.length - 1 + return prev < maxIndex ? prev + 1 : prev + }) + } else if (e.key === 'ArrowUp' || e.key === 'k') { + e.preventDefault() + setSelectedIndex(prev => prev > 0 ? prev - 1 : -1) + } else if (e.key === 'Enter' && selectedIndex >= 0) { + e.preventDefault() + const item = items[selectedIndex] + if (item) { + copyToClipboard(item.content) + } + } else if ((e.metaKey || e.ctrlKey) && e.key === 'c' && selectedIndex >= 0) { + e.preventDefault() + const item = items[selectedIndex] + if (item) { + copyToClipboard(item.content) + } + } else if (e.key === 'p' && selectedIndex >= 0) { + e.preventDefault() + const item = items[selectedIndex] + if (item) { + togglePin(item) + } + } else if (e.key === 'f' && selectedIndex >= 0) { + e.preventDefault() + const item = items[selectedIndex] + if (item) { + toggleFavorite(item) + } + } else if (e.key === 'd' && selectedIndex >= 0) { + e.preventDefault() + const item = items[selectedIndex] + if (item) { + deleteItem(item.id) + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [items, selectedIndex, selectedItem]) + + // Close context menu on click outside + useEffect(() => { + const close = () => setContextMenu(null) + window.addEventListener('click', close) + return () => window.removeEventListener('click', close) }, []) const loadClipboardHistory = async (isInitial: boolean = false) => { @@ -82,6 +571,21 @@ function App() { } } + const performSearch = async (query: string) => { + try { + setLoading(true) + setError(null) + + const results = await invoke('search_clipboard_items', { query }) + setItems(results) + } catch (e) { + setError(e as string) + console.error('Failed to search clipboard items:', e) + } finally { + setLoading(false) + } + } + const copyToClipboard = async (content: string) => { try { await invoke('set_clipboard_content', { content }) @@ -119,17 +623,19 @@ function App() { const getIconForType = (contentType: string) => { const type = contentType.toLowerCase() - if (type.includes('url') || type.includes('http')) return Link + if (type === 'encrypted') return Pin // lock icon substitute if (type.includes('image')) return Image - if (type.includes('code') || type.includes('json')) return Terminal + if (type.includes('code') || type.includes('json') || type.includes('html')) return Terminal return FileText } - const getTypeBadge = (contentType: string) => { + const getTypeBadge = (contentType: string, content: string) => { const type = contentType.toLowerCase() - if (type.includes('url') || type.includes('http')) return 'URL' + if (type === 'encrypted') return 'ENCRYPTED' if (type.includes('image')) return 'IMAGE' - if (type.includes('code') || type.includes('json')) return 'CODE' + if (type.includes('code') || type.includes('json') || type.includes('html')) return 'CODE' + const trimmed = content.trim() + if (trimmed.startsWith('http://') || trimmed.startsWith('https://') || trimmed.startsWith('ftp://')) return 'URL' return 'TEXT' } @@ -148,9 +654,7 @@ function App() { return `${diffDays} day${diffDays > 1 ? 's' : ''} ago` } - const filteredItems = items.filter(item => - item.content.toLowerCase().includes(searchQuery.toLowerCase()) - ) + const filteredItems = items return (
- - - - + { + searchInputRef.current?.focus() + updateActivity() + }} /> + updateActivity()} /> + { + setShowPlugins(true) + loadPlugins() + updateActivity() + }}> + + + { + setShowSync(true) + loadSyncConfig() + updateActivity() + }}> + + + { + setShowSettings(true) + updateActivity() + }} /> + updateActivity()} />
- {/* Search Section */} -
-
- - setSearchQuery(e.target.value)} - style={{ - flex: 1, - marginLeft: '12px', - fontSize: '14px', - color: '#F3F5F8', - background: 'transparent', - border: 'none', - outline: 'none', - fontFamily: 'Inter, sans-serif' - }} - /> + {/* Search + List + Tag Sidebar */} +
+ + {/* Tag Sidebar */} + {allTags.length > 0 && (
- ⌘K +
+ Tags +
+
setActiveTagFilter(null)} + style={{ + padding: '6px 10px', + borderRadius: '6px', + fontSize: '13px', + fontWeight: '500', + cursor: 'pointer', + backgroundColor: activeTagFilter === null ? '#262B31' : 'transparent', + color: activeTagFilter === null ? '#F3F5F8' : '#A7B0BC', + }} + > + All items +
+ {allTags.map(tag => ( +
+
setActiveTagFilter(activeTagFilter === tag.id ? null : tag.id)} + style={{ + flex: 1, + padding: '6px 10px', + borderRadius: '6px', + fontSize: '13px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '6px', + color: activeTagFilter === tag.id ? '#F3F5F8' : '#A7B0BC', + }} + > + + + {tag.name} + +
+ +
+ ))}
-
-
+ )} - {/* Clipboard List */} -
+ {/* Right pane: Search + List */} +
+ + {/* Search Section */} +
+
+ + { + setSearchQuery(e.target.value) + updateActivity() + }} + style={{ + flex: 1, + marginLeft: '12px', + fontSize: '14px', + color: '#F3F5F8', + background: 'transparent', + border: 'none', + outline: 'none', + fontFamily: 'Inter, sans-serif' + }} + /> +
+ ⌘K +
+
+
+ + {/* Clipboard List */} +
{loading && (
Loading clipboard history... @@ -358,49 +964,69 @@ function App() { {!loading && !error && (
- {filteredItems.map((item) => { + {filteredItems.map((item, index) => { const IconComponent = getIconForType(item.content_type) + const isSelected = index === selectedIndex return (
{ + setSelectedIndex(index) e.currentTarget.style.transform = 'translateY(-1px)' e.currentTarget.style.borderColor = '#5A6471' e.currentTarget.style.boxShadow = '0 6px 12px rgba(0,0,0,0.28)' }} onMouseLeave={(e) => { + setSelectedIndex(-1) e.currentTarget.style.transform = 'translateY(0)' e.currentTarget.style.borderColor = '#343A44' e.currentTarget.style.boxShadow = '0 4px 8px rgba(0,0,0,0.22)' }} - onClick={() => setSelectedItem(item)} + onClick={() => { + if (item.content_type !== 'encrypted') { + setSelectedItem(item) + } + updateActivity() + }} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + setContextMenu({ x: e.clientX, y: e.clientY, item }) + }} > {/* Left Icon Area */}
- + {item.content_type === 'encrypted' + ? 🔒 + : + }
{/* Content Area */} @@ -413,32 +1039,83 @@ function App() { }}> {new Date(item.created_at).toLocaleString()}
-
- {item.content} -
+ {item.content_type === 'encrypted' ? ( +
+ 🔒 Encrypted — unlock vault to view +
+ ) : item.content_type === 'image' ? ( + Clipboard image + ) : ( +
+ {item.content} +
+ )}
- {getTypeBadge(item.content_type)} + {getTypeBadge(item.content_type, item.content)}
+ {/* Tag pills */} + {(itemTags[item.id] ?? []).length > 0 && ( +
+ {(itemTags[item.id] ?? []).map(tag => ( + { e.stopPropagation(); setActiveTagFilter(tag.id) }} + > + {tag.name} + + ))} +
+ )}
{/* Vertical Separator */} @@ -468,11 +1145,14 @@ function App() {
{ e.stopPropagation() - copyToClipboard(item.content) + if (item.content_type !== 'encrypted') { + copyToClipboard(item.content) + updateActivity() + } }} /> { e.stopPropagation() togglePin(item) + updateActivity() }} /> { e.stopPropagation() toggleFavorite(item) + updateActivity() }} /> { e.stopPropagation() deleteItem(item.id) + updateActivity() }} />
@@ -517,41 +1200,646 @@ function App() { )}
)} -
+
{/* end Clipboard List */} + +
{/* end right pane */} +
{/* end Search + List + Tag Sidebar */} {/* Bottom Status Bar */}
-
- - {filteredItems.length} items + {/* Left — stats */} +
+ + {filteredItems.length} items - - {items.filter(i => i.pinned).length} pinned + + {items.filter(i => i.pinned).length} pinned - - {items.filter(i => i.favorite).length} favorites + + {items.filter(i => i.favorite).length} fav - - {items.filter(i => i.content_type.includes('image')).length} images + + {items.filter(i => i.content_type === 'image').length} img
-
- - - All synced - + + {/* Right — vault badge + synced + shortcuts hint */} +
+ {settings.encryptionEnabled && hasPassword && ( +
{ setShowSettings(true); updateActivity() } : handleLockVault} + style={{ + display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', + padding: '2px 7px', borderRadius: '5px', + backgroundColor: vaultLocked ? 'rgba(255,122,114,0.12)' : 'rgba(71,194,103,0.12)', + border: `1px solid ${vaultLocked ? 'rgba(255,122,114,0.3)' : 'rgba(71,194,103,0.3)'}`, + }} + title={vaultLocked ? 'Vault locked — click to unlock' : 'Vault unlocked — click to lock'} + > + {vaultLocked ? '🔒' : '🔓'} + + {vaultLocked ? 'Locked' : 'Unlocked'} + +
+ )} + +
+ + Synced +
+ + {/* Shortcuts tooltip trigger */} +
+
?
+ {/* Tooltip */} +
+ {[ + ['⌘⇧V', 'Show / hide window'], + ['⌘⇧C', 'Quick paste latest'], + ['⌘K', 'Focus search'], + ['↑ ↓', 'Navigate'], + ['Enter', 'Copy selected'], + ['P', 'Pin selected'], + ['F', 'Favorite selected'], + ['D', 'Delete selected'], + ].map(([key, desc]) => ( +
+ {key} + {desc} +
+ ))} +
+
+ {/* Context Menu */} + {contextMenu && ( +
e.stopPropagation()} + > + {/* Current tags */} + {(itemTags[contextMenu.item.id] ?? []).length > 0 && ( + <> +
+ Tags +
+ {(itemTags[contextMenu.item.id] ?? []).map(tag => ( +
+ + + {tag.name} + + +
+ ))} +
+ + )} + {/* Add tag row */} + {showTagInput ? ( +
+ setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddTag(contextMenu.item, tagInput) + setTagInput('') + setShowTagInput(false) + setContextMenu(null) + } + if (e.key === 'Escape') { + setShowTagInput(false) + setTagInput('') + } + }} + placeholder="Tag name…" + style={{ + flex: 1, padding: '5px 8px', backgroundColor: '#1F242B', + border: '1px solid #3A414C', borderRadius: '5px', + color: '#F3F5F8', fontSize: '13px', outline: 'none' + }} + /> + +
+ ) : ( +
setShowTagInput(true)} + style={{ + padding: '8px 10px', borderRadius: '6px', fontSize: '13px', + color: '#A7B0BC', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' + }} + > + + Add tag +
+ )} + {/* Other actions */} +
+
{ copyToClipboard(contextMenu.item.content); setContextMenu(null) }} + style={{ + padding: '8px 10px', borderRadius: '6px', fontSize: '13px', + color: contextMenu.item.content_type === 'encrypted' ? '#5A6471' : '#A7B0BC', + cursor: contextMenu.item.content_type === 'encrypted' ? 'not-allowed' : 'pointer' + }} + > + Copy to clipboard +
+
{ togglePin(contextMenu.item); setContextMenu(null) }} + style={{ padding: '8px 10px', borderRadius: '6px', fontSize: '13px', color: '#A7B0BC', cursor: 'pointer' }} + > + {contextMenu.item.pinned ? 'Unpin' : 'Pin'} +
+
{ toggleFavorite(contextMenu.item); setContextMenu(null) }} + style={{ padding: '8px 10px', borderRadius: '6px', fontSize: '13px', color: '#A7B0BC', cursor: 'pointer' }} + > + {contextMenu.item.favorite ? 'Unfavorite' : 'Favorite'} +
+
{ deleteItem(contextMenu.item.id); setContextMenu(null) }} + style={{ padding: '8px 10px', borderRadius: '6px', fontSize: '13px', color: '#FF7A72', cursor: 'pointer' }} + > + Delete +
+
+ )} + + {/* Settings Modal */} + {showSettings && ( +
+
+
+

+ Settings +

+ +
+ +
+ {/* Encryption Section */} +
+

+ Encryption +

+
+
+ Enable encryption +
handleSettingsChange('encryptionEnabled', !settings.encryptionEnabled)} + style={{ + width: '44px', + height: '24px', + backgroundColor: settings.encryptionEnabled ? '#6D7FFF' : '#3A414C', + borderRadius: '12px', + position: 'relative', + cursor: 'pointer', + transition: 'background-color 0.2s' + }} + > +
+
+
+
+ Auto-lock after (min) + handleSettingsChange('autoLockMinutes', parseInt(e.target.value) || 0)} + style={{ + width: '60px', + padding: '6px 8px', + backgroundColor: '#1F242B', + border: '1px solid #3A414C', + borderRadius: '6px', + color: '#F3F5F8', + fontSize: '14px' + }} + /> +
+
+
+ + {/* Master Password Section */} +
+

+ Master Password +

+
+ {!hasPassword ? ( + /* No password configured — show set form */ +
+ + Set a master password to enable AES-256-GCM encryption. + + { setMasterPassword(e.target.value); setPasswordError(null) }} + style={{ + padding: '8px 12px', + backgroundColor: '#1F242B', + border: '1px solid #3A414C', + borderRadius: '6px', + color: '#F3F5F8', + fontSize: '14px' + }} + /> + { setConfirmPassword(e.target.value); setPasswordError(null) }} + onKeyDown={(e) => { if (e.key === 'Enter') handleSetMasterPassword() }} + style={{ + padding: '8px 12px', + backgroundColor: '#1F242B', + border: '1px solid #3A414C', + borderRadius: '6px', + color: '#F3F5F8', + fontSize: '14px' + }} + /> + {passwordError && ( + {passwordError} + )} + +
+ ) : vaultLocked ? ( + /* Password set, vault locked — show unlock form */ +
+
+ 🔒 + Vault is locked +
+ { setUnlockPassword(e.target.value); setUnlockError(null) }} + onKeyDown={(e) => { if (e.key === 'Enter') handleUnlockVault() }} + style={{ + padding: '8px 12px', + backgroundColor: '#1F242B', + border: `1px solid ${unlockError ? '#FF7A72' : '#3A414C'}`, + borderRadius: '6px', + color: '#F3F5F8', + fontSize: '14px' + }} + /> + {unlockError && ( + {unlockError} + )} + +
+ ) : ( + /* Password set, vault unlocked — show lock button */ +
+
+ 🔓 + Vault is unlocked +
+ + Clipboard data is being encrypted. Auto-lock after {settings.autoLockMinutes} min of inactivity. + + +
+ )} + +
+
+ + {/* Retention Section */} +
+

+ Data Retention +

+
+
+ Max items + handleSettingsChange('maxItems', parseInt(e.target.value) || 0)} + style={{ + width: '80px', + padding: '6px 8px', + backgroundColor: '#1F242B', + border: '1px solid #3A414C', + borderRadius: '6px', + color: '#F3F5F8', + fontSize: '14px' + }} + /> +
+
+ Retention days + handleSettingsChange('retentionDays', parseInt(e.target.value) || 0)} + style={{ + width: '60px', + padding: '6px 8px', + backgroundColor: '#1F242B', + border: '1px solid #3A414C', + borderRadius: '6px', + color: '#F3F5F8', + fontSize: '14px' + }} + /> +
+
+
+ + {/* General Section */} +
+

+ General +

+
+
+ Auto-refresh interval (sec) + handleSettingsChange('refreshInterval', parseInt(e.target.value) || 0)} + style={{ + width: '60px', + padding: '6px 8px', + backgroundColor: '#1F242B', + border: '1px solid #3A414C', + borderRadius: '6px', + color: '#F3F5F8', + fontSize: '14px' + }} + /> +
+
+ Show notifications +
handleSettingsChange('showNotifications', !settings.showNotifications)} + style={{ + width: '44px', + height: '24px', + backgroundColor: settings.showNotifications ? '#6D7FFF' : '#3A414C', + borderRadius: '12px', + position: 'relative', + cursor: 'pointer', + transition: 'background-color 0.2s' + }} + > +
+
+
+
+ Launch at login +
{ + const next = !launchAtLogin + try { + await invoke('set_launch_at_login', { enabled: next }) + setLaunchAtLogin(next) + } catch (e) { + console.error('Failed to set launch at login:', e) + } + }} + style={{ + width: '44px', + height: '24px', + backgroundColor: launchAtLogin ? '#6D7FFF' : '#3A414C', + borderRadius: '12px', + position: 'relative', + cursor: 'pointer', + transition: 'background-color 0.2s' + }} + > +
+
+
+
+
+ +
+ + +
+
+
+
+ )} + {/* Selected Item Detail Modal */} {selectedItem && (
Content: -
-                  {selectedItem.content}
-                
+ {selectedItem.content_type === 'image' ? ( +
+ Clipboard image +
+ ) : ( +
+                    {selectedItem.content}
+                  
+ )} +
+
+
+
+ )} + + {/* ── Plugins Modal ──────────────────────────────────────────────────── */} + {showPlugins && ( +
+
+ {/* Header */} +
+
+ +

+ Plugins +

+
+ +
+ +

+ Plugins are WebAssembly modules that transform clipboard text. Drop a{' '} + .wasm{' '} + file in ~/.local/share/openpaste/plugins/{' '} + or load one manually below. +

+ + {/* Error banner */} + {pluginError && ( +
+ {pluginError} +
+ )} + + {/* Load button */} + + + {/* Plugin list */} + {pluginLoading ? ( +
Loading…
+ ) : plugins.length === 0 ? ( +
+ + No plugins loaded. Plugins are auto-loaded from the plugins directory on daemon start. +
+ ) : ( +
+ {plugins.map(plugin => ( +
+
+ +
+
+ {plugin.name} +
+
+ {plugin.path} +
+
+
+
+ + {plugin.enabled ? 'Active' : 'Disabled'} + + +
+
+ ))} +
+ )} + + {/* Refresh button */} +
+ +
+
+
+ )} + + {/* ── Sync Modal ──────────────────────────────────────────────────────── */} + {showSync && ( +
+
+ {/* Header */} +
+
+ {syncConfig.enabled + ? + : + } +

+ Sync +

+
+ +
+ +

+ Sync your clipboard history across devices via a self-hosted relay server + (or any server running the OpenPaste HTTP API). +

+ + {/* Status banners */} + {syncError && ( +
+ {syncError} +
+ )} + {syncStatus && ( +
+ + {syncStatus} +
+ )} + + {/* Form */} +
+ + {/* Enable toggle */} +
+ Enable sync +
setSyncConfig(c => ({ ...c, enabled: !c.enabled }))} + style={{ + width: '44px', height: '24px', + backgroundColor: syncConfig.enabled ? '#6D7FFF' : '#3A414C', + borderRadius: '12px', position: 'relative', cursor: 'pointer', + transition: 'background-color 0.2s', + }} + > +
+
+
+ + {/* Server URL */} +
+ + setSyncConfig(c => ({ ...c, server_url: e.target.value }))} + style={{ + width: '100%', padding: '9px 12px', boxSizing: 'border-box', + backgroundColor: '#1F242B', border: '1px solid #3A414C', + borderRadius: '7px', color: '#F3F5F8', fontSize: '14px', outline: 'none', + }} + /> +
+ + {/* API token */} +
+ + setSyncConfig(c => ({ ...c, api_token: e.target.value || null }))} + style={{ + width: '100%', padding: '9px 12px', boxSizing: 'border-box', + backgroundColor: '#1F242B', border: '1px solid #3A414C', + borderRadius: '7px', color: '#F3F5F8', fontSize: '14px', outline: 'none', + }} + /> +
+ + {/* Last sync */} + {syncConfig.last_sync_at && ( +
+ + Last synced: {new Date(syncConfig.last_sync_at).toLocaleString()} +
+ )} + + {/* Buttons */} +
+ +
diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 6662826..6da3be3 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -16,3 +16,12 @@ body, html { overflow: hidden; background-color: #171A1F; } + +.shortcuts-hint:hover .shortcuts-tooltip { + display: flex !important; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/crates/clipboard-api/Cargo.toml b/crates/clipboard-api/Cargo.toml index ccc6728..5e62d4f 100644 --- a/crates/clipboard-api/Cargo.toml +++ b/crates/clipboard-api/Cargo.toml @@ -7,6 +7,10 @@ authors.workspace = true license.workspace = true repository.workspace = true +[[bin]] +name = "clipboard-api" +path = "src/main.rs" + [dependencies] tokio = { workspace = true } serde = { workspace = true } @@ -14,8 +18,13 @@ serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } axum = { workspace = true } tower-http = { version = "0.5", features = ["cors", "trace"] } clipboard-core = { path = "../clipboard-core" } clipboard-db = { path = "../clipboard-db" } clipboard-search = { path = "../clipboard-search" } +chrono = { workspace = true } +base64 = "0.22" +md5 = "0.7" +dirs = "5.0" diff --git a/crates/clipboard-api/src/api.rs b/crates/clipboard-api/src/api.rs index f62c852..0547b58 100644 --- a/crates/clipboard-api/src/api.rs +++ b/crates/clipboard-api/src/api.rs @@ -1,77 +1,317 @@ -//! REST API server implementation +//! REST API server +//! +//! Routes: +//! GET /api/v1/status — health check +//! GET /api/v1/clipboard — get current clipboard content +//! POST /api/v1/clipboard — set clipboard content +//! POST /api/v1/search — FTS5 search +//! GET /api/v1/history — list history (limit/offset) +//! GET /api/v1/item/:id — get single item +//! DELETE /api/v1/item/:id — delete item +//! POST /api/v1/sync/push — sync push endpoint +//! GET /api/v1/sync/pull — sync pull endpoint use crate::ApiError; use axum::{ + extract::{Path, Query, State}, routing::{get, post}, - Router, + Json, Router, }; +use clipboard_db::Database; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use std::net::SocketAddr; +use std::sync::Arc; use tokio::net::TcpListener; +use tower_http::cors::CorsLayer; + +// ── Shared app state ────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct AppState { + pub db: Arc, +} + +// ── Server ──────────────────────────────────────────────────────────────────── -/// REST API server pub struct ApiServer { addr: SocketAddr, + db: Arc, } impl ApiServer { - /// Create a new API server - pub fn new(addr: SocketAddr) -> Self { - Self { addr } + pub fn new(addr: SocketAddr, db: Arc) -> Self { + Self { addr, db } } - /// Build the router - fn router() -> Router { + fn router(state: AppState) -> Router { Router::new() - .route("/api/v1/status", get(handlers::status)) - .route( - "/api/v1/clipboard", - get(handlers::get_clipboard).post(handlers::set_clipboard), - ) - .route("/api/v1/search", post(handlers::search)) + .route("/api/v1/status", get(status)) + .route("/api/v1/clipboard", get(get_clipboard).post(set_clipboard)) + .route("/api/v1/search", post(search)) + .route("/api/v1/history", get(list_history)) + .route("/api/v1/item/:id", get(get_item).delete(delete_item)) + .route("/api/v1/sync/push", post(sync_push)) + .route("/api/v1/sync/pull", get(sync_pull)) + .with_state(state) + .layer(CorsLayer::permissive()) } - /// Start the API server pub async fn start(&self) -> Result<(), ApiError> { - let app = Self::router(); + let state = AppState { + db: self.db.clone(), + }; + let app = Self::router(state); let listener = TcpListener::bind(self.addr) .await - .map_err(|e: std::io::Error| ApiError::RequestFailed(e.to_string()))?; + .map_err(|e| ApiError::RequestFailed(e.to_string()))?; + tracing::info!("API server listening on {}", self.addr); axum::serve(listener, app) .await - .map_err(|e: std::io::Error| ApiError::RequestFailed(e.to_string())) + .map_err(|e| ApiError::RequestFailed(e.to_string())) } } -mod handlers { - use axum::{response::IntoResponse, Json}; - use serde_json::json; +// ── Handlers ────────────────────────────────────────────────────────────────── - pub async fn status() -> impl IntoResponse { - Json(json!({ - "status": "ok", - "version": "0.1.0" - })) +async fn status() -> Json { + Json(json!({ "status": "ok", "version": env!("CARGO_PKG_VERSION") })) +} + +#[derive(Deserialize)] +struct PaginationParams { + #[serde(default = "default_limit")] + limit: usize, + #[serde(default)] + offset: usize, +} +fn default_limit() -> usize { 50 } + +async fn list_history( + State(state): State, + Query(params): Query, +) -> Json { + match state.db.list_items(params.limit.min(200), params.offset).await { + Ok(items) => { + let out: Vec = items.iter().map(item_to_json).collect(); + Json(json!({ "items": out, "total": out.len() })) + } + Err(e) => Json(json!({ "error": e.to_string() })), + } +} + +async fn get_clipboard(State(state): State) -> Json { + match state.db.list_items(1, 0).await { + Ok(items) if !items.is_empty() => Json(json!({ "item": item_to_json(&items[0]) })), + Ok(_) => Json(json!({ "item": null })), + Err(e) => Json(json!({ "error": e.to_string() })), } +} + +#[derive(Deserialize)] +struct SetClipboardBody { + content: String, + content_type: Option, +} + +async fn set_clipboard( + State(state): State, + Json(body): Json, +) -> Json { + use clipboard_db::models::ClipboardItem; + use md5; + + let content_bytes = body.content.as_bytes().to_vec(); + let hash = format!("{:x}", md5::compute(&content_bytes)); + let ct = body.content_type.unwrap_or_else(|| "text".to_string()); + + let item = ClipboardItem { + id: 0, + content_type: ct, + content: content_bytes, + hash, + created_at: chrono::Utc::now(), + accessed_at: None, + pinned: false, + favorite: false, + nonce: None, + encrypted: false, + }; + + match state.db.insert_item(&item).await { + Ok(id) => Json(json!({ "id": id, "success": true })), + Err(e) if e.to_string().contains("UNIQUE") => { + Json(json!({ "success": true, "duplicate": true })) + } + Err(e) => Json(json!({ "error": e.to_string() })), + } +} + +#[derive(Deserialize)] +struct SearchBody { + query: String, + #[serde(default = "default_limit")] + limit: usize, + #[serde(default)] + offset: usize, +} + +async fn search( + State(state): State, + Json(body): Json, +) -> Json { + match state.db.search_items(&body.query, body.limit.min(200), body.offset).await { + Ok(items) => { + let out: Vec = items.iter().map(item_to_json).collect(); + Json(json!({ "items": out })) + } + Err(e) => Json(json!({ "error": e.to_string() })), + } +} - pub async fn get_clipboard() -> impl IntoResponse { - Json(json!({ - "success": false, - "error": "Not implemented" - })) +async fn get_item( + State(state): State, + Path(id): Path, +) -> Json { + match state.db.get_item(id).await { + Ok(item) => Json(json!({ "item": item_to_json(&item) })), + Err(e) => Json(json!({ "error": e.to_string() })), } +} - pub async fn set_clipboard() -> impl IntoResponse { - Json(json!({ - "success": false, - "error": "Not implemented" - })) +async fn delete_item( + State(state): State, + Path(id): Path, +) -> Json { + match state.db.delete_item(id).await { + Ok(_) => Json(json!({ "success": true })), + Err(e) => Json(json!({ "error": e.to_string() })), } +} + +// ── Sync endpoints ──────────────────────────────────────────────────────────── +// These implement the server side of the sync protocol described in clipboard-sync. + +#[derive(Deserialize)] +struct SyncPushBody { + device_id: String, + items: Vec, +} + +#[derive(Deserialize, Serialize)] +struct SyncItemWire { + hash: String, + content_type: String, + content_b64: String, + created_at: String, + pinned: bool, + favorite: bool, + encrypted: bool, + nonce_b64: Option, +} + +#[derive(Deserialize)] +struct SyncPullParams { + device_id: String, + since: Option, +} + +async fn sync_push( + State(state): State, + Json(body): Json, +) -> Json { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use clipboard_db::models::ClipboardItem; + + tracing::info!("Sync push from device: {}", body.device_id); + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + + for wire in body.items { + let content = match STANDARD.decode(&wire.content_b64) { + Ok(b) => b, + Err(_) => { rejected.push(wire.hash); continue; } + }; + let nonce = wire.nonce_b64.as_deref().and_then(|n| STANDARD.decode(n).ok()); + let created_at = match chrono::DateTime::parse_from_rfc3339(&wire.created_at) { + Ok(dt) => dt.with_timezone(&chrono::Utc), + Err(_) => { rejected.push(wire.hash); continue; } + }; + + let item = ClipboardItem { + id: 0, + content_type: wire.content_type, + content, + hash: wire.hash.clone(), + created_at, + accessed_at: None, + pinned: wire.pinned, + favorite: wire.favorite, + nonce, + encrypted: wire.encrypted, + }; - pub async fn search() -> impl IntoResponse { - Json(json!({ - "success": false, - "error": "Not implemented" - })) + match state.db.insert_item(&item).await { + Ok(_) | Err(_) => accepted.push(wire.hash), // duplicates are also "accepted" + } } + + Json(json!({ "accepted": accepted, "rejected": rejected })) +} + +async fn sync_pull( + State(state): State, + Query(params): Query, +) -> Json { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + tracing::info!("Sync pull from device: {}", params.device_id); + + // Fetch recent items (server just returns last 500; client filters by since) + let items = match state.db.list_items(500, 0).await { + Ok(i) => i, + Err(e) => return Json(json!({ "error": e.to_string() })), + }; + + let since = params.since.as_deref().unwrap_or("1970-01-01T00:00:00Z"); + + let wire_items: Vec = items + .into_iter() + .filter(|item| item.created_at.to_rfc3339().as_str() > since) + .map(|item| SyncItemWire { + hash: item.hash, + content_type: item.content_type, + content_b64: STANDARD.encode(&item.content), + created_at: item.created_at.to_rfc3339(), + pinned: item.pinned, + favorite: item.favorite, + encrypted: item.encrypted, + nonce_b64: item.nonce.as_deref().map(|n| STANDARD.encode(n)), + }) + .collect(); + + Json(json!({ "items": wire_items })) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn item_to_json(item: &clipboard_db::models::ClipboardItem) -> Value { + // Return text content as UTF-8 string; binary/encrypted as base64 + let content_str = if item.encrypted { + "[encrypted]".to_string() + } else { + String::from_utf8_lossy(&item.content).to_string() + }; + + json!({ + "id": item.id, + "content_type": item.content_type, + "content": content_str, + "hash": item.hash, + "created_at": item.created_at.to_rfc3339(), + "pinned": item.pinned, + "favorite": item.favorite, + "encrypted": item.encrypted, + }) } diff --git a/crates/clipboard-api/src/main.rs b/crates/clipboard-api/src/main.rs new file mode 100644 index 0000000..23e7823 --- /dev/null +++ b/crates/clipboard-api/src/main.rs @@ -0,0 +1,56 @@ +//! OpenPaste sync relay server +//! +//! Starts the HTTP API server so other devices can push/pull clipboard history. +//! +//! Usage: +//! cargo run --bin clipboard-api -- --addr 0.0.0.0:8080 +//! OPENPASTE_API_ADDR=0.0.0.0:8080 cargo run --bin clipboard-api + +use anyhow::Result; +use clipboard_api::ApiServer; +use clipboard_db::Database; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<()> { + // Basic logging + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .init(); + + // Resolve bind address — flag > env > default + let addr_str = std::env::args() + .skip_while(|a| a != "--addr") + .nth(1) + .or_else(|| std::env::var("OPENPASTE_API_ADDR").ok()) + .unwrap_or_else(|| "127.0.0.1:8080".to_string()); + + let addr: SocketAddr = addr_str + .parse() + .map_err(|e| anyhow::anyhow!("Invalid address '{}': {}", addr_str, e))?; + + // Use the same data directory as the daemon so they share the same DB + let data_dir = dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("openpaste"); + + std::fs::create_dir_all(&data_dir)?; + let db_path = data_dir.join("clipboard.db"); + + tracing::info!("Opening database at {:?}", db_path); + let db = Arc::new( + Database::new(&db_path) + .await + .map_err(|e| anyhow::anyhow!("Database error: {}", e))?, + ); + + tracing::info!("OpenPaste API server listening on {}", addr); + ApiServer::new(addr, db).start().await?; + + Ok(()) +} diff --git a/crates/clipboard-core/Cargo.toml b/crates/clipboard-core/Cargo.toml index 215508a..3f334e8 100644 --- a/crates/clipboard-core/Cargo.toml +++ b/crates/clipboard-core/Cargo.toml @@ -17,3 +17,4 @@ tracing = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } md5 = "0.7" +arboard = "3.3" diff --git a/crates/clipboard-core/src/clipboard.rs b/crates/clipboard-core/src/clipboard.rs index 4a3f168..ef7e7d3 100644 --- a/crates/clipboard-core/src/clipboard.rs +++ b/crates/clipboard-core/src/clipboard.rs @@ -1,6 +1,7 @@ -//! Clipboard management +//! Clipboard management — thin wrapper around arboard. use crate::item::ClipboardItem; +use crate::ContentType; use thiserror::Error; #[derive(Error, Debug)] @@ -13,27 +14,57 @@ pub enum ClipboardError { ContentTooLarge(usize), } -/// Clipboard manager for capturing and managing clipboard content +/// Clipboard manager for reading and writing the system clipboard. pub struct ClipboardManager { - #[allow(dead_code)] max_size: usize, } impl ClipboardManager { - /// Create a new clipboard manager + /// Create a new clipboard manager. + /// `max_size` is the maximum content size in bytes that will be accepted. pub fn new(max_size: usize) -> Self { Self { max_size } } - /// Capture clipboard content + /// Read the current clipboard content. + /// + /// Tries text first; images are not handled here (use `clipboard-platform` + /// for full image + content-type detection). pub async fn capture(&self) -> Result { - // TODO: Implement platform-specific clipboard capture - Err(ClipboardError::AccessFailed("Not implemented".to_string())) + let mut ctx = arboard::Clipboard::new() + .map_err(|e| ClipboardError::AccessFailed(e.to_string()))?; + + let text = ctx + .get_text() + .map_err(|e| ClipboardError::AccessFailed(e.to_string()))?; + + if text.is_empty() { + return Err(ClipboardError::AccessFailed("Clipboard is empty".to_string())); + } + + let bytes = text.into_bytes(); + if bytes.len() > self.max_size { + return Err(ClipboardError::ContentTooLarge(bytes.len())); + } + + Ok(ClipboardItem::new(ContentType::Text, bytes)) } - /// Set clipboard content - pub async fn set(&self, _item: &ClipboardItem) -> Result<(), ClipboardError> { - // TODO: Implement platform-specific clipboard setting - Err(ClipboardError::AccessFailed("Not implemented".to_string())) + /// Write a clipboard item to the system clipboard. + /// + /// Only text content types are supported; image writes require the + /// platform-level API. + pub async fn set(&self, item: &ClipboardItem) -> Result<(), ClipboardError> { + match item.content_type { + ContentType::Image => return Err(ClipboardError::UnsupportedContentType), + _ => {} + } + + let text = String::from_utf8_lossy(&item.content).to_string(); + let mut ctx = arboard::Clipboard::new() + .map_err(|e| ClipboardError::AccessFailed(e.to_string()))?; + + ctx.set_text(&text) + .map_err(|e| ClipboardError::AccessFailed(e.to_string())) } } diff --git a/crates/clipboard-daemon/Cargo.toml b/crates/clipboard-daemon/Cargo.toml index efc6837..0dc2876 100644 --- a/crates/clipboard-daemon/Cargo.toml +++ b/crates/clipboard-daemon/Cargo.toml @@ -27,3 +27,9 @@ clipboard-platform = { path = "../clipboard-platform" } clipboard-events = { path = "../clipboard-events" } clipboard-ipc = { path = "../clipboard-ipc" } dirs = "5.0" +notify-rust = { version = "4.11", features = ["d"] } +chrono = { workspace = true } +uuid = { workspace = true } +clipboard-sync = { path = "../clipboard-sync" } +clipboard-plugin = { path = "../clipboard-plugin" } +clipboard-api = { path = "../clipboard-api" } diff --git a/crates/clipboard-daemon/src/main.rs b/crates/clipboard-daemon/src/main.rs index 97b6121..9acff6c 100644 --- a/crates/clipboard-daemon/src/main.rs +++ b/crates/clipboard-daemon/src/main.rs @@ -3,18 +3,18 @@ use anyhow::Result; use clipboard_core::ClipboardItem; use clipboard_db::Database; -use clipboard_encryption::Encryption; use clipboard_events::{Event, EventBus}; use clipboard_ipc::{IpcMessage, IpcServer}; use clipboard_platform::{get_provider, ClipboardProvider}; +use clipboard_plugin::PluginManager; +use clipboard_sync::{SyncClient, SyncConfig}; use std::path::PathBuf; use std::sync::Arc; use tokio::signal; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use tracing::{error, info}; use tracing_subscriber::fmt; use tracing_subscriber::EnvFilter; - #[tokio::main] async fn main() -> Result<()> { // Initialize logging @@ -48,8 +48,25 @@ async fn main() -> Result<()> { let _storage = Arc::new(clipboard_storage::Storage::new(&storage_path)); // Initialize encryption (placeholder key - should come from user password) - let key = [0u8; 32]; // TODO: Get from user password - let _encryption = Arc::new(Encryption::new(key)); + let _key = [0u8; 32]; // TODO: Get from user password + let encryption = Arc::new(RwLock::new(None::)); + + // Initialize plugin manager + let plugin_manager = Arc::new(PluginManager::new()); + + // Load plugins from data_dir/plugins/*.wasm if any exist + let plugins_dir = data_dir.join("plugins"); + std::fs::create_dir_all(&plugins_dir)?; + if let Ok(entries) = std::fs::read_dir(&plugins_dir) { + for entry in entries.flatten() { + if entry.path().extension().and_then(|e| e.to_str()) == Some("wasm") { + match plugin_manager.load(&entry.path()) { + Ok(name) => info!("Auto-loaded plugin: {}", name), + Err(e) => error!("Failed to load plugin {:?}: {}", entry.path(), e), + } + } + } + } // Initialize event bus let event_bus = Arc::new(EventBus::new(100)); @@ -63,6 +80,8 @@ async fn main() -> Result<()> { let database = database.clone(); let event_bus = event_bus.clone(); let provider = provider.clone(); + let encryption = encryption.clone(); + let plugin_manager = plugin_manager.clone(); move |message: IpcMessage| -> std::pin::Pin + Send>> { match message { @@ -78,19 +97,40 @@ async fn main() -> Result<()> { } IpcMessage::GetHistory => { let database_clone = database.clone(); + let encryption_clone = encryption.clone(); Box::pin(async move { match database_clone.list_items(100, 0).await { Ok(items) => { + let encryption_guard = encryption_clone.read().await; let history_items: Vec = items.into_iter().map(|item| { + let (content, content_type) = if item.encrypted { + if let Some(ref enc) = *encryption_guard { + match enc.decrypt(&clipboard_encryption::EncryptedData { + nonce: item.nonce.clone().unwrap_or_default(), + ciphertext: item.content.clone(), + }) { + Ok(decrypted) => (decrypted, item.content_type.clone()), + Err(_) => (b"[decryption failed]".to_vec(), "encrypted".to_string()), + } + } else { + // Vault locked — return placeholder, not raw ciphertext + (b"\xf0\x9f\x94\x92 Encrypted".to_vec(), "encrypted".to_string()) + } + } else { + (item.content, item.content_type.clone()) + }; + clipboard_ipc::ClipboardHistoryItem { id: item.id.to_string(), - content_type: item.content_type, - content: item.content, + content_type, + content, hash: item.hash, created_at: item.created_at.to_rfc3339(), accessed_at: item.accessed_at.map(|t| t.to_rfc3339()), pinned: item.pinned, favorite: item.favorite, + nonce: item.nonce, + encrypted: item.encrypted, } }).collect(); IpcMessage::ClipboardHistory { items: history_items } @@ -101,19 +141,39 @@ async fn main() -> Result<()> { } IpcMessage::SearchItems { query } => { let database_clone = database.clone(); + let encryption_clone = encryption.clone(); Box::pin(async move { match database_clone.search_items(&query, 100, 0).await { Ok(items) => { + let encryption_guard = encryption_clone.read().await; let history_items: Vec = items.into_iter().map(|item| { + let (content, content_type) = if item.encrypted { + if let Some(ref enc) = *encryption_guard { + match enc.decrypt(&clipboard_encryption::EncryptedData { + nonce: item.nonce.clone().unwrap_or_default(), + ciphertext: item.content.clone(), + }) { + Ok(decrypted) => (decrypted, item.content_type.clone()), + Err(_) => (b"[decryption failed]".to_vec(), "encrypted".to_string()), + } + } else { + (b"\xf0\x9f\x94\x92 Encrypted".to_vec(), "encrypted".to_string()) + } + } else { + (item.content, item.content_type.clone()) + }; + clipboard_ipc::ClipboardHistoryItem { id: item.id.to_string(), - content_type: item.content_type, - content: item.content, + content_type, + content, hash: item.hash, created_at: item.created_at.to_rfc3339(), accessed_at: item.accessed_at.map(|t| t.to_rfc3339()), pinned: item.pinned, favorite: item.favorite, + nonce: item.nonce, + encrypted: item.encrypted, } }).collect(); IpcMessage::ClipboardHistory { items: history_items } @@ -126,21 +186,41 @@ async fn main() -> Result<()> { let provider_clone = provider.clone(); let database_clone = database.clone(); let event_bus_clone = event_bus.clone(); + let encryption_clone = encryption.clone(); let item = ClipboardItem::new(clipboard_core::ContentType::Text, content.clone()); Box::pin(async move { let provider = provider_clone.lock().await; match provider.set_content(&item).await { Ok(_) => { + // Encrypt if encryption is enabled + let (content_to_store, nonce, encrypted) = { + let encryption_guard = encryption_clone.read().await; + if let Some(ref enc) = *encryption_guard { + match enc.encrypt(&item.content) { + Ok(encrypted_data) => (encrypted_data.ciphertext, Some(encrypted_data.nonce), true), + Err(e) => { + error!("Encryption failed: {}, storing unencrypted", e); + (item.content.clone(), None, false) + } + } + } else { + (item.content.clone(), None, false) + } + }; + drop(encryption_clone); + // Convert to DB model and save let db_item = clipboard_db::models::ClipboardItem { id: 0, // Will be auto-assigned content_type: item.content_type.to_string(), - content: item.content.clone(), + content: content_to_store, hash: item.hash, created_at: item.created_at, accessed_at: item.accessed_at, pinned: item.pinned, favorite: item.favorite, + nonce, + encrypted, }; let _ = database_clone.insert_item(&db_item).await; @@ -181,6 +261,378 @@ async fn main() -> Result<()> { } }) } + IpcMessage::GetSettings => { + let database_clone = database.clone(); + Box::pin(async move { + // Load settings from database + let settings = match database_clone.get_setting("max_items").await { + Ok(Some(val)) => val.parse::().unwrap_or(10000), + _ => 10000, + }; + let retention = match database_clone.get_setting("retention_days").await { + Ok(Some(val)) => val.parse::().unwrap_or(90), + _ => 90, + }; + let encryption = match database_clone.get_setting("encryption_enabled").await { + Ok(Some(val)) => val.parse::().unwrap_or(false), + _ => false, + }; + let auto_lock = match database_clone.get_setting("auto_lock_minutes").await { + Ok(Some(val)) => val.parse::().unwrap_or(5), + _ => 5, + }; + let refresh = match database_clone.get_setting("refresh_interval").await { + Ok(Some(val)) => val.parse::().unwrap_or(2), + _ => 2, + }; + let notifications = match database_clone.get_setting("show_notifications").await { + Ok(Some(val)) => val.parse::().unwrap_or(true), + _ => true, + }; + + IpcMessage::Settings { + settings: clipboard_ipc::AppSettings { + encryption_enabled: encryption, + auto_lock_minutes: auto_lock, + max_items: settings, + retention_days: retention, + refresh_interval: refresh, + show_notifications: notifications, + } + } + }) + } + IpcMessage::SaveSettings { settings } => { + let database_clone = database.clone(); + Box::pin(async move { + // Save settings to database + let results = vec![ + database_clone.upsert_setting("max_items", &settings.max_items.to_string()).await, + database_clone.upsert_setting("retention_days", &settings.retention_days.to_string()).await, + database_clone.upsert_setting("encryption_enabled", &settings.encryption_enabled.to_string()).await, + database_clone.upsert_setting("auto_lock_minutes", &settings.auto_lock_minutes.to_string()).await, + database_clone.upsert_setting("refresh_interval", &settings.refresh_interval.to_string()).await, + database_clone.upsert_setting("show_notifications", &settings.show_notifications.to_string()).await, + ]; + + if results.iter().all(|r| r.is_ok()) { + IpcMessage::Success + } else { + IpcMessage::Error { message: "Failed to save some settings".to_string() } + } + }) + } + IpcMessage::SetMasterPassword { password } => { + let database_clone = database.clone(); + Box::pin(async move { + use clipboard_encryption::KeyDerivation; + let key_derivation = KeyDerivation::new(); + + // Hash the password and store it + match key_derivation.hash_password(&password) { + Ok(hash) => { + // Generate and store salt for key derivation + let salt = KeyDerivation::generate_salt(); + match database_clone.upsert_setting("encryption_salt", &salt).await { + Ok(_) => { + match database_clone.upsert_setting("master_password_hash", &hash).await { + Ok(_) => IpcMessage::Success, + Err(e) => IpcMessage::Error { message: format!("Failed to store password: {}", e) }, + } + } + Err(e) => IpcMessage::Error { message: format!("Failed to store salt: {}", e) }, + } + } + Err(e) => IpcMessage::Error { message: format!("Failed to hash password: {}", e) }, + } + }) + } + IpcMessage::UnlockVault { password } => { + let database_clone = database.clone(); + let encryption_clone = encryption.clone(); + Box::pin(async move { + use clipboard_encryption::KeyDerivation; + let key_derivation = KeyDerivation::new(); + + // Get stored password hash and salt + let stored_hash = match database_clone.get_setting("master_password_hash").await { + Ok(Some(hash)) => hash, + Ok(None) => return IpcMessage::Error { message: "No master password set".to_string() }, + Err(e) => return IpcMessage::Error { message: format!("Failed to get password: {}", e) }, + }; + + let salt = match database_clone.get_setting("encryption_salt").await { + Ok(Some(s)) => s, + Ok(None) => return IpcMessage::Error { message: "No encryption salt found".to_string() }, + Err(e) => return IpcMessage::Error { message: format!("Failed to get salt: {}", e) }, + }; + + match key_derivation.verify(&password, &stored_hash) { + Ok(true) => { + // Derive encryption key from password using stored salt + match key_derivation.derive_key(&password, &salt) { + Ok(key) => { + use clipboard_encryption::Encryption; + let enc = Encryption::new(key); + *encryption_clone.write().await = Some(enc); + IpcMessage::Success + } + Err(e) => IpcMessage::Error { message: format!("Failed to derive key: {}", e) }, + } + } + Ok(false) => IpcMessage::Error { message: "Invalid password".to_string() }, + Err(e) => IpcMessage::Error { message: format!("Verification failed: {}", e) }, + } + }) + } + IpcMessage::LockVault => { + let encryption_clone = encryption.clone(); + Box::pin(async move { + *encryption_clone.write().await = None; + IpcMessage::Success + }) + } + IpcMessage::CheckVaultStatus => { + let database_clone = database.clone(); + let encryption_clone = encryption.clone(); + Box::pin(async move { + // Check if master password is configured + let has_password = matches!( + database_clone.get_setting("master_password_hash").await, + Ok(Some(_)) + ); + + if !has_password { + // No password configured — vault feature not set up + return IpcMessage::VaultStatus { is_locked: false }; + } + + // Password is configured: locked = encryption key not loaded in memory + let encryption_guard = encryption_clone.read().await; + let is_locked = encryption_guard.is_none(); + IpcMessage::VaultStatus { is_locked } + }) + } + IpcMessage::HasMasterPassword => { + let database_clone = database.clone(); + Box::pin(async move { + let has_password = matches!( + database_clone.get_setting("master_password_hash").await, + Ok(Some(_)) + ); + IpcMessage::PasswordConfigured { has_password } + }) + } + // ── Tag handlers ────────────────────────────────────────────── + IpcMessage::ListTags => { + let database_clone = database.clone(); + Box::pin(async move { + match database_clone.list_tags().await { + Ok(tags) => IpcMessage::TagsList { + tags: tags.into_iter().map(|t| clipboard_ipc::TagItem { + id: t.id, name: t.name, color: t.color, + }).collect() + }, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + IpcMessage::CreateTag { name, color } => { + let database_clone = database.clone(); + Box::pin(async move { + match database_clone.create_tag(&name, color.as_deref()).await { + Ok(_) => IpcMessage::Success, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + IpcMessage::DeleteTag { id } => { + let database_clone = database.clone(); + Box::pin(async move { + match database_clone.delete_tag(id).await { + Ok(_) => IpcMessage::Success, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + IpcMessage::GetItemTags { item_id } => { + let database_clone = database.clone(); + Box::pin(async move { + match database_clone.get_item_tags(item_id).await { + Ok(tags) => IpcMessage::ItemTags { + tags: tags.into_iter().map(|t| clipboard_ipc::TagItem { + id: t.id, name: t.name, color: t.color, + }).collect() + }, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + IpcMessage::AddTagToItem { item_id, tag_name, color } => { + let database_clone = database.clone(); + Box::pin(async move { + // Create tag if needed, then assign + match database_clone.create_tag(&tag_name, color.as_deref()).await { + Ok(tag_id) => { + // create_tag returns last_insert_rowid which is 0 on conflict update + // fetch the actual id by name + let actual_id = match database_clone.list_tags().await { + Ok(tags) => tags.into_iter() + .find(|t| t.name == tag_name) + .map(|t| t.id) + .unwrap_or(tag_id), + Err(_) => tag_id, + }; + match database_clone.add_tag_to_item(item_id, actual_id).await { + Ok(_) => IpcMessage::Success, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + } + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + IpcMessage::RemoveTagFromItem { item_id, tag_id } => { + let database_clone = database.clone(); + Box::pin(async move { + match database_clone.remove_tag_from_item(item_id, tag_id).await { + Ok(_) => IpcMessage::Success, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + IpcMessage::ListItemsByTag { tag_id } => { + let database_clone = database.clone(); + let encryption_clone = encryption.clone(); + Box::pin(async move { + match database_clone.list_items_by_tag(tag_id, 100, 0).await { + Ok(items) => { + let encryption_guard = encryption_clone.read().await; + let history_items = items.into_iter().map(|item| { + let (content, content_type) = if item.encrypted { + if let Some(ref enc) = *encryption_guard { + match enc.decrypt(&clipboard_encryption::EncryptedData { + nonce: item.nonce.clone().unwrap_or_default(), + ciphertext: item.content.clone(), + }) { + Ok(d) => (d, item.content_type.clone()), + Err(_) => (b"[decryption failed]".to_vec(), "encrypted".to_string()), + } + } else { + (b"\xf0\x9f\x94\x92 Encrypted".to_vec(), "encrypted".to_string()) + } + } else { + (item.content, item.content_type.clone()) + }; + clipboard_ipc::ClipboardHistoryItem { + id: item.id.to_string(), + content_type, + content, + hash: item.hash, + created_at: item.created_at.to_rfc3339(), + accessed_at: item.accessed_at.map(|t| t.to_rfc3339()), + pinned: item.pinned, + favorite: item.favorite, + nonce: item.nonce, + encrypted: item.encrypted, + } + }).collect(); + IpcMessage::ClipboardHistory { items: history_items } + } + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + // ── Sync handlers ───────────────────────────────────────────── + IpcMessage::GetSyncConfig => { + let database_clone = database.clone(); + Box::pin(async move { + let server_url = database_clone.get_setting("sync_server_url").await + .ok().flatten().unwrap_or_default(); + let api_token = database_clone.get_setting("sync_api_token").await + .ok().flatten(); + let enabled = database_clone.get_setting("sync_enabled").await + .ok().flatten().and_then(|v| v.parse::().ok()).unwrap_or(false); + let last_sync_at = database_clone.get_setting("last_sync_at").await + .ok().flatten(); + IpcMessage::SyncConfig { server_url, api_token, enabled, last_sync_at } + }) + } + IpcMessage::SetSyncConfig { server_url, api_token, enabled } => { + let database_clone = database.clone(); + Box::pin(async move { + let results = vec![ + database_clone.upsert_setting("sync_server_url", &server_url).await, + database_clone.upsert_setting("sync_enabled", &enabled.to_string()).await, + ]; + if let Some(token) = &api_token { + let _ = database_clone.upsert_setting("sync_api_token", token).await; + } + if results.iter().all(|r| r.is_ok()) { + IpcMessage::Success + } else { + IpcMessage::Error { message: "Failed to save sync config".to_string() } + } + }) + } + IpcMessage::SyncNow => { + let database_clone = database.clone(); + Box::pin(async move { + let server_url = match database_clone.get_setting("sync_server_url").await { + Ok(Some(url)) if !url.is_empty() => url, + _ => return IpcMessage::Error { message: "Sync server not configured".to_string() }, + }; + let api_token = database_clone.get_setting("sync_api_token").await.ok().flatten(); + let device_id = database_clone.get_setting("device_id").await.ok().flatten() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + // Persist device_id if new + let _ = database_clone.upsert_setting("device_id", &device_id).await; + + let config = SyncConfig { server_url, device_id, api_token }; + let client = SyncClient::new(config, database_clone.clone()); + let last_sync = database_clone.get_setting("last_sync_at").await.ok().flatten(); + match client.sync_once(last_sync.as_deref()).await { + Ok((pushed, pulled)) => { + let now = chrono::Utc::now().to_rfc3339(); + let _ = database_clone.upsert_setting("last_sync_at", &now).await; + IpcMessage::ClipboardContent { + content: format!("pushed={}, pulled={}", pushed, pulled).into_bytes() + } + } + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + // ── Plugin handlers ──────────────────────────────────────────── + IpcMessage::ListPlugins => { + let pm = plugin_manager.clone(); + Box::pin(async move { + let plugins = pm.list().into_iter().map(|p| clipboard_ipc::PluginInfoItem { + name: p.name, + path: p.path.to_string_lossy().to_string(), + enabled: p.enabled, + }).collect(); + IpcMessage::PluginList { plugins } + }) + } + IpcMessage::LoadPlugin { path } => { + let pm = plugin_manager.clone(); + Box::pin(async move { + match pm.load(std::path::Path::new(&path)) { + Ok(name) => IpcMessage::ClipboardContent { content: name.into_bytes() }, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } + IpcMessage::UnloadPlugin { name } => { + let pm = plugin_manager.clone(); + Box::pin(async move { + match pm.unload(&name) { + Ok(_) => IpcMessage::Success, + Err(e) => IpcMessage::Error { message: e.to_string() }, + } + }) + } _ => Box::pin(async move { IpcMessage::Error { message: "Invalid message".to_string() } }), } } @@ -196,8 +648,10 @@ async fn main() -> Result<()> { let provider_watch = provider.clone(); let event_bus_watch = event_bus.clone(); let database_watch = database.clone(); + let encryption_watch = encryption.clone(); tokio::spawn(async move { let mut last_hash = String::new(); + let mut is_startup = true; // suppress notification for the initial clipboard snapshot let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500)); loop { @@ -207,25 +661,80 @@ async fn main() -> Result<()> { match provider.get_content().await { Ok(item) => { if item.hash != last_hash { + let is_new_capture = !is_startup; last_hash = item.hash.clone(); + is_startup = false; + + if !is_new_capture { + // First tick — just record current hash, no save, no notification + continue; + } + info!("Clipboard changed: hash={}", item.hash); // Publish clipboard change event let event = Event::ClipboardAdded { id: item.id }; let _ = event_bus_watch.publish(event); - // Save to database + // Encrypt if encryption is enabled + let (content_to_store, nonce, encrypted) = { + let encryption_guard = encryption_watch.read().await; + if let Some(ref enc) = *encryption_guard { + match enc.encrypt(&item.content) { + Ok(encrypted_data) => (encrypted_data.ciphertext, Some(encrypted_data.nonce), true), + Err(e) => { + error!("Encryption failed: {}, storing unencrypted", e); + (item.content.clone(), None, false) + } + } + } else { + (item.content.clone(), None, false) + } + }; + + // Save to database — silently skip duplicates (UNIQUE constraint on hash) let db_item = clipboard_db::models::ClipboardItem { - id: 0, // Will be auto-assigned + id: 0, content_type: item.content_type.to_string(), - content: item.content.clone(), + content: content_to_store, hash: item.hash, created_at: item.created_at, accessed_at: item.accessed_at, pinned: item.pinned, favorite: item.favorite, + nonce, + encrypted, + }; + // Send notification if enabled + let show_notifications = match database_watch.get_setting("show_notifications").await { + Ok(Some(val)) => val.parse::().unwrap_or(true), + _ => true, }; - let _ = database_watch.insert_item(&db_item).await; + if show_notifications { + let preview: String = if item.content_type.to_string() == "image" { + "📷 Image captured".to_string() + } else { + let text = String::from_utf8_lossy(&item.content); + let truncated = text.chars().take(60).collect::(); + if text.len() > 60 { format!("{}…", truncated) } else { truncated } + }; + let _ = notify_rust::Notification::new() + .summary("OpenPaste") + .body(&preview) + .timeout(notify_rust::Timeout::Milliseconds(2500)) + .show(); + } + + match database_watch.insert_item(&db_item).await { + Ok(_) => {} + Err(e) => { + let msg = e.to_string(); + // UNIQUE constraint = duplicate, not a real error + if !msg.contains("UNIQUE") { + error!("Failed to save clipboard item: {}", msg); + } + } + } } } Err(e) => { @@ -235,6 +744,45 @@ async fn main() -> Result<()> { } }); + // Start periodic cleanup task + let database_cleanup = database.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3600)); // Run every hour + + loop { + interval.tick().await; + + // Load retention settings + let max_items = match database_cleanup.get_setting("max_items").await { + Ok(Some(val)) => val.parse::().unwrap_or(10000), + _ => 10000, + }; + let retention_days = match database_cleanup.get_setting("retention_days").await { + Ok(Some(val)) => val.parse::().unwrap_or(90), + _ => 90, + }; + + // Run cleanup + match database_cleanup.cleanup_old_items(retention_days).await { + Ok(deleted) => { + if deleted > 0 { + info!("Cleaned up {} items older than {} days", deleted, retention_days); + } + } + Err(e) => error!("Failed to cleanup old items: {}", e), + } + + match database_cleanup.enforce_max_items(max_items).await { + Ok(deleted) => { + if deleted > 0 { + info!("Deleted {} items to enforce max items limit of {}", deleted, max_items); + } + } + Err(e) => error!("Failed to enforce max items: {}", e), + } + } + }); + // Setup graceful shutdown let shutdown_signal = async { signal::ctrl_c() diff --git a/crates/clipboard-db/migrations/001_initial.sql b/crates/clipboard-db/migrations/001_initial.sql index 788d959..a10c250 100644 --- a/crates/clipboard-db/migrations/001_initial.sql +++ b/crates/clipboard-db/migrations/001_initial.sql @@ -9,7 +9,9 @@ CREATE TABLE IF NOT EXISTS clipboard_items ( created_at TEXT NOT NULL, accessed_at TEXT, pinned INTEGER NOT NULL DEFAULT 0, - favorite INTEGER NOT NULL DEFAULT 0 + favorite INTEGER NOT NULL DEFAULT 0, + nonce BLOB, + encrypted INTEGER NOT NULL DEFAULT 0 ); -- FTS5 virtual table for full-text search @@ -63,6 +65,7 @@ CREATE TABLE IF NOT EXISTS clipboard_item_tags ( CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, + value_type TEXT NOT NULL DEFAULT 'string', updated_at TEXT NOT NULL ); diff --git a/crates/clipboard-db/src/database.rs b/crates/clipboard-db/src/database.rs index 05c1b25..389eeeb 100644 --- a/crates/clipboard-db/src/database.rs +++ b/crates/clipboard-db/src/database.rs @@ -51,8 +51,8 @@ impl Database { pub async fn insert_item(&self, item: &ClipboardItem) -> Result { let result = sqlx::query( r#" - INSERT INTO clipboard_items (content_type, content, hash, created_at, accessed_at, pinned, favorite) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + INSERT INTO clipboard_items (content_type, content, hash, created_at, accessed_at, pinned, favorite, nonce, encrypted) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) "#, ) .bind(&item.content_type) @@ -62,6 +62,8 @@ impl Database { .bind(item.accessed_at) .bind(item.pinned) .bind(item.favorite) + .bind(&item.nonce) + .bind(item.encrypted) .execute(&self.pool) .await .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; @@ -72,7 +74,7 @@ impl Database { /// Get a clipboard item by ID pub async fn get_item(&self, id: i64) -> Result { let item = sqlx::query_as::<_, ClipboardItem>( - "SELECT id, content_type, content, hash, created_at, accessed_at, pinned, favorite FROM clipboard_items WHERE id = ?" + "SELECT id, content_type, content, hash, created_at, accessed_at, pinned, favorite, nonce, encrypted FROM clipboard_items WHERE id = ?" ) .bind(id) .fetch_optional(&self.pool) @@ -90,7 +92,7 @@ impl Database { offset: usize, ) -> Result, DatabaseError> { let items = sqlx::query_as::<_, ClipboardItem>( - "SELECT id, content_type, content, hash, created_at, accessed_at, pinned, favorite FROM clipboard_items ORDER BY created_at DESC LIMIT ? OFFSET ?" + "SELECT id, content_type, content, hash, created_at, accessed_at, pinned, favorite, nonce, encrypted FROM clipboard_items ORDER BY created_at DESC LIMIT ? OFFSET ?" ) .bind(limit as i64) .bind(offset as i64) @@ -143,7 +145,7 @@ impl Database { ) -> Result, DatabaseError> { let items = sqlx::query_as::<_, ClipboardItem>( r#" - SELECT ci.id, ci.content_type, ci.content, ci.hash, ci.created_at, ci.accessed_at, ci.pinned, ci.favorite + SELECT ci.id, ci.content_type, ci.content, ci.hash, ci.created_at, ci.accessed_at, ci.pinned, ci.favorite, ci.nonce, ci.encrypted FROM clipboard_items ci INNER JOIN clipboard_items_fts fts ON ci.id = fts.rowid WHERE clipboard_items_fts MATCH ? @@ -160,4 +162,406 @@ impl Database { Ok(items) } + + /// Get a setting value by key + pub async fn get_setting(&self, key: &str) -> Result, DatabaseError> { + let result = sqlx::query_as::<_, (String,)>( + "SELECT value FROM settings WHERE key = ?" + ) + .bind(key) + .fetch_optional(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + + Ok(result.map(|r| r.0)) + } + + /// Upsert a setting value + pub async fn upsert_setting(&self, key: &str, value: &str) -> Result<(), DatabaseError> { + sqlx::query( + r#" + INSERT INTO settings (key, value, value_type, updated_at) + VALUES (?1, ?2, 'string', strftime('%s', 'now') * 1000) + ON CONFLICT(key) DO UPDATE SET + value = ?2, + updated_at = strftime('%s', 'now') * 1000 + "# + ) + .bind(key) + .bind(value) + .execute(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + + Ok(()) + } + + /// Delete items older than retention days + pub async fn cleanup_old_items(&self, retention_days: i32) -> Result { + let cutoff_time = chrono::Utc::now() - chrono::Duration::days(retention_days as i64); + let cutoff_str = cutoff_time.to_rfc3339(); + + let result = sqlx::query( + "DELETE FROM clipboard_items WHERE created_at < ?" + ) + .bind(cutoff_str) + .execute(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + + Ok(result.rows_affected()) + } + + /// Enforce max items limit by deleting oldest items + pub async fn enforce_max_items(&self, max_items: i32) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM clipboard_items") + .fetch_one(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + + let max_items_i64 = max_items as i64; + + if count <= max_items_i64 { + return Ok(0); + } + + let to_delete = count - max_items_i64; + + let result = sqlx::query( + r#" + DELETE FROM clipboard_items + WHERE id IN ( + SELECT id FROM clipboard_items + ORDER BY created_at ASC + LIMIT ? + ) + "# + ) + .bind(to_delete) + .execute(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + + Ok(result.rows_affected()) + } + + // ── Tag operations ──────────────────────────────────────────────────────── + + /// List all tags + pub async fn list_tags(&self) -> Result, DatabaseError> { + let tags = sqlx::query_as::<_, crate::models::Tag>( + "SELECT id, name, color FROM tags ORDER BY name ASC" + ) + .fetch_all(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + Ok(tags) + } + + /// Create a tag (upsert by name) + pub async fn create_tag(&self, name: &str, color: Option<&str>) -> Result { + let result = sqlx::query( + "INSERT INTO tags (name, color) VALUES (?1, ?2) ON CONFLICT(name) DO UPDATE SET color = ?2" + ) + .bind(name) + .bind(color) + .execute(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + Ok(result.last_insert_rowid()) + } + + /// Delete a tag and all its assignments + pub async fn delete_tag(&self, id: i64) -> Result<(), DatabaseError> { + sqlx::query("DELETE FROM tags WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + Ok(()) + } + + /// Get tags assigned to a clipboard item + pub async fn get_item_tags(&self, item_id: i64) -> Result, DatabaseError> { + let tags = sqlx::query_as::<_, crate::models::Tag>( + r#" + SELECT t.id, t.name, t.color + FROM tags t + INNER JOIN clipboard_item_tags cit ON t.id = cit.tag_id + WHERE cit.clipboard_item_id = ? + ORDER BY t.name ASC + "# + ) + .bind(item_id) + .fetch_all(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + Ok(tags) + } + + /// Add a tag to a clipboard item (no-op if already assigned) + pub async fn add_tag_to_item(&self, item_id: i64, tag_id: i64) -> Result<(), DatabaseError> { + sqlx::query( + "INSERT OR IGNORE INTO clipboard_item_tags (clipboard_item_id, tag_id) VALUES (?1, ?2)" + ) + .bind(item_id) + .bind(tag_id) + .execute(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + Ok(()) + } + + /// Remove a tag from a clipboard item + pub async fn remove_tag_from_item(&self, item_id: i64, tag_id: i64) -> Result<(), DatabaseError> { + sqlx::query( + "DELETE FROM clipboard_item_tags WHERE clipboard_item_id = ?1 AND tag_id = ?2" + ) + .bind(item_id) + .bind(tag_id) + .execute(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + Ok(()) + } + + /// List clipboard items that have a given tag + pub async fn list_items_by_tag( + &self, + tag_id: i64, + limit: usize, + offset: usize, + ) -> Result, DatabaseError> { + let items = sqlx::query_as::<_, ClipboardItem>( + r#" + SELECT ci.id, ci.content_type, ci.content, ci.hash, ci.created_at, + ci.accessed_at, ci.pinned, ci.favorite, ci.nonce, ci.encrypted + FROM clipboard_items ci + INNER JOIN clipboard_item_tags cit ON ci.id = cit.clipboard_item_id + WHERE cit.tag_id = ? + ORDER BY ci.created_at DESC + LIMIT ? OFFSET ? + "# + ) + .bind(tag_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await + .map_err(|e| DatabaseError::QueryFailed(e.to_string()))?; + Ok(items) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::ClipboardItem; + use chrono::Utc; + + fn make_item(content: &str, content_type: &str) -> ClipboardItem { + let content_bytes = content.as_bytes().to_vec(); + // Simple hash: hex of a basic checksum, sufficient for test uniqueness + let hash = format!("{:016x}", content_bytes.iter().fold(0u64, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u64))); + ClipboardItem { + id: 0, + content_type: content_type.to_string(), + content: content_bytes, + hash, + created_at: Utc::now(), + accessed_at: None, + pinned: false, + favorite: false, + nonce: None, + encrypted: false, + } + } + + #[tokio::test] + async fn test_insert_and_list() { + let db = Database::in_memory().await.unwrap(); + let item = make_item("hello world", "text"); + let id = db.insert_item(&item).await.unwrap(); + assert!(id > 0); + + let items = db.list_items(10, 0).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].content, b"hello world"); + assert_eq!(items[0].content_type, "text"); + } + + #[tokio::test] + async fn test_get_item() { + let db = Database::in_memory().await.unwrap(); + let id = db.insert_item(&make_item("get me", "text")).await.unwrap(); + let item = db.get_item(id).await.unwrap(); + assert_eq!(item.id, id); + assert_eq!(item.content, b"get me"); + } + + #[tokio::test] + async fn test_delete_item() { + let db = Database::in_memory().await.unwrap(); + let id = db.insert_item(&make_item("delete me", "text")).await.unwrap(); + db.delete_item(id).await.unwrap(); + let items = db.list_items(10, 0).await.unwrap(); + assert!(items.iter().all(|i| i.id != id)); + } + + #[tokio::test] + async fn test_toggle_pin_and_favorite() { + let db = Database::in_memory().await.unwrap(); + let id = db.insert_item(&make_item("pin me", "text")).await.unwrap(); + + db.toggle_pin(id).await.unwrap(); + let item = db.get_item(id).await.unwrap(); + assert!(item.pinned); + + db.toggle_pin(id).await.unwrap(); + let item = db.get_item(id).await.unwrap(); + assert!(!item.pinned); + + db.toggle_favorite(id).await.unwrap(); + let item = db.get_item(id).await.unwrap(); + assert!(item.favorite); + } + + #[tokio::test] + async fn test_duplicate_hash_rejected() { + let db = Database::in_memory().await.unwrap(); + let item = make_item("unique content", "text"); + db.insert_item(&item).await.unwrap(); + // Second insert with same hash should fail with UNIQUE constraint + let result = db.insert_item(&item).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("UNIQUE")); + } + + #[tokio::test] + async fn test_settings_upsert_and_get() { + let db = Database::in_memory().await.unwrap(); + db.upsert_setting("test_key", "value1").await.unwrap(); + let val = db.get_setting("test_key").await.unwrap(); + assert_eq!(val, Some("value1".to_string())); + + // Upsert again — should overwrite + db.upsert_setting("test_key", "value2").await.unwrap(); + let val = db.get_setting("test_key").await.unwrap(); + assert_eq!(val, Some("value2".to_string())); + } + + #[tokio::test] + async fn test_get_missing_setting_returns_none() { + let db = Database::in_memory().await.unwrap(); + let val = db.get_setting("nonexistent").await.unwrap(); + assert_eq!(val, None); + } + + #[tokio::test] + async fn test_tag_crud() { + let db = Database::in_memory().await.unwrap(); + + // Create tag + db.create_tag("work", Some("#ff0000")).await.unwrap(); + let tags = db.list_tags().await.unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name, "work"); + assert_eq!(tags[0].color.as_deref(), Some("#ff0000")); + + // Upsert same name — should not duplicate + db.create_tag("work", Some("#00ff00")).await.unwrap(); + let tags = db.list_tags().await.unwrap(); + assert_eq!(tags.len(), 1); + + // Delete + let tag_id = tags[0].id; + db.delete_tag(tag_id).await.unwrap(); + let tags = db.list_tags().await.unwrap(); + assert!(tags.is_empty()); + } + + #[tokio::test] + async fn test_item_tags() { + let db = Database::in_memory().await.unwrap(); + let item_id = db.insert_item(&make_item("tagged item", "text")).await.unwrap(); + + db.create_tag("personal", None).await.unwrap(); + let tags = db.list_tags().await.unwrap(); + let tag_id = tags[0].id; + + db.add_tag_to_item(item_id, tag_id).await.unwrap(); + let item_tags = db.get_item_tags(item_id).await.unwrap(); + assert_eq!(item_tags.len(), 1); + assert_eq!(item_tags[0].name, "personal"); + + db.remove_tag_from_item(item_id, tag_id).await.unwrap(); + let item_tags = db.get_item_tags(item_id).await.unwrap(); + assert!(item_tags.is_empty()); + } + + #[tokio::test] + async fn test_list_items_by_tag() { + let db = Database::in_memory().await.unwrap(); + let id1 = db.insert_item(&make_item("item one", "text")).await.unwrap(); + let _id2 = db.insert_item(&make_item("item two", "text")).await.unwrap(); + + db.create_tag("filtered", None).await.unwrap(); + let tags = db.list_tags().await.unwrap(); + let tag_id = tags[0].id; + + db.add_tag_to_item(id1, tag_id).await.unwrap(); + let results = db.list_items_by_tag(tag_id, 10, 0).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, id1); + } + + #[tokio::test] + async fn test_cleanup_old_items() { + let db = Database::in_memory().await.unwrap(); + // Insert an item with a very old timestamp + let old_item = ClipboardItem { + id: 0, + content_type: "text".to_string(), + content: b"old".to_vec(), + hash: "oldhash123".to_string(), + created_at: Utc::now() - chrono::Duration::days(100), + accessed_at: None, + pinned: false, + favorite: false, + nonce: None, + encrypted: false, + }; + db.insert_item(&old_item).await.unwrap(); + let new_id = db.insert_item(&make_item("new item", "text")).await.unwrap(); + + let deleted = db.cleanup_old_items(30).await.unwrap(); + assert_eq!(deleted, 1); + + let items = db.list_items(10, 0).await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, new_id); + } + + #[tokio::test] + async fn test_enforce_max_items() { + let db = Database::in_memory().await.unwrap(); + for i in 0..5 { + db.insert_item(&make_item(&format!("item {}", i), "text")).await.unwrap(); + } + let deleted = db.enforce_max_items(3).await.unwrap(); + assert_eq!(deleted, 2); + let items = db.list_items(10, 0).await.unwrap(); + assert_eq!(items.len(), 3); + } + + #[tokio::test] + async fn test_search_items() { + let db = Database::in_memory().await.unwrap(); + db.insert_item(&make_item("the quick brown fox", "text")).await.unwrap(); + db.insert_item(&make_item("hello world", "text")).await.unwrap(); + + let results = db.search_items("quick", 10, 0).await.unwrap(); + assert_eq!(results.len(), 1); + assert!(String::from_utf8_lossy(&results[0].content).contains("quick")); + } } diff --git a/crates/clipboard-db/src/models.rs b/crates/clipboard-db/src/models.rs index 75c18cd..fc02f20 100644 --- a/crates/clipboard-db/src/models.rs +++ b/crates/clipboard-db/src/models.rs @@ -4,7 +4,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; -/// Clipboard item database model +/// Tag model +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Tag { + pub id: i64, + pub name: String, + pub color: Option, +} #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct ClipboardItem { pub id: i64, @@ -15,4 +21,6 @@ pub struct ClipboardItem { pub accessed_at: Option>, pub pinned: bool, pub favorite: bool, + pub nonce: Option>, + pub encrypted: bool, } diff --git a/crates/clipboard-encryption/src/encryption.rs b/crates/clipboard-encryption/src/encryption.rs index 15e6bfd..543b3db 100644 --- a/crates/clipboard-encryption/src/encryption.rs +++ b/crates/clipboard-encryption/src/encryption.rs @@ -51,3 +51,77 @@ impl Encryption { .map_err(|e: aes_gcm::aead::Error| EncryptionError::DecryptionFailed(e.to_string())) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> [u8; 32] { + let mut key = [0u8; 32]; + for (i, b) in key.iter_mut().enumerate() { + *b = i as u8; + } + key + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let enc = Encryption::new(test_key()); + let plaintext = b"hello, OpenPaste!"; + let encrypted = enc.encrypt(plaintext).unwrap(); + assert!(!encrypted.ciphertext.is_empty()); + assert_eq!(encrypted.nonce.len(), 12); // AES-GCM nonce is 12 bytes + let decrypted = enc.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_encrypt_produces_different_ciphertexts() { + // Each call should produce a different nonce → different ciphertext + let enc = Encryption::new(test_key()); + let plaintext = b"same plaintext"; + let e1 = enc.encrypt(plaintext).unwrap(); + let e2 = enc.encrypt(plaintext).unwrap(); + // Nonces should differ (probabilistically guaranteed) + assert_ne!(e1.nonce, e2.nonce); + } + + #[test] + fn test_decrypt_with_wrong_key_fails() { + let enc = Encryption::new(test_key()); + let encrypted = enc.encrypt(b"secret").unwrap(); + + let mut wrong_key = test_key(); + wrong_key[0] ^= 0xff; + let enc2 = Encryption::new(wrong_key); + assert!(enc2.decrypt(&encrypted).is_err()); + } + + #[test] + fn test_decrypt_with_tampered_ciphertext_fails() { + let enc = Encryption::new(test_key()); + let mut encrypted = enc.encrypt(b"integrity check").unwrap(); + // Flip a byte in the ciphertext — AEAD should detect this + if let Some(b) = encrypted.ciphertext.last_mut() { + *b ^= 0x01; + } + assert!(enc.decrypt(&encrypted).is_err()); + } + + #[test] + fn test_empty_plaintext() { + let enc = Encryption::new(test_key()); + let encrypted = enc.encrypt(b"").unwrap(); + let decrypted = enc.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, b""); + } + + #[test] + fn test_large_plaintext() { + let enc = Encryption::new(test_key()); + let plaintext = vec![0xABu8; 1024 * 1024]; // 1 MB + let encrypted = enc.encrypt(&plaintext).unwrap(); + let decrypted = enc.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } +} diff --git a/crates/clipboard-encryption/src/key_derivation.rs b/crates/clipboard-encryption/src/key_derivation.rs index 0ec176b..1568b82 100644 --- a/crates/clipboard-encryption/src/key_derivation.rs +++ b/crates/clipboard-encryption/src/key_derivation.rs @@ -79,3 +79,62 @@ impl KeyDerivation { .map_err(|_| EncryptionError::InvalidPassword) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_derive_key_is_deterministic() { + let kd = KeyDerivation::new(); + let salt = KeyDerivation::generate_salt(); + let key1 = kd.derive_key("password123", &salt).unwrap(); + let key2 = kd.derive_key("password123", &salt).unwrap(); + assert_eq!(key1, key2); + } + + #[test] + fn test_derive_key_differs_for_different_passwords() { + let kd = KeyDerivation::new(); + let salt = KeyDerivation::generate_salt(); + let key1 = kd.derive_key("password123", &salt).unwrap(); + let key2 = kd.derive_key("different!", &salt).unwrap(); + assert_ne!(key1, key2); + } + + #[test] + fn test_derive_key_differs_for_different_salts() { + let kd = KeyDerivation::new(); + let salt1 = KeyDerivation::generate_salt(); + let salt2 = KeyDerivation::generate_salt(); + let key1 = kd.derive_key("password", &salt1).unwrap(); + let key2 = kd.derive_key("password", &salt2).unwrap(); + assert_ne!(key1, key2); + } + + #[test] + fn test_hash_and_verify_password() { + let kd = KeyDerivation::new(); + let hash = kd.hash_password("my_secure_password").unwrap(); + assert!(!hash.is_empty()); + // Correct password verifies + assert!(kd.verify("my_secure_password", &hash).unwrap()); + } + + #[test] + fn test_wrong_password_fails_verification() { + let kd = KeyDerivation::new(); + let hash = kd.hash_password("correct_password").unwrap(); + let result = kd.verify("wrong_password", &hash); + // Should return Err(InvalidPassword) + assert!(result.is_err()); + } + + #[test] + fn test_derived_key_is_32_bytes() { + let kd = KeyDerivation::new(); + let salt = KeyDerivation::generate_salt(); + let key = kd.derive_key("anything", &salt).unwrap(); + assert_eq!(key.len(), 32); + } +} diff --git a/crates/clipboard-ipc/src/ipc.rs b/crates/clipboard-ipc/src/ipc.rs index c2a632f..28d96da 100644 --- a/crates/clipboard-ipc/src/ipc.rs +++ b/crates/clipboard-ipc/src/ipc.rs @@ -21,20 +21,103 @@ pub enum IpcMessage { TogglePin { id: i64 }, /// Toggle favorite status ToggleFavorite { id: i64 }, + /// Get settings + GetSettings, + /// Save settings + SaveSettings { settings: AppSettings }, + /// Set master password + SetMasterPassword { password: String }, + /// Unlock vault with password + UnlockVault { password: String }, + /// Lock vault + LockVault, + /// Check if vault is locked + CheckVaultStatus, + /// Check if a master password has been configured + HasMasterPassword, + // ── Tag operations ──────────────────────────────────────────────────────── + /// List all tags + ListTags, + /// Create or upsert a tag + CreateTag { name: String, color: Option }, + /// Delete a tag by id + DeleteTag { id: i64 }, + /// Get tags for a clipboard item + GetItemTags { item_id: i64 }, + /// Add a tag to a clipboard item (creates tag if needed) + AddTagToItem { item_id: i64, tag_name: String, color: Option }, + /// Remove a tag from a clipboard item + RemoveTagFromItem { item_id: i64, tag_id: i64 }, + /// List items with a given tag + ListItemsByTag { tag_id: i64 }, + // ── Sync operations ─────────────────────────────────────────────────────── + /// Get sync configuration (server_url, device_id, enabled) + GetSyncConfig, + /// Save sync configuration + SetSyncConfig { server_url: String, api_token: Option, enabled: bool }, + /// Trigger an immediate sync cycle + SyncNow, + // ── Plugin operations ───────────────────────────────────────────────────── + /// List loaded plugins + ListPlugins, + /// Load a plugin from an absolute path + LoadPlugin { path: String }, + /// Unload a plugin by name + UnloadPlugin { name: String }, /// Clipboard content response ClipboardContent { content: Vec }, /// Clipboard history response ClipboardHistory { items: Vec }, + /// Settings response + Settings { settings: AppSettings }, + /// Vault status response + VaultStatus { is_locked: bool }, + /// Whether a master password has been configured + PasswordConfigured { has_password: bool }, + /// Tags list response + TagsList { tags: Vec }, + /// Tags for a single item + ItemTags { tags: Vec }, + /// Sync configuration response + SyncConfig { server_url: String, api_token: Option, enabled: bool, last_sync_at: Option }, + /// Plugin list response + PluginList { plugins: Vec }, /// Success response Success, /// Error response Error { message: String }, } +/// Application settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + pub encryption_enabled: bool, + pub auto_lock_minutes: i32, + pub max_items: i32, + pub retention_days: i32, + pub refresh_interval: i32, + pub show_notifications: bool, +} + +/// Tag item for IPC +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TagItem { + pub id: i64, + pub name: String, + pub color: Option, +} + +/// Plugin info for IPC +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginInfoItem { + pub name: String, + pub path: String, + pub enabled: bool, +} + /// Clipboard history item for IPC #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClipboardHistoryItem { - pub id: String, +pub struct ClipboardHistoryItem { pub id: String, pub content_type: String, pub content: Vec, pub hash: String, @@ -42,6 +125,8 @@ pub struct ClipboardHistoryItem { pub accessed_at: Option, pub pinned: bool, pub favorite: bool, + pub nonce: Option>, + pub encrypted: bool, } /// IPC server diff --git a/crates/clipboard-ipc/src/lib.rs b/crates/clipboard-ipc/src/lib.rs index 9ccda1f..aa92995 100644 --- a/crates/clipboard-ipc/src/lib.rs +++ b/crates/clipboard-ipc/src/lib.rs @@ -4,4 +4,4 @@ pub mod error; pub mod ipc; pub use error::IpcError; -pub use ipc::{ClipboardHistoryItem, IpcClient, IpcMessage, IpcServer}; +pub use ipc::{AppSettings, ClipboardHistoryItem, IpcClient, IpcMessage, IpcServer, PluginInfoItem, TagItem}; diff --git a/crates/clipboard-platform/Cargo.toml b/crates/clipboard-platform/Cargo.toml index 820c599..67e5345 100644 --- a/crates/clipboard-platform/Cargo.toml +++ b/crates/clipboard-platform/Cargo.toml @@ -17,6 +17,7 @@ tracing = { workspace = true } clipboard-core = { path = "../clipboard-core" } async-trait = "0.1" arboard = "3.3" +image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(windows)'.dependencies] clipboard-win = "4.5" diff --git a/crates/clipboard-platform/src/provider.rs b/crates/clipboard-platform/src/provider.rs index 03a6c33..3a9baea 100644 --- a/crates/clipboard-platform/src/provider.rs +++ b/crates/clipboard-platform/src/provider.rs @@ -2,12 +2,12 @@ use crate::PlatformError; use async_trait::async_trait; -use clipboard_core::ClipboardItem; +use clipboard_core::{ClipboardItem, ContentType}; /// Platform-agnostic clipboard provider trait #[async_trait] pub trait ClipboardProvider: Send + Sync { - /// Get clipboard content + /// Get clipboard content — tries image first, then text async fn get_content(&self) -> Result; /// Set clipboard content @@ -17,231 +17,218 @@ pub trait ClipboardProvider: Send + Sync { async fn watch_changes(&self) -> Result<(), PlatformError>; } -/// Platform-specific provider enum -#[derive(Clone)] -pub enum PlatformProvider { - #[cfg(windows)] - Windows(windows::WindowsProvider), - #[cfg(target_os = "linux")] - Linux(linux::LinuxProvider), - #[cfg(target_os = "macos")] - Macos(macos::MacosProvider), +// ── Shared helpers ──────────────────────────────────────────────────────────── + +/// Encode raw RGBA pixels as a PNG byte vector. +fn rgba_to_png(width: usize, height: usize, bytes: &[u8]) -> Result, String> { + use image::{ImageBuffer, RgbaImage}; + let img: RgbaImage = ImageBuffer::from_raw(width as u32, height as u32, bytes.to_vec()) + .ok_or_else(|| "Invalid image dimensions".to_string())?; + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + Ok(buf.into_inner()) } -#[async_trait] -impl ClipboardProvider for PlatformProvider { - async fn get_content(&self) -> Result { - match self { - #[cfg(windows)] - PlatformProvider::Windows(p) => p.get_content().await, - #[cfg(target_os = "linux")] - PlatformProvider::Linux(p) => p.get_content().await, - #[cfg(target_os = "macos")] - PlatformProvider::Macos(p) => p.get_content().await, - #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] - _ => Err(PlatformError::UnsupportedPlatform), - } - } +/// Detect a richer content type from plain text. +fn detect_text_type(text: &str) -> ContentType { + let trimmed = text.trim(); - async fn set_content(&self, item: &ClipboardItem) -> Result<(), PlatformError> { - match self { - #[cfg(windows)] - PlatformProvider::Windows(p) => p.set_content(item).await, - #[cfg(target_os = "linux")] - PlatformProvider::Linux(p) => p.set_content(item).await, - #[cfg(target_os = "macos")] - PlatformProvider::Macos(p) => p.set_content(item).await, - #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] - _ => Err(PlatformError::UnsupportedPlatform), - } - } - - async fn watch_changes(&self) -> Result<(), PlatformError> { - match self { - #[cfg(windows)] - PlatformProvider::Windows(p) => p.watch_changes().await, - #[cfg(target_os = "linux")] - PlatformProvider::Linux(p) => p.watch_changes().await, - #[cfg(target_os = "macos")] - PlatformProvider::Macos(p) => p.watch_changes().await, - #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] - _ => Err(PlatformError::UnsupportedPlatform), - } - } -} - -/// Get the appropriate clipboard provider for the current platform -pub fn get_provider() -> Result { - #[cfg(windows)] + // HTML + if trimmed.starts_with("(trimmed).is_ok() { + return ContentType::Code; + } } - #[cfg(target_os = "macos")] - { - Ok(PlatformProvider::Macos(macos::MacosProvider::new())) + // Code heuristics + let code_indicators = [ + "fn ", "def ", "class ", "import ", "use ", "const ", "let ", "var ", + "function ", "return ", "if (", "for (", "while (", "#include", "public ", + "private ", "async ", "await ", "=>", "->", "::", "$(", "#!/", + ]; + let code_score: usize = code_indicators + .iter() + .filter(|&&pat| trimmed.contains(pat)) + .count(); + let lines: Vec<&str> = trimmed.lines().collect(); + let indented_lines = lines + .iter() + .filter(|l| l.starts_with(" ") || l.starts_with('\t')) + .count(); + if code_score >= 2 || (lines.len() > 3 && indented_lines > 1) { + return ContentType::Code; } - #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] - { - Err(PlatformError::UnsupportedPlatform) - } + ContentType::Text } -#[cfg(windows)] -mod windows { - use super::*; - use arboard::Clipboard; - use clipboard_core::{ClipboardItem, ContentType}; - - #[derive(Clone)] - pub struct WindowsProvider; - - impl WindowsProvider { - pub fn new() -> Self { - Self +/// On macOS, check whether the pasteboard currently advertises an image type, +/// without actually reading the image data. This avoids the ~50 ms arboard +/// penalty on every poll when the clipboard contains only text. +/// +/// Returns `true` → image type is present, go ahead and call `get_image()`. +/// Returns `false` → skip image attempt entirely. +#[cfg(target_os = "macos")] +#[allow(unexpected_cfgs)] +fn pasteboard_has_image() -> bool { + // Use the Objective-C runtime to call + // [[NSPasteboard generalPasteboard] canReadObjectForClasses:...] or + // more simply check the types array for common image UTIs. + // + // We do this via a tiny inline Objective-C call using the `objc` crate + // that is already a transitive dependency. + use std::ffi::CStr; + unsafe { + use objc::runtime::{Class, Object}; + use objc::{msg_send, sel, sel_impl}; + + let cls = match Class::get("NSPasteboard") { + Some(c) => c, + None => return false, + }; + let pb: *mut Object = msg_send![cls, generalPasteboard]; + if pb.is_null() { + return false; } - } - - #[async_trait] - impl ClipboardProvider for WindowsProvider { - async fn get_content(&self) -> Result { - let mut ctx = - Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - - let text = ctx - .get_text() - .map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - - let content_bytes = text.into_bytes(); - let content_type = ContentType::detect(&content_bytes); - - Ok(ClipboardItem::new(content_type, content_bytes)) + let types: *mut Object = msg_send![pb, types]; + if types.is_null() { + return false; } - - async fn set_content(&self, item: &ClipboardItem) -> Result<(), PlatformError> { - if item.content_type == ContentType::Text { - let text = String::from_utf8_lossy(&item.content).to_string(); - let mut ctx = - Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - - ctx.set_text(&text) - .map_err(|e| PlatformError::AccessFailed(e.to_string()))?; + let count: usize = msg_send![types, count]; + for i in 0..count { + let type_str: *mut Object = msg_send![types, objectAtIndex: i]; + if type_str.is_null() { + continue; + } + let utf8: *const std::os::raw::c_char = msg_send![type_str, UTF8String]; + if utf8.is_null() { + continue; + } + let s = CStr::from_ptr(utf8).to_string_lossy(); + // Common image UTIs advertised by macOS apps + if s.contains("image") + || s == "public.png" + || s == "public.jpeg" + || s == "public.tiff" + || s == "com.apple.pict" + { + return true; } - // TODO: Handle other content types - Ok(()) - } - - async fn watch_changes(&self) -> Result<(), PlatformError> { - // TODO: Implement Windows clipboard watching using clipboard format listeners - Err(PlatformError::WatchFailed("Not implemented".to_string())) } + false } } -#[cfg(target_os = "linux")] -mod linux { - use super::*; - use arboard::Clipboard; - use clipboard_core::{ClipboardItem, ContentType}; - - #[derive(Clone)] - pub struct LinuxProvider; - - impl LinuxProvider { - pub fn new() -> Self { - Self - } - } - - #[async_trait] - impl ClipboardProvider for LinuxProvider { - async fn get_content(&self) -> Result { - let mut ctx = - Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - - let text = ctx - .get_text() - .map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - - let content_bytes = text.into_bytes(); - let content_type = ContentType::detect(&content_bytes); - - Ok(ClipboardItem::new(content_type, content_bytes)) - } - - async fn set_content(&self, item: &ClipboardItem) -> Result<(), PlatformError> { - if item.content_type == ContentType::Text { - let text = String::from_utf8_lossy(&item.content).to_string(); - let mut ctx = - Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - - ctx.set_text(&text) - .map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - } - // TODO: Handle other content types - Ok(()) - } +#[cfg(not(target_os = "macos"))] +fn pasteboard_has_image() -> bool { + // On Linux/Windows we always attempt get_image(); arboard is fast there. + true +} - async fn watch_changes(&self) -> Result<(), PlatformError> { - // TODO: Implement clipboard watching using wl-clipboard or x11 events - Err(PlatformError::WatchFailed("Not implemented".to_string())) - } +/// Try to read image from clipboard, encode as PNG. +fn try_get_image() -> Option> { + if !pasteboard_has_image() { + return None; } + let mut ctx = arboard::Clipboard::new().ok()?; + let img = ctx.get_image().ok()?; + rgba_to_png(img.width, img.height, &img.bytes).ok() } -#[cfg(target_os = "macos")] -mod macos { - use super::*; - use arboard::Clipboard; - use clipboard_core::{ClipboardItem, ContentType}; - - #[derive(Clone)] - pub struct MacosProvider; +/// Read text from clipboard. +fn try_get_text() -> Option { + let mut ctx = arboard::Clipboard::new().ok()?; + let text = ctx.get_text().ok()?; + if text.is_empty() { None } else { Some(text) } +} - impl MacosProvider { - pub fn new() -> Self { - Self +/// Build a ClipboardItem from the current clipboard state. +/// Prefers images over text. +pub(crate) fn read_clipboard_item() -> Result { + if let Some(png_bytes) = try_get_image() { + return Ok(ClipboardItem::new(ContentType::Image, png_bytes)); + } + match try_get_text() { + Some(text) => { + let content_type = detect_text_type(&text); + Ok(ClipboardItem::new(content_type, text.into_bytes())) } + None => Err(PlatformError::AccessFailed("Clipboard is empty".to_string())), } +} - #[async_trait] - impl ClipboardProvider for MacosProvider { - async fn get_content(&self) -> Result { - let mut ctx = - Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?; - - let text = ctx - .get_text() - .map_err(|e| PlatformError::AccessFailed(e.to_string()))?; +/// Write text to the system clipboard. +pub(crate) fn write_text(text: &str) -> Result<(), PlatformError> { + let mut ctx = + arboard::Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?; + ctx.set_text(text) + .map_err(|e| PlatformError::AccessFailed(e.to_string())) +} - let content_bytes = text.into_bytes(); - let content_type = ContentType::detect(&content_bytes); +// ── Platform enum ───────────────────────────────────────────────────────────── - Ok(ClipboardItem::new(content_type, content_bytes)) - } +#[derive(Clone)] +pub enum PlatformProvider { + #[cfg(target_os = "macos")] + Macos, + #[cfg(target_os = "linux")] + Linux, + #[cfg(windows)] + Windows, +} - async fn set_content(&self, item: &ClipboardItem) -> Result<(), PlatformError> { - if item.content_type == ContentType::Text { - let text = String::from_utf8_lossy(&item.content).to_string(); - let mut ctx = - Clipboard::new().map_err(|e| PlatformError::AccessFailed(e.to_string()))?; +#[async_trait] +impl ClipboardProvider for PlatformProvider { + async fn get_content(&self) -> Result { + read_clipboard_item() + } - ctx.set_text(&text) + async fn set_content(&self, item: &ClipboardItem) -> Result<(), PlatformError> { + match item.content_type { + ContentType::Image => { + let img = image::load_from_memory(&item.content) + .map_err(|e| PlatformError::AccessFailed(e.to_string()))? + .to_rgba8(); + let (w, h) = img.dimensions(); + let mut ctx = arboard::Clipboard::new() .map_err(|e| PlatformError::AccessFailed(e.to_string()))?; + ctx.set_image(arboard::ImageData { + width: w as usize, + height: h as usize, + bytes: img.into_raw().into(), + }) + .map_err(|e| PlatformError::AccessFailed(e.to_string())) + } + _ => { + let text = String::from_utf8_lossy(&item.content).to_string(); + write_text(&text) } - // TODO: Handle other content types - Ok(()) } + } - async fn watch_changes(&self) -> Result<(), PlatformError> { - // TODO: Implement clipboard watching using NSPasteboard notifications - Err(PlatformError::WatchFailed("Not implemented".to_string())) - } + async fn watch_changes(&self) -> Result<(), PlatformError> { + Err(PlatformError::WatchFailed("Not implemented".to_string())) } } + +pub fn get_provider() -> Result { + #[cfg(target_os = "macos")] + return Ok(PlatformProvider::Macos); + #[cfg(target_os = "linux")] + return Ok(PlatformProvider::Linux); + #[cfg(windows)] + return Ok(PlatformProvider::Windows); + #[allow(unreachable_code)] + Err(PlatformError::UnsupportedPlatform) +} diff --git a/crates/clipboard-plugin/src/plugin.rs b/crates/clipboard-plugin/src/plugin.rs index 0e655a9..caacdf3 100644 --- a/crates/clipboard-plugin/src/plugin.rs +++ b/crates/clipboard-plugin/src/plugin.rs @@ -1,11 +1,45 @@ -//! Plugin manager implementation +//! Plugin manager — loads WASM plugins and applies them to clipboard items. +//! +//! Plugin contract (guest side): +//! - Export: `process_item(ptr: i32, len: i32) -> i32` +//! Receives a UTF-8 string (the clipboard text content) at [ptr..ptr+len] +//! in the guest's linear memory. +//! Return value: byte length of the result string written at ptr. +//! Return 0 or negative to leave the content unchanged. +//! +//! - The host provides one import: +//! `env::log(ptr: i32, len: i32)` — write a log message. +//! +//! Plugins receive ONLY plain-text content. Encrypted and image items are +//! skipped automatically. use crate::PluginError; -use std::path::Path; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tracing::{info, warn}; +use wasmtime::{Engine, Linker, Module, Store}; + +// ── Plugin record ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct PluginInfo { + pub name: String, + pub path: PathBuf, + pub enabled: bool, +} + +// ── Per-call state threaded through Store ───────────────────────────────────── + +struct HostState { + plugin_name: String, +} + +// ── Manager ─────────────────────────────────────────────────────────────────── -/// Plugin manager for loading and executing WASM plugins pub struct PluginManager { - // TODO: Add WASM runtime + engine: Engine, + plugins: Arc>>, } impl Default for PluginManager { @@ -15,25 +49,186 @@ impl Default for PluginManager { } impl PluginManager { - /// Create a new plugin manager pub fn new() -> Self { - Self {} + Self { + engine: Engine::default(), + plugins: Arc::new(Mutex::new(HashMap::new())), + } } - /// Load a plugin from a file - pub async fn load(&self, _path: &Path) -> Result { - // TODO: Implement plugin loading - Err(PluginError::LoadFailed("Not implemented".to_string())) + /// Load (or reload) a `.wasm` plugin from disk. + /// The plugin name is the file stem (e.g. `"dedupe"` for `dedupe.wasm`). + pub fn load(&self, path: &Path) -> Result { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| PluginError::LoadFailed("Invalid file name".to_string()))? + .to_string(); + + let module = Module::from_file(&self.engine, path) + .map_err(|e| PluginError::LoadFailed(e.to_string()))?; + + let info = PluginInfo { + name: name.clone(), + path: path.to_path_buf(), + enabled: true, + }; + + self.plugins + .lock() + .unwrap() + .insert(name.clone(), (info, module)); + + info!("Plugin loaded: {}", name); + Ok(name) } - /// Unload a plugin - pub async fn unload(&self, _name: &str) -> Result<(), PluginError> { - // TODO: Implement plugin unloading + /// Unload a plugin by name. + pub fn unload(&self, name: &str) -> Result<(), PluginError> { + self.plugins.lock().unwrap().remove(name); + info!("Plugin unloaded: {}", name); Ok(()) } - /// List loaded plugins - pub async fn list(&self) -> Result, PluginError> { - Ok(Vec::new()) + /// List all loaded plugins. + pub fn list(&self) -> Vec { + self.plugins + .lock() + .unwrap() + .values() + .map(|(info, _)| info.clone()) + .collect() + } + + /// Run all enabled plugins against a text clipboard item. + /// Returns the (possibly transformed) content. + /// If any plugin errors, it is logged and skipped — the original (or last + /// successful) content is passed to the next plugin. + pub fn process(&self, content: &str, content_type: &str) -> String { + // Only process plain text — skip images, encrypted items, etc. + if content_type != "text" && content_type != "code" && content_type != "html" { + return content.to_string(); + } + + let plugins: Vec<(String, Module)> = { + let guard = self.plugins.lock().unwrap(); + guard + .values() + .filter(|(info, _)| info.enabled) + .map(|(info, module)| (info.name.clone(), module.clone())) + .collect() + }; + + let mut current = content.to_string(); + + for (name, module) in plugins { + match self.run_plugin(&name, &module, ¤t) { + Ok(Some(transformed)) => { + info!("Plugin '{}' transformed content ({} → {} bytes)", name, current.len(), transformed.len()); + current = transformed; + } + Ok(None) => {} // plugin chose not to transform + Err(e) => { + warn!("Plugin '{}' failed: {}", name, e); + } + } + } + + current + } + + // ── Internal: run one plugin ────────────────────────────────────────────── + + fn run_plugin( + &self, + name: &str, + module: &Module, + content: &str, + ) -> Result, PluginError> { + let mut linker: Linker = Linker::new(&self.engine); + let plugin_name = name.to_string(); + + // Provide `env::log` import + // plugin_name is captured by reference via an Arc-like clone so the + // closure stays Fn (not FnOnce). + let plugin_name_log = plugin_name.to_string(); + linker + .func_wrap( + "env", + "log", + move |mut caller: wasmtime::Caller<'_, HostState>, ptr: i32, len: i32| { + let _ = &plugin_name_log; // keep capture alive without moving out + if let Some(mem) = caller.get_export("memory") { + if let Some(mem) = mem.into_memory() { + let data = mem.data(&caller); + let ptr = ptr as usize; + let len = len as usize; + if ptr + len <= data.len() { + if let Ok(msg) = std::str::from_utf8(&data[ptr..ptr + len]) { + info!("[plugin:{}] {}", caller.data().plugin_name, msg); + } + } + } + } + }, + ) + .map_err(|e| PluginError::ExecutionFailed(e.to_string()))?; + + let mut store = Store::new( + &self.engine, + HostState { + plugin_name: name.to_string(), + }, + ); + + let instance = linker + .instantiate(&mut store, module) + .map_err(|e| PluginError::ExecutionFailed(format!("instantiate: {}", e)))?; + + // Get memory export + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| PluginError::ExecutionFailed("No memory export".to_string()))?; + + // Get process_item export + let process_fn = instance + .get_typed_func::<(i32, i32), i32>(&mut store, "process_item") + .map_err(|e| PluginError::ExecutionFailed(format!("process_item: {}", e)))?; + + // Write content into guest memory at offset 0 + let content_bytes = content.as_bytes(); + if content_bytes.len() > 64 * 1024 { + return Err(PluginError::ExecutionFailed( + "Content too large for plugin (>64 KB)".to_string(), + )); + } + + // Grow memory if needed (1 page = 64 KB minimum) + let needed_pages = (content_bytes.len() / 65536) + 1; + let current_pages = memory.size(&store) as usize; + if needed_pages > current_pages { + memory + .grow(&mut store, (needed_pages - current_pages) as u64) + .map_err(|e| PluginError::ExecutionFailed(format!("grow: {}", e)))?; + } + + memory + .write(&mut store, 0, content_bytes) + .map_err(|e| PluginError::ExecutionFailed(format!("write: {}", e)))?; + + let result_len = process_fn + .call(&mut store, (0, content_bytes.len() as i32)) + .map_err(|e| PluginError::ExecutionFailed(format!("call: {}", e)))?; + + if result_len <= 0 { + return Ok(None); // no transformation + } + + // Read back the result from memory offset 0 + let result_bytes: Vec = memory.data(&store)[..result_len as usize].to_vec(); + let result = String::from_utf8(result_bytes) + .map_err(|e| PluginError::ExecutionFailed(format!("utf8: {}", e)))?; + + Ok(Some(result)) } } diff --git a/crates/clipboard-sync/Cargo.toml b/crates/clipboard-sync/Cargo.toml index 17ede8a..d32d80d 100644 --- a/crates/clipboard-sync/Cargo.toml +++ b/crates/clipboard-sync/Cargo.toml @@ -14,3 +14,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } +reqwest = { version = "0.11", features = ["json"] } +chrono = { workspace = true } +clipboard-db = { path = "../clipboard-db" } +urlencoding = "2.1" +base64 = "0.22" diff --git a/crates/clipboard-sync/src/lib.rs b/crates/clipboard-sync/src/lib.rs index 8552203..e085725 100644 --- a/crates/clipboard-sync/src/lib.rs +++ b/crates/clipboard-sync/src/lib.rs @@ -1,10 +1,12 @@ //! OpenPaste Sync Module //! -//! This module provides synchronization functionality for clipboard data across devices. +//! HTTP-based clipboard sync. Each device connects to a shared relay server. +//! Items are identified by content hash — the server is a simple append-only +//! log; clients push new items and pull anything newer than their last sync. pub mod sync; -pub use sync::SyncManager; +pub use sync::{SyncClient, SyncConfig, SyncStatus}; use thiserror::Error; @@ -14,6 +16,10 @@ pub enum SyncError { SyncFailed(String), #[error("Provider not configured")] ProviderNotConfigured, + #[error("HTTP error: {0}")] + Http(String), #[error("Conflict detected: {0}")] ConflictDetected(String), + #[error("Database error: {0}")] + Database(String), } diff --git a/crates/clipboard-sync/src/sync.rs b/crates/clipboard-sync/src/sync.rs index 2b9952f..c76bc33 100644 --- a/crates/clipboard-sync/src/sync.rs +++ b/crates/clipboard-sync/src/sync.rs @@ -1,46 +1,295 @@ -//! Sync manager implementation +//! Sync client implementation +//! +//! Protocol (all JSON over HTTP): +//! +//! POST /api/v1/sync/push +//! Body: { device_id, items: [SyncItem] } +//! Reply: { accepted: [hash], rejected: [hash] } +//! +//! GET /api/v1/sync/pull?device_id=&since= +//! Reply: { items: [SyncItem] } +//! +//! SyncItem mirrors clipboard_items columns except id (server-assigned). use crate::SyncError; +use clipboard_db::Database; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::{error, info}; -/// Sync manager for clipboard synchronization -pub struct SyncManager { - // TODO: Add sync provider configuration +// ── Wire types ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncItem { + pub hash: String, + pub content_type: String, + /// Base-64 encoded raw bytes (may be ciphertext when encrypted=true) + pub content_b64: String, + pub created_at: String, + pub pinned: bool, + pub favorite: bool, + pub encrypted: bool, + /// Base-64 encoded nonce, present when encrypted=true + pub nonce_b64: Option, } -impl Default for SyncManager { - fn default() -> Self { - Self::new() - } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PushRequest { + device_id: String, + items: Vec, } -impl SyncManager { - /// Create a new sync manager - pub fn new() -> Self { - Self {} - } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PushResponse { + accepted: Vec, + #[serde(default)] + rejected: Vec, +} - /// Start synchronization - pub async fn start(&self) -> Result<(), SyncError> { - // TODO: Implement sync start - Err(SyncError::ProviderNotConfigured) - } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PullResponse { + items: Vec, +} - /// Stop synchronization - pub async fn stop(&self) -> Result<(), SyncError> { - // TODO: Implement sync stop - Ok(()) - } +// ── Config ──────────────────────────────────────────────────────────────────── - /// Get sync status - pub async fn status(&self) -> Result { - Ok(SyncStatus::Stopped) - } +/// Configuration required to use the sync client. +#[derive(Debug, Clone)] +pub struct SyncConfig { + /// Base URL of the sync relay server, e.g. `https://sync.example.com` + pub server_url: String, + /// Stable identifier for this machine (UUID stored in settings) + pub device_id: String, + /// Optional bearer token for authenticated servers + pub api_token: Option, } -/// Sync status +// ── Status ──────────────────────────────────────────────────────────────────── + #[derive(Debug, Clone, PartialEq)] pub enum SyncStatus { - Stopped, - Running, + Idle, + Syncing, Error(String), } + +// ── Client ──────────────────────────────────────────────────────────────────── + +pub struct SyncClient { + config: SyncConfig, + http: Client, + db: Arc, +} + +impl SyncClient { + pub fn new(config: SyncConfig, db: Arc) -> Self { + Self { + config, + http: Client::new(), + db, + } + } + + // ── Push ───────────────────────────────────────────────────────────────── + + /// Push items created after `since_rfc3339` to the server. + /// Pass `None` to push everything (initial sync). + pub async fn push(&self, since_rfc3339: Option<&str>) -> Result { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + // Fetch items from local DB + let items = self + .db + .list_items(1000, 0) + .await + .map_err(|e| SyncError::Database(e.to_string()))?; + + let to_push: Vec = items + .into_iter() + .filter(|item| { + if let Some(since) = since_rfc3339 { + item.created_at.to_rfc3339().as_str() > since + } else { + true + } + }) + .map(|item| SyncItem { + hash: item.hash, + content_type: item.content_type, + content_b64: STANDARD.encode(&item.content), + created_at: item.created_at.to_rfc3339(), + pinned: item.pinned, + favorite: item.favorite, + encrypted: item.encrypted, + nonce_b64: item.nonce.as_deref().map(|n| STANDARD.encode(n)), + }) + .collect(); + + if to_push.is_empty() { + return Ok(0); + } + + let count = to_push.len(); + let url = format!("{}/api/v1/sync/push", self.config.server_url); + + let mut req = self + .http + .post(&url) + .json(&PushRequest { + device_id: self.config.device_id.clone(), + items: to_push, + }); + + if let Some(ref token) = self.config.api_token { + req = req.bearer_auth(token); + } + + let resp = req + .send() + .await + .map_err(|e| SyncError::Http(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(SyncError::Http(format!("{}: {}", status, body))); + } + + let push_resp: PushResponse = resp + .json() + .await + .map_err(|e| SyncError::Http(e.to_string()))?; + + info!( + "Sync push: {} accepted, {} rejected", + push_resp.accepted.len(), + push_resp.rejected.len() + ); + Ok(count) + } + + // ── Pull ───────────────────────────────────────────────────────────────── + + /// Pull items from the server newer than `since_rfc3339` and insert them + /// into the local DB. Already-known hashes (UNIQUE constraint) are silently + /// skipped. + pub async fn pull(&self, since_rfc3339: Option<&str>) -> Result { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + let since_param = since_rfc3339.unwrap_or("1970-01-01T00:00:00Z"); + let url = format!( + "{}/api/v1/sync/pull?device_id={}&since={}", + self.config.server_url, + urlencoding::encode(&self.config.device_id), + urlencoding::encode(since_param) + ); + + let mut req = self.http.get(&url); + if let Some(ref token) = self.config.api_token { + req = req.bearer_auth(token); + } + + let resp = req + .send() + .await + .map_err(|e| SyncError::Http(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(SyncError::Http(format!("{}: {}", status, body))); + } + + let pull_resp: PullResponse = resp + .json() + .await + .map_err(|e| SyncError::Http(e.to_string()))?; + + let mut inserted = 0usize; + for sync_item in pull_resp.items { + let content = STANDARD + .decode(&sync_item.content_b64) + .map_err(|e| SyncError::SyncFailed(format!("base64 decode: {}", e)))?; + + let nonce = sync_item + .nonce_b64 + .as_deref() + .and_then(|n| STANDARD.decode(n).ok()); + + let created_at = chrono::DateTime::parse_from_rfc3339(&sync_item.created_at) + .map_err(|e| SyncError::SyncFailed(format!("date parse: {}", e)))? + .with_timezone(&chrono::Utc); + + let db_item = clipboard_db::models::ClipboardItem { + id: 0, + content_type: sync_item.content_type, + content, + hash: sync_item.hash, + created_at, + accessed_at: None, + pinned: sync_item.pinned, + favorite: sync_item.favorite, + nonce, + encrypted: sync_item.encrypted, + }; + + match self.db.insert_item(&db_item).await { + Ok(_) => inserted += 1, + Err(e) => { + // UNIQUE violation = we already have it, not an error + if !e.to_string().contains("UNIQUE") { + error!("Failed to insert synced item: {}", e); + } + } + } + } + + info!("Sync pull: {} new items inserted", inserted); + Ok(inserted) + } + + // ── Full sync cycle ─────────────────────────────────────────────────────── + + /// Run one full push → pull cycle. + /// `last_sync` should be persisted to the `settings` table as `last_sync_at`. + pub async fn sync_once(&self, last_sync: Option<&str>) -> Result<(usize, usize), SyncError> { + let pushed = self.push(last_sync).await?; + let pulled = self.pull(last_sync).await?; + Ok((pushed, pulled)) + } + + // ── Background sync loop ────────────────────────────────────────────────── + + /// Spawn a background task that syncs every `interval_secs` seconds. + pub fn start_background(client: Arc, interval_secs: u64) { + tokio::spawn(async move { + let mut ticker = + tokio::time::interval(tokio::time::Duration::from_secs(interval_secs)); + + loop { + ticker.tick().await; + + // Load last sync timestamp from settings + let last_sync = client + .db + .get_setting("last_sync_at") + .await + .ok() + .flatten(); + + match client.sync_once(last_sync.as_deref()).await { + Ok((pushed, pulled)) => { + // Persist new sync timestamp + let now = chrono::Utc::now().to_rfc3339(); + let _ = client.db.upsert_setting("last_sync_at", &now).await; + info!("Sync cycle complete: pushed={}, pulled={}", pushed, pulled); + } + Err(e) => { + error!("Sync cycle failed: {}", e); + } + } + } + }); + } +}