Skip to content

Client architecture

A Tauri v2 + React desktop clipboard manager for Windows and Linux with real-time monitoring, global hotkeys, multi-window popups, and optional cloud sync with end-to-end encryption.

Owns: how this app works inside. Windows and runtime, app state, Tauri commands and events, local persistence, the capture pipeline, and the client half of the sync engine - what it does with what it receives. Not here: the wire contract itself. Routes, payloads, DDL, socket-event shapes and the crypto envelope live in orange-copy-paste-clipboard-backend/docs/architecture.md; who-may-do-what in the root docs/permissions.md; cross-component invariants in the root docs/architecture.md. Restating a payload here creates a second contract that nothing keeps true.


The app runs as a single Tauri process with five webview windows: main, splash, and the three popups in the diagram (see section 5.1). The Rust backend owns all clipboard operations, history storage and OS integrations. The React frontends call Tauri commands (request/response) and listen for events (push notifications). The SyncClient runs in a dedicated background Tokio runtime and is optional: the app works fully without it.


Component Version Purpose
Tauri 2 App framework, windowing, IPC
tauri-plugin-global-shortcut 2 Ctrl+Shift+C / Ctrl+Shift+V
tauri-plugin-autostart 2 Launch on OS startup
arboard 3 Cross-platform clipboard (text + images); bypassed on Windows for image writes
windows-sys 0.59 Win32 APIs (clipboard formats, key simulation, monitors) - Windows only
image 0.25 PNG encode/decode for clipboard images
base64 0.22 Data-URL encoding
parking_lot 0.12 Mutex without poisoning
serde + serde_json 1 Serialization for IPC and settings persistence
rmp-serde 1 MessagePack binary serialization for history persistence
reqwest 0.12 Async HTTP client for sync push/pull (rustls TLS, JSON) - sync module only
tokio-tungstenite 0.24 Async WebSocket client for realtime events - sync module only
argon2 0.5 Argon2id key derivation for User Master Key (UMK) - sync module only
aes-gcm 0.10 AES-256-GCM content encryption/decryption - sync module only
x25519-dalek 2 X25519 ECDH for multi-device key exchange and space key wrapping - sync module only
keyring 2 OS credential store for the device private key and cached Supabase session - sync module only

Auth is delegated to Supabase. The backend runs on Supabase Auth (GoTrue) + Supabase Postgres. The client runs sign-up, login, refresh, email verification and password reset against Supabase directly. It then attaches the Supabase access token to backend calls. The backend issues no JWT and has no /auth/login or /auth/refresh route. The sync module talks to GoTrue over REST (sync/supabase.rs).

Component Version Purpose
React 19.1 UI framework
TypeScript 5.8 Type safety
Vite 7 Build tool (multi-page)
@tauri-apps/api 2 IPC (commands + events)
Vanilla CSS - Styling (CSS variables for theming)

  • Directorysrc-tauri/
    • Directorysrc/
      • main.rs Process entry point
      • lib.rs App builder, setup, Tauri command registration
      • health.rs Panic hook, heartbeat watchdog, atomic writes, quarantine
      • updater.rs Signed self-update: check, download, install
      • clock.rs One clock for the whole product: every timestamp is read here
      • settings_file.rs settings.json reader (tolerates transient Windows sharing violations)
      • Directoryclipboard/
        • mod.rs Module re-exports
        • commands.rs Tauri command handlers (get_history, copy, paste, etc.)
        • history.rs ClipboardEntry, ClipboardHistory, binary persistence (image files + msgpack)
        • files.rs CF_HDROP read/write (Windows file clipboard)
        • html.rs CF_HTML read/write (rich text clipboard)
        • image.rs Multi-format image clipboard read/write
      • Directorynotes/
        • mod.rs Module re-exports
        • commands.rs Tauri command handlers (get_notes, create/update/delete, groups)
        • store.rs Note model + MessagePack persistence
      • Directorynotifications/ Notification centre (bell popout)
        • mod.rs Module re-exports
        • commands.rs list/refresh/mark-read/dismiss; reconciles invites with the server
        • store.rs Notification model + MessagePack persistence, read TTL, cap
      • Directorysync/ Cloud sync module (optional, runtime-gated)
        • mod.rs SyncClient init, background Tokio runtime
        • client.rs reqwest HTTP client, Bearer + X-Device-Id injection, 401 refresh
        • supabase.rs Supabase Auth (GoTrue): login, signup, refresh, recover, PKCE
        • oauth.rs Google sign-in: loopback redirect server (ports 53170-53172)
        • device_id.rs Stable per-machine device fingerprint (not identity)
        • ws_listener.rs WebSocket connection, event dispatch to Tauri event system
        • pending_queue.rs sync_pending.json read/write for offline accumulation
        • pending_work.rs sync_pending_work.json: uploads and downloads under way
        • id_map.rs client_id -> server_id map (id_map.json)
        • sync_state.rs Connection/status state shared with the UI
        • persist.rs Cached session + sync metadata persistence
        • crypto.rs password split (Argon2id + HKDF), UMK envelope, AES-256-GCM, X25519
        • types.rs Wire types mirrored from the backend contract
        • commands.rs Tauri commands: sync_login, sync_logout, sync_now, etc.
        • config.rs Server/Supabase endpoints + sync-enabled flag
      • Directoryruntime/
        • mod.rs Module re-exports
        • clipboard_watcher.rs Background polling thread (220ms)
        • hotkeys.rs Global shortcut handlers (Ctrl+Shift+C/V)
        • notifications.rs Copy/paste notification toast logic
        • os_notify.rs OS desktop toast (Windows/Linux), distinct from the in-app popup
        • popup_windows.rs Window creation, show/hide, focus handlers
        • commands.rs Window control commands (close/resize popups)
        • tray.rs System tray icon and menu
        • Directoryplatform/
          • mod.rs cfg-gated module selection + cross-platform popup_position()
          • windows.rs Win32: key simulation, cursor, monitors, DPI
          • linux.rs xdotool/wtype: key simulation, cursor, screen info
        • window_state.rs Saved window geometry (position, size)
      • Directorystate/
        • mod.rs Re-exports
        • app_state.rs AppState (shared history + flags)
        • popup_state.rs Popup dimensions, event payload structs
    • Cargo.toml
    • tauri.conf.json
    • capabilities/default.json
  • Directorysrc/
    • types.ts Shared types (ClipboardEntry, AppScreen, helpers)
    • Directoryhooks/ Shared React hooks (see “Shared Hooks” below)
      • …
    • Directorycomponents/
      • Directoryapp/
        • index.html Main window HTML entry
        • App.tsx Root component, state management, event listeners
        • App.css
        • Directorysidebar/ Navigation sidebar
          • …
        • Directoryclipboard-screen/ Main history view (tiles/list, day groups; progressive render)
          • Directorybulk-actions/ Multi-select actions bar
            • …
          • Directorygroup-manager/ Group CRUD card
            • …
          • Directorysearch-filter/ Search + filters panel
            • …
          • Directoryentry-card/ Entry cards (EntryCard, ChipBar, VideoPlayer)
            • …
        • Directorytopbar/ Sort/layout/filter/group controls (shared)
          • …
        • Directoryview-toolbar/ Toolbar above a single opened item (entry/space item/note)
          • …
        • Directorynotes-screen/ Notes UI (editor-engine, list, filters, groups)
          • …
        • Directoryspaces-screen/ Spaces: shared feed + space management/settings
          • …
        • Directoryaccount-screen/ Sync auth, cloud sync mode, devices/presence, storage
          • …
        • Directorysettings-screen/ User preferences + Cloud Sync controls
          • …
        • Directoryshortcuts-screen/ Keyboard shortcut reference
          • …
        • Directorycard-menu/ Right-click context menu (portal)
          • …
        • Directorynotifications/ Notification centre popout (bell)
          • …
        • Directorystatus-pill/ Entry count summary bar
          • …
        • Directoryupdate-banner/ In-app update prompt (check/download/install)
          • …
        • Directorytoast/ Toast notifications (undo clear)
          • …
        • Directorytooltip/ Tooltip portal
          • …
      • Directorycommon/ Shared components (ConfirmDeleteDialog)
        • …
      • Directoryentry-types/ EntryTypePill (shared type badge)
        • …
      • Directorysplash/ SplashScreen (startup)
        • …
      • Directorycopy-popup/
        • copy-popup.html Copy popup HTML entry
        • CopyPopup.tsx Copy confirmation popup (preview, pin, delete)
        • copyPopup.css
      • Directorynotifications/
        • notification.html Copy/paste notification HTML entry
        • Notification.tsx Notification component (“Copied”/“Pasted”)
        • notification.css
      • Directorypaste-popup/
        • paste-popup.html Paste popup HTML entry
        • PastePopup.tsx Quick paste popup (keyboard slots, tabs)
        • pastePopup.css

main.rs - Minimal entry: calls lib::run().

lib.rs - Orchestrates the entire startup sequence:

  1. kill_previous_instance() - Terminates a running copy so its global hotkeys are released. It runs only in a debug build or on the app’s own restart (update or health recovery). A plain relaunch or a --trigger/deep-link launch is forwarded to the running instance by the single-instance plugin instead. On Windows it uses tasklist/taskkill; on Linux, pgrep/kill -9. It waits first: see Shutdown and the rotation window.
  2. create_shared_history() - Creates Arc<Mutex<ClipboardHistory>>.
  3. AppState { .. } - run() builds AppState inline from the shared history and suppress flag.
  4. setup_runtime() - Called inside tauri::Builder::setup:
    • Configures the images directory ({app_data}/images/) for on-disk image storage
    • Loads history from {app_data}/history.bin (MessagePack binary) + {app_data}/images/ through ClipboardHistory::load_from_disk, which folds in a pre-upgrade pinned_entries.bin once (section 7.2)
    • Creates popup windows (hidden, off-screen)
    • Registers global shortcuts (Ctrl+Shift+C, Ctrl+Shift+V)
    • Starts clipboard watcher thread
    • Starts background flush thread (flush_dirty_stores every 2s; see History keeping in section 4.2)
    • Sets up main-window focus handler (auto-hides popups)
    • Restores saved window geometry
    • Starts window move/resize tracking
    • Starts cloud sync, if it was enabled when the app last quit, through sync::commands::get_or_create_client_with, the same helper the commands use. Why: building a SyncClient is only half of starting sync; the other half is the passive-pull and reminder loops. A second construction path could skip those loops, and nothing would start them later: every command that would returns the client this path already installed.
  5. Registers all Tauri command handlers.
  6. Hooks WindowEvent::Destroyed on the main window to exit(0) the entire process.

The rotation window is the gap between spending a refresh token and writing its replacement to the keychain. GoTrue revokes a refresh token the moment it is presented, so during that gap the account’s only live credential exists in memory alone.

sync::client::RotationGuard marks the window. Two places enter it, the two places a token is spent: refresh_access_token, and the restore path in sync/mod.rs, which bypasses refresh_lock entirely. The guard increments a process-wide count and writes a marker file naming this pid under the temp directory.

Exit path What it does
Tray quit, window close, any AppHandle::exit RunEvent::ExitRequested prevents the exit once, drains on a worker thread (sync work first, see below, then the rotation), then re-issues it. Two latches - one for “the drain is running”, one for “this exit is ours” - because there are two independent sources of the event and the drain re-issues it; collapsing them gives either a skipped drain or an app that cannot be quit.
health_restart_app Drains and flushes before it asks for the restart (flush_for_restart). A restart carries its own exit code and the runtime ignores an objection to it, so there is nothing to prevent. The command is async and runs the drain on a blocking thread, so the main thread stays free; the restart is then asked for off the main thread, which delivers ExitRequested (code RESTART_EXIT_CODE) and Exit.
updater_install Drains and flushes before install(), the same way. On Windows the plugin ends this process from inside that call, so anything after it never runs. An install that fails hands sync back (resume_after_failed_restart) and shows the window again if the drain hid it.
Being force-killed by a relaunch The victim gets no say, so the killer waits: wait_out_rotation polls the marker file and holds off while it names a process it is about to kill. A marker abandoned by a crash names a pid that is not a victim, so it costs one file read rather than the wait.

EXIT_DRAIN_MS (3s) bounds the rotation wait on all four paths. It is sized from the keychain write’s own retry ladder. After a sync wait (below) it gets what is left of that wait’s budget, if that is less. On a timeout the exit goes ahead, and crash.log records it.

Running sync work (drain_sync_work). A quit or restart also waits for uploads and blob downloads that are already running:

  • SyncClient::begin_quit first, so nothing new starts. A push or download that comes due after it, or a push still waiting for its slot, is written to the pending-work record instead (section 4.6). A queue flush needs no wait: ops it took are in the queue’s in-flight file, which the next launch replays.
  • Nothing running: no wait, no window change.
  • Otherwise the main window hides at once and the app’s own toast reads “Finishing sync”. The note under it says the app will close, or restart, in a few seconds. The toast shows whatever the notification settings say, and stays up until the app exits.
  • SYNC_DRAIN_MS (10s) bounds the sync wait and the rotation wait together. What is still running then is already named in sync_pending_work.json, so the exit flush keeps its entry and the next launch runs it. crash.log records the timeout.
  • Opening the app again during a quit’s wait calls the quit off (QUIT_CANCELLED): the wait ends, sync starts again, the toast goes and the window comes back. A restart’s wait cannot be called off.
  • A restart’s Exit writes with Flush::Forced, like the flush before it, so it drops nothing.

Why: an entry can be in the middle of its upload, or of the download that brings it here, when the user quits. With Keep history off, that entry had nothing yet to keep it, so the exit dropped it. The wait lets most transfers finish; the record covers the rest.

Limit. Part of the window stays open. It opens the moment GoTrue commits, inside an await that no exit hook can reach. If the process dies while the response is in flight, the replacement token never existed locally. Only a write-ahead marker would close that part, and it would change what the next launch may conclude from a rejected token.

Why:

  • A process that ends inside the window leaves the keychain holding a token the server has already thrown away. The next launch cannot tell that apart from a session that was genuinely revoked, so every way the process can end has to know about the window.
  • The 3s budget follows the keychain retry ladder so that a rotation about to succeed is not cut off one step from the end.
  • A timeout never holds the exit: an app that cannot be quit is a worse bug than a session the user has to sign into again.
AppState
├── history: Arc<Mutex<ClipboardHistory>> ← shared across all threads
├── suppress_next_capture: Arc<AtomicBool> ← prevents watcher re-capturing
├── keep_history: Arc<AtomicBool> ← cached mirror of the setting, default on (~1ns check)
├── history_dirty: Arc<AtomicBool> ← triggers periodic flush to history.bin
├── close_to_tray: Arc<AtomicBool> ← hide to tray instead of quitting
├── os_notifications: Arc<AtomicBool> ← may also raise an OS toast while unfocused
├── start_minimized: Arc<AtomicBool> ← start hidden (minimized to tray)
├── notification_enabled: Arc<AtomicBool> ← master toggle for copy/paste notifications
├── notif_copy: Arc<AtomicBool> ← show notification on copy action
├── notif_paste: Arc<AtomicBool> ← show notification on paste action
├── autosave: Arc<AtomicBool> ← auto-add "Saved" group to new entries, default on
├── show_splash: Arc<AtomicBool> ← show the startup splash on launch
├── splash_updating: Arc<AtomicBool> ← splash is mid auto-update; holds its close timer
├── active_clipboard_id: Arc<Mutex<String>> ← ID of the entry currently in the OS clipboard
├── notes: Arc<Mutex<NoteStore>> ← shared notes store
├── notes_dirty: Arc<AtomicBool> ← triggers periodic flush to notes.bin
├── notifications: Arc<Mutex<NotificationStore>> ← notification centre feed
├── notifications_dirty: Arc<AtomicBool> ← triggers periodic flush to notifications.bin
├── sync_client: Mutex<Option<Arc<SyncClient>>> ← None when sync disabled or not yet authed
└── ui_view: Arc<Mutex<UiView>> ← the screen and space the user is looking at

AppState is managed by Tauri and injected into every command handler via State<'_, AppState>. The same Arc references are also held by the clipboard watcher thread and the hotkey handler closures.

Suppress flag: When copy_entry, paste_entry, or Ctrl+Shift+C write to the OS clipboard, they set suppress_next_capture = true. The next watcher poll sees this, clears it, and skips capture - preventing duplicate entries.

Entry size is capped; entry count is not. MAX_TEXT_BYTES (4 MiB) bounds one text, rich-text or file-list entry. Image content is a path to a file on disk and is exempt.

  • Where it is enforced. At the three places an entry can enter memory:
    • read_clipboard_capture at capture. On Windows it measures the OS handle first, so an oversized payload is never decoded into the process.
    • upsert_synced on the sync merge.
    • drop_oversized on load, which prunes a history file written before the cap existed.
  • A refused capture always shows the app’s own toast, whatever the notification preferences say.
  • The cap sits well above the sync limit (MAX_INLINE_SYNC_BYTES in sync/mod.rs). An entry between the two is kept locally and marked local-only.

Why:

  • An entry is copied several times on its way to the user: into the store, into each webview that shows it, into MessagePack on every flush, and into ciphertext when sync pushes it. An unbounded entry is an unbounded multiple.
  • The toast ignores preferences because the only other sign of a refused capture is the item’s absence.
  • What this app holds locally and what a cloud row may weigh are different questions, and history is useful without sync.

History keeping. Every mutation sets history_dirty. flush_dirty_stores (lib.rs) writes history.bin at most every 2 seconds, and again, forced, on every exit and before a self-restart or update install (flush_for_restart). There is one history file and one writer, ClipboardHistory::save_to_file, which goes through health::write_state.

keep_history (per device) What history.bin holds
On (the default) Every entry
Off Pinned and Saved entries, plus every entry this device’s sync records name: SyncClient::keep_keys, or with no client the same files read from disk (keep_keys_on_disk). That is the id map’s rows, shares waiting for a space key and items received from a space (IdMap::keep_keys), queued ops including those a flush holds, pushes in flight, and the pending-work record. A record that will not read keeps everything: for that flush with no client, for the whole session with one.
  • Nothing caps the entry count. MAX_TEXT_BYTES bounds a single entry (above).
  • Turning Keep history back on loses nothing. Startup loads history.bin whatever the setting, so memory already holds everything the file does, and the next flush writes it all.
  • Files go only with their entry. An image or received-files folder is queued in gone_files when its entry is removed and deleted once the save that drops the entry has landed, never for an id that is live again. Nothing scans images/ for orphans.
  • With Keep history off, the final exit drops what it does not write. Only the RunEvent::Exit flush (Flush::Exit) calls drop_unkept, so the entries it leaves out lose their files the same way. A restart flush drops nothing, since an update install can fail and return to the running app, and neither does a flush that keeps everything.
  • A file the clipboard still holds stays, with its entry. drop_unkept keeps an entry whose file the system clipboard names (CF_HDROP, read by files::clipboard_file_paths) or a kept entry names, so no file is left that nothing points at. A clipboard that will not read may hold anything, so that exit drops nothing.
  • The keep set is read under the history lock. Every merge writes the id_map row before the entry reaches history, and every upload or download is in the pending-work record from before it starts until after its row is written, so no entry can be in history with nothing naming it.
  • The cursor never runs ahead of the file. A pull page flushes the store before last_server_ts moves past it (section 6.3).

Why: a device that holds less than its sync record says it pulled cannot tell “deleted” from “never written”, and the delta pull never offers those rows again. Each default on a keep-or-drop path falls to keep: dropping what the user expected to keep cannot be undone, keeping extra entries can (bug #31 in docs/bugfix-history.md).

ClipboardEntry {
id: String ← UUIDv4, generated at capture; doubles as the cross-device client_id
kind: EntryKind ← Text | Image | File | Html
content: String ← plain text / file path (images) / newline-delimited paths / html---PLAINTEXT---text
timestamp: u64 ← Unix ms
pinned: bool
groups: Vec<String> ← user-defined group tags (e.g. "Saved")
label: Option<String> ← display name (e.g. "Image Mar 17, 2:45 PM" for images)
content_hash: Option<u64> ← session-only dedupe hash; #[serde(skip)], not persisted
}

Sync note: per-entry sync state is not stored on the entry. It lives in id_map.json and the pending queue, and the UI reads it via the sync_get_entry_states command (useEntrySyncStates()), gated by the show_sync_badges setting.

ClipboardHistory is a Vec<ClipboardEntry> with most-recent-first ordering:

Method Behavior
push(entry) Prepend, externalising an inline image. No count cap
push_if_distinct_with_flag(entry) Skip if top entry matches (kind + content); returns whether insertion happened
top_matches(entry) Whether the newest entry holds this content, taking no copy
drop_oversized() Drop entries past MAX_TEXT_BYTES, returning their sizes
top(n) First N entries
find(id) / find_mut(id) Lookup by ID
pin(id) / unpin(id) Toggle pinned flag
remove(id) Delete by ID
clear() Remove every entry that is not pinned or Saved; returns each removed (id, timestamp) for its tombstone
set_groups(id, groups) Replace the groups list for an entry
add_group(id, group) Add a single group tag (no duplicates)
purge_group(group) Remove a group tag from every entry that has it
rename_group(old, new) Rename a group tag across all entries
remove_group(id, group) Remove a single group tag from an entry
pinned_entries() All pinned entries
all() Full history slice (most-recent first)
set_images_dir(dir) Configure the directory for on-disk image storage
upsert_synced(entry) Insert or replace a merged entry by id; refuses one past MAX_TEXT_BYTES
save_to_file(path, also_keep) Write pinned/Saved entries plus those also_keep names, then delete gone_files
drop_unkept(also_keep, clipboard_refs) Drop the entries save_to_file would leave out, queueing their files in gone_files unless the clipboard or a kept entry names them; final exit flush only
load_from_disk(history, legacy, keep) Startup load, including the one-time fold of pinned_entries.bin (section 7.2)
merge_leftover(bytes) Fold in a sealed session’s leftover by id, adding only what is missing

The generate_handler! / invoke_handler block in src-tauri/src/lib.rs is the authoritative registry of every Tauri command. The command tables in this document summarize it for reading and can lag behind it; when they disagree, lib.rs wins.

Command Signature Description
get_history () -> Vec<ClipboardEntry> Return full history (most-recent first)
delete_entry (id) -> bool Remove entry, emit clipboard:entry-deleted
clear_history () -> bool Remove every entry not pinned or Saved; tombstones exactly those
pin_entry (id) -> bool Pin entry (max 10), auto-save to disk
unpin_entry (id) -> bool Unpin entry, auto-save to disk
copy_entry (id) -> bool Write entry to OS clipboard, set suppress flag, update active clipboard ID, show copy notification
copy_entries (ids) -> bool Copy a multi-entry selection as one clipboard payload (text block or one file drop)
paste_entry (id) -> bool Write to clipboard, hide popup, simulate Ctrl+V, update active clipboard ID, show paste notification
get_active_clipboard_id () -> String Return ID of entry currently in the OS clipboard
set_entry_groups (id, groups) -> bool Set group tags for an entry, auto-save
purge_group_from_entries (group) -> bool Remove a group tag from all entries
rename_group_in_entries (old_name, new_name) -> bool Rename a group tag across all entries
bulk_delete_entries (ids) -> u32 Delete multiple entries, returns count removed
bulk_pin_entries (ids, pin) -> u32 Pin/unpin multiple entries (respects MAX_PINNED)
bulk_add_group (ids, group) -> u32 Add a group to multiple entries
bulk_remove_group (ids, group) -> u32 Remove a group from multiple entries
get_setting (key) -> Option<Value> Read a setting from settings.json
set_setting (key, value) -> bool Write a setting; syncs in-memory caches for known keys (sync_enabled, sync_server_url included); a roaming key schedules a settings round
get_image_file_preview (path) -> Option<String> Read image file -> data-URL (max 12 MB)
check_missing_files (paths) -> Vec<String> Returns paths that do not exist (used by paste popup before paste)
stat_files (paths) -> Vec<FileStat> Per-path facts for a multi-file card: is-dir, size, item count, missing (batched)

get_image_file_preview results are served from a bounded in-process LRU cache (32 MB, keyed by path + mtime + length), so repeated previews across the grid and the copy/paste popups do not re-read the file. See src-tauri/src/clipboard/commands.rs.

notes/commands.rs - Notes Command Handlers

Section titled “notes/commands.rs - Notes Command Handlers”
Command Signature Description
get_notes () -> Vec<Note> Return all notes
create_note () -> Note Create a new blank note
update_note (id, title, content) -> bool Update note content/title
delete_note (id) -> bool Delete a note by ID
pin_note (id) -> bool Pin a note
unpin_note (id) -> bool Unpin a note
set_note_groups (id, groups) -> bool Replace note groups
purge_group_from_notes (group) -> () Remove a group from all notes
rename_group_in_notes (old_name, new_name) -> () Rename a group across all notes
save_note_image (bytes, ext) -> String Save an image attachment; returns its stored path
save_note_file (bytes, name) -> String Save a file attachment; returns its stored path
get_note_attachments_dirs () -> NoteAttachmentDirs Return the note image/file attachment directories
export_note_text (text, filename) -> String Export a note’s plain text to a file

Internal helpers:

  • read_clipboard_capture() - Reads the OS clipboard in priority order: files (CF_HDROP) -> HTML (CF_HTML) -> text -> images (CF_PNG, registered formats, CF_DIB fallback). Returns a Capture: Entry, TooLarge (over MAX_TEXT_BYTES) or Nothing. Every capture path goes through it.
  • write_entry_to_clipboard(entry) - Writes a ClipboardEntry back to the OS clipboard. Text goes via arboard (with retry), files via CF_HDROP. Images: on Windows it uses the Win32 API directly (write_image_to_clipboard) and bypasses arboard; on Linux and other platforms it uses the arboard RGBA path.
  • open_clipboard_with_retry() - Opens an arboard Clipboard handle in up to 6 attempts, 50ms apart, to ride out contention with the watcher thread or other apps.
  • set_active_clipboard_id(app, id) - Updates active_clipboard_id in AppState and emits clipboard:active-id to the frontend. Called from copy_entry, paste_entry, the clipboard watcher and the copy shortcut handler.

files.rs - Windows File Clipboard (CF_HDROP)

Section titled “files.rs - Windows File Clipboard (CF_HDROP)”

Reads and writes file lists via CF_HDROP clipboard format using Win32 APIs:

  • Read: OpenClipboard -> GetClipboardData(CF_HDROP) -> DragQueryFileW to extract paths.
  • Write: Build DROPFILES struct + UTF-16 filename block -> GlobalAlloc -> SetClipboardData(CF_HDROP).
  • Serialization: File paths stored as newline-delimited strings in ClipboardEntry.content.

Reads and writes HTML content via the CF_HTML registered clipboard format:

  • Read: Extracts the HTML fragment from the CF_HTML format header (StartFragment/EndFragment markers).
  • Write: Constructs a CF_HTML header with proper byte offsets and writes the fragment via Win32 APIs.
  • HTML entries store both the HTML fragment and a plain-text fallback separated by \n---PLAINTEXT---\n.

Reading - tries formats in priority order:

  1. Registered custom formats: "PNG", "image/png", "image/jpeg", "image/webp", "image/bmp", "JFIF" - covers browsers, Snipping Tool, etc.
  2. CF_HDROP - image file exposed as a shell file-drop.
  3. arboard fallback - CF_DIB/CF_DIBV5 for screenshots and classic Win32 apps.

Image data is first encoded as a data:<mime>;base64,... URL. On push to history, the image is written to its own file in the images/ directory (named {id}_{label}.{ext}), and content becomes the absolute file path. The frontend displays file-backed images through Tauri’s convertFileSrc() asset protocol.

Writing (Windows) - write_image_to_clipboard(data_url):

Uses the Win32 API directly and bypasses arboard:

  1. Decode base64 -> image -> RGBA pixels before opening the clipboard.
  2. OpenClipboard in up to 10 attempts, 50ms apart.
  3. EmptyClipboard -> write CF_DIB (BITMAPINFOHEADER + BGRA bottom-up pixel data) + registered “PNG” format.
  4. CloseClipboard.

Why: arboard’s internal proxy thread races the clipboard watcher on Windows and fails with OS error 1418.

Writing (Linux/other) - uses data_url_to_rgba() to decode the image, then writes via arboard’s set_image(), which does not have that race off Windows.

NoteStore keeps notes in-memory as Vec<Note> and persists them to {app_data}/notes.bin using MessagePack.

Note fields:

  • id, title, content (sanitised HTML)
  • created_at, updated_at
  • pinned
  • groups

Notes are sorted by updated_at descending. A new note gets a UUIDv4 id. On load, a legacy numeric id counter is still advanced past any numeric ids in the file.

As with clipboard entries, per-note sync state is not a field on the note. It lives in id_map.json and the pending queue, and the UI reads it through sync_get_entry_states (useEntrySyncStates()).

  • Note mutations set notes_dirty = true.
  • The shared background flush thread writes notes.bin every ~2s when dirty.
  • Notes are loaded during startup in setup_runtime.

One surface for everything the app has to tell the user, reached from the bell at the bottom of the sidebar. NotificationKind has five values: space_invite, space_activity, sync_warning, announcement and reminder. A new source adds rows through one of them.

The store records what the user was told, not the thing itself.

  • A space_invite notification carries the invite_id in its opaque data map.
  • notifications_refresh re-reads the server’s invite list and retires any row that is no longer pending: resolved becomes "Joined", "Declined" or "No longer available". The row stays as history and drops its buttons.
  • Signed out, refresh does nothing and empties nothing: the feed is whatever the last sign-in left.
  • Ids derive from the source (invite:<invite_id>), so the same server row ingested on every reconnect updates one record instead of stacking copies.
  • upsert preserves the existing read flag and created_at.

Why: an invite lives on the server, and can be answered on another device, revoked, or expire while this one is closed. A refresh must never push a row the user has already seen back to the top as if it were new.

Command Purpose
notifications_list Whole feed, newest first
notifications_unread_count Badge count
notifications_refresh Reconcile against the server (async)
notifications_mark_read / notifications_mark_all_read Read state
notifications_dismiss / notifications_clear_read Removal

Event notifications:changed (no payload) fires on every real change, so the badge and an open popout re-read together. Read rows age out after 30 days, and unread rows are exempt from that sweep. The feed is capped at 500 rows. Signing in as a different account clears the feed in finalize_session, because invites are addressed to a person.

What raises a notification

Source Kind Where
An invite addressed to this user space_invite notifications_refresh, reconciled against GET /api/v1/invites
Someone joined or left a space, or a space was deleted space_activity SyncClient::handle_membership_changed, off the space:membership_changed socket event
An invite this user sent was accepted or declined space_activity SyncClient::note_invite_answered, off invite:updated
Somebody used a code or a join link on a space this user may approve space_invite SyncClient::note_join_requested, off space:join_requested. Raised only when i_can_approve, keyed on the request id so a replayed event cannot double-report.
A space became readable (its key arrived) space_activity SyncClient::note_space_readable, in reconcile_spaces. Gated on SyncState::spaces_announced - the keyring is memory-only, so the in-memory map cannot tell “just gained access” from “just launched”. See bug #16.
A join request this user made was approved space_activity SyncClient::note_join_approved, off space:join_decided. A decline raises nothing loud - the row stops showing as pending.
A space owner removed something this user shared there space_activity SyncClient::note_entry_taken_down, in drop_space_entry
Sync refused to send an item sync_warning record_skip
Items sitting in the manual-mode queue reminder remind_manual_queue_waiting, on the reminder sweep
Blob storage past 90% reminder remind_storage_nearly_full, on the reminder sweep
The server said something announcement (or whatever kind it names) SyncClient::pull_announcements from GET /api/v1/announcements, and the announcement:new socket event

Four rules the sources follow:

  • Your own actions are not news. note_membership_change drops events whose actor is this user, who watched the screen change. Losing your own membership is the exception, and the reason the case exists. The payload cannot separate being removed from leaving, and missing a removal is worse than a redundant line after a deliberate leave.
  • Ids decide whether a row stacks or replaces. A membership change is a distinct occurrence, so its id carries now_ms(). Everything else is keyed on the thing it is about (invite-answered:<id>, space-removed:<space>:<type>:<client_id>) so a replayed event cannot report it twice.
  • Bursts collapse to one row. record_skip can fire hundreds of times in a single push, so it uses raise_rolling on the fixed id sync-skipped. The one row carries the count and goes back to unread whenever the count moves. clear_skipped dismisses it; otherwise the centre would keep quoting a number the Account screen no longer shows.
  • Reminders describe a state, not an event, so they are true on every sweep and would nag. reminder_id folds the current day into the id, so the store’s own idempotence does the rate limiting. Repeats inside a day land on the existing row, and upsert refreshes its count without re-alerting. Tomorrow gets a fresh row if the state still holds.

Server-authored announcements are the one notification the app does not raise itself. They are also the one payload in the sync contract that arrives as plaintext. They are the service’s words, such as a maintenance window or a note to one account, and never quote content the server would have had to decrypt.

  • Delivery is doubled. A connected socket gets announcement:new at once, and pull_announcements hands the same rows to a device that was closed. Both key on announcement:<id>, so a second landing changes nothing. Why: the case that matters is a user who is not looking.
  • SyncState::announcements_cursor makes dismissing one stick. The server keeps no per-user read state; it answers “what is newer than this”. Asking for the same window twice would hand back rows the user had already cleared.
  • The cursor advances only after the rows are in the store, so a crash between the two repeats a message rather than losing one.

Membership names come from the cached space list, which is stale until reconcile_spaces has run. So handle_membership_changed owns the reconcile and reads names on both sides of it, and the fresher answer wins. Why: a joiner is not cached yet before the reconcile, and a space that was left or deleted is gone after it.

Popout behaviour (components/app/notifications/NotificationsPopout.tsx):

  • Anchored to the bell in sidebar-bottom and portalled to document.body. It grows upward, so its top is computed after measuring rather than passed in.
  • Rows render 15 at a time behind a “Show more” button; the count resets when the filter changes or the popout reopens.
  • Filter chips only appear once more than one category is present.
  • Unread rows are marked read on the close edge, not on click. The list does not reflow under the cursor, and every route out (bell, click-outside, Escape, a parent closing it) counts exactly once.
  • The outside-click handler ignores [data-notif-bell]. Otherwise the bell would close the popout and then reopen it with its own click.
  • A row carrying space_id in its data opens the Spaces screen on click or Enter. Invites awaiting an answer are excluded, because answering must not be a side effect of trying to read the row. The row opens the Spaces screen, not the space itself; per-space deep linking would need a selection prop on SpacesScreen.

Sound and the OS toast hang off raise instead of off each caller: they are the same feed, heard rather than read. raise_cued captures title, body and cue before the store takes ownership of the row. It then acts only if upsert reported a real change. Why: notifications_refresh re-reads the server’s invites on every panel open, and a sound per re-read would be unbearable.

  • A Cue is a family, not an event. Six cues (copy, paste, arrived, knock, unlocked, refused) cover every source above. Rising means something came, falling means something was refused, and lower and slower means a person rather than a thing. Cue::for_kind maps a NotificationKind to one cue. raise_cued lets a caller override it where the kind is too broad: a space becoming readable and a join being approved are both space_activity, and both want unlocked. Why: a small set is one the user can learn.
  • Rust decides when, the webview decides what it sounds like. notifications::cue emits ui:cue with the name. src/sounds.ts synthesizes the tone in WebAudio and caches a buffer per cue. Nothing ships as an audio file, and no audio backend is linked into the binary. The main window is the only listener, so a cue plays once even though the popups are separate webviews. The main window also outlives every popup, since closing it either hides it or exits the app.
  • Copy and paste are cued at the two command call sites (clipboard::commands), never inside runtime::notifications::notify_if_enabled. They are also the two cues that default to off (sound_copy, sound_paste). Why: the clipboard watcher calls that helper on every capture, so a cue there would fire on every copy anywhere in the OS.
  • The OS toast only fires while the app is not focused (runtime::os_notify). The check asks whether the main window is both visible and focused, and an error from either question reads as “not focused”. Why: a toast for something the user is looking at says the same thing twice. A toast nobody needed costs less than dropping the only sign that something happened.

Settings, all device-local: sound (master), sound_copy, sound_paste, os_notifications. There is no volume control. The Settings screen previews the four cues the user cannot fire on demand (arrived, knock, unlocked, refused) through the frontend’s playCue(cue, true), which ignores the per-cue settings. The frontend module holds the settings in memory. The Settings screen calls configureSounds directly as well as writing them, so a change takes effect on the next cue rather than the next launch.

Why: one level, chosen to sit under whatever else is playing, beats a slider nobody moves twice. The preview ignores the per-cue settings because the user has to hear a cue to decide whether to turn it on.


Status: Implemented against the current Supabase-based backend contract, Rust module and React UI both. Location: src-tauri/src/sync/ (UI in src/components/app/account-screen/ and spaces-screen/)

Auth path as built: identity comes from Supabase Auth, not from a backend login route. sync/supabase.rs handles password login, signup, refresh and recovery; sync/oauth.rs adds PKCE for Google. The backend verifies the Supabase token and never issues one. The exact routes, headers, payloads and socket events are the wire contract: see orange-copy-paste-clipboard-backend/docs/architecture.md.

The provider handshake does not use the orange:// deep link. sync/oauth.rs:

  • binds a loopback server on the first free port of 127.0.0.1:53170-53172;
  • uses that bare origin as the PKCE redirect_to, and opens the system browser;
  • reads the code off the request line of the single request that comes back.

All three ports must be in the Supabase redirect allow-list.

Sign-in then stops before finalizing. Why: the session alone cannot decrypt anything. The account password is the E2E secret, and only the user has it.

  1. begin_oauth finishes the handshake and probes bootstrap to learn whether the account already has an envelope (is_new). It stashes the session in pending_oauth, returns OAuthBegin and emits sync:oauth-ready. sync_oauth_pending lets a freshly mounted UI pick the step back up. The event exists because the command’s reply is lost if the window was hidden or reloaded during the browser hop.
  2. complete_oauth takes the password, writes the envelope, and finalizes.

Three rules apply in phase 2. Each was once broken; see bugs #9 and #10 in docs/bugfix-history.md.

  • The stash is cloned, not taken, and cleared only on success. A wrong password must leave a retry possible; otherwise the only way to guess again is another trip through the browser.
  • For a new account the envelope is written before the Supabase credential. The other order can leave an account that signs in and cannot decrypt.
  • focus_main_window runs the moment the loopback capture returns, on both the success and failure paths. The browser owns the foreground through the whole handshake, and whatever happens next is in the app.

The loopback serves a result page built from the same App.css tokens as the sign-in screen, linking orange:// as a manual way back.

One scheme, orange, declared under plugins.deep-link.desktop.schemes in tauri.conf.json. A link arrives by one of two routes:

  • the plugin callback in setup;
  • the single-instance handler, for a URL opened while the app is already running, which arrives as argv rather than through the plugin. The handler also marks the launch as a trigger, so the running instance is not replaced.

dispatch_deep_link raises the window first, then parses. That order makes a bare orange:// a usable “come to the front” link, which the OAuth result page uses. parse_deep_link then returns one of two shapes, keyed on the host:

URL Event Consumed by
orange://join?code=<CODE> spaces:join-code SpacesScreen, which joins
orange://reset?code=<CODE> sync:password-reset AccountScreen, which sets the new password

Any host other than reset that carries a code is a join, so orange://anything?code= still works. Why: invite links already sent out rely on it and cannot be re-sent.

App holds both events, not the screen that uses them. App stores the code and switches screens; the screen reads it as a prop and calls back when it is done with it. Why: neither screen is usually mounted when the link arrives.

Both flows re-wrap the same UMK under the new password. Why: the password is only a wrapping key (see orange-copy-paste-clipboard-backend/docs/architecture.md, section 7.1), so a reset that minted a new UMK would leave everything already synced unreadable.

The emailed link is PKCE, not the implicit flow:

  • recover() sends redirect_to = reset_page_url plus an S256 challenge.
  • The verifier goes into the OS keychain, install-scoped. A reset is requested while signed out, and an app restart usually separates the two halves.
  • The link lands on a static page, which hands the code to orange://reset?code=.
  • The code alone is useless: redeeming it needs the verifier, which never left the machine that asked.

complete_password_reset then recovers the UMK from the first source that has it:

Source Needs When it applies
In memory already signed in resetting from a running, signed-in app
Device wrap this machine’s keychain key any machine that has signed in before
Recovery code the code the user saved the only source that works on a machine which has never signed in
Start over nothing last resort, and loses access to everything synced under the old key

A supplied recovery code is tried first. If it fails, that failure is returned rather than falling through, so a typo reads as a typo instead of “this device has never held your key”.

Finishing a reset takes more than one attempt, by design.

  • The recovery field and the start-over button appear only once the plain attempt has failed and said why. The second attempt is the normal case, not the exception.
  • The emailed code cannot be exchanged twice, so SyncClient::pending_reset holds the session from the first exchange for reuse.
  • That session is dropped on success, and by sync_cancel_password_reset when the panel closes, because it is a live credential for the account.
  • The device id is set on the reset’s HTTP client before the wrap is fetched. Without it, the device-wrap source silently cannot apply (bug #15 in docs/bugfix-history.md).

A second account-wide envelope holding the same UMK, wrapped under a secret the user keeps rather than one they remember.

  • Format. 30 characters in six groups of five (150 bits), from a 32-symbol alphabet with O, 0, I and 1 removed. Exactly 32 symbols, so each character is 5 unbiased bits from one random byte.
  • Wrapping. The same Argon2id step and the account’s own kdf_salt are reused; only the secret and the AAD differ (umk-recovery-v1 against umk-envelope-v2, or -v1 before the split). Sharing the salt is deliberate. The distinct AAD makes feeding one envelope to the other’s unwrap fail loudly rather than half-work, and a test covers exactly that.
  • No credential half. The code is never sent anywhere, so unlike the password it is not split into one.
  • Returned once, stored nowhere. The code is generated in Rust and only its envelope goes to the server. The envelope is uploaded before the code is handed to the UI, so a code the user saves always opens something.
  • One live at a time. Regenerating replaces the envelope, which is what revokes the previous code.
  • Forced at the next sign-in when bootstrap reports recovery_wrapped_umk: null, which is true of every account predating this. The panel blocks only the account screen; clipboard, notes and capture keep working. Continue needs the “I saved my recovery code” box ticked. “Save as file” reuses export_note_text, which writes to Downloads. Why: a modal that stops the product is a worse failure than an unsaved code.
  • Starting over with a new key clears the envelope (DELETE /auth/umk/recovery). Clearing it is also what makes the panel ask for a fresh code. Why: the envelope holds the key being abandoned. Left in place, it would hand a later recovery a key that decrypts nothing.

Two ordering rules, both learned in the OAuth flow:

  • The envelope goes up before the password changes. The other order can leave an account whose password opens nothing.
  • The reset ends by signing in normally with the new password, so device registration, key registration and the device wrap all run through the single path that owns them.

change_password is the same flow minus the code exchange, for a user who is already signed in. Nothing has to be recovered, so nothing can be lost. The account screen offers it, which is why the reset link is the fallback rather than the route.

Requires a dashboard entry: reset_page_url must be in Supabase Authentication -> URL Configuration -> Redirect URLs, character for character.

  • Without it, GoTrue ignores the redirect and falls back to the Site URL. That is how this used to mail a localhost link, and how it broke again when the backend changed hostname.
  • An older release sends its own compiled-in value, and no later release can change that. Every value ever shipped has to stay listed until those installs have aged out.
  • Releases up to 0.2.2 send {server_url}/reset, which the backend now answers with a 302 to the static page.

The sync module runs entirely in a dedicated background Tokio runtime, separate from Tauri’s internal runtime, so it cannot block clipboard capture or the UI.

SyncClient is the public handle held in AppState. Its main entry points:

  • on_new_clipboard_entry(entry) / on_new_note(note) - called after a new entry or note is stored
  • on_update_clipboard_entry(entry) / on_update_note(note) - called from the pin, group and edit commands
  • delete_entries(entry_type, items) - reached from the delete commands through forward_deletes; writes the tombstones to the queue before sending them (see Deletes under pending_queue.rs)
  • on_manual_push_clipboard_entry(entry) / on_manual_push_note(note) - “Upload to cloud” on a picked item, which goes out even in manual mode
  • flush_and_pull() - flush the offline queue, then pull the delta (sync_now)
  • restore_sweep() / restore_if_owed() - walk the whole account for rows missing here (see Restore sweep below)
  • schedule_settings_sync() - debounce one settings round (see Settings Sync below)
  • start_ws_listener() / stop_ws_listener() - WebSocket lifecycle (private)

On startup, when sync is enabled and a Supabase session can be restored:

  1. Restore the Supabase session. On first login, bootstrap the account (fetch kdf_salt, unwrap the UMK) and register the device (obtain device_id).
  2. Open the WebSocket connection. Opening it schedules a settings round, which pulls before it pushes.
  3. Flush sync_pending.json, then pull the delta after last_server_ts, page by page (flush_and_pull), decrypting and merging remote entries into the local store.
  4. Sweep the whole account if a restore is due (restore_if_owed).

Manual mode stops after recovering space keyrings: steps 3 and 4 wait for Sync now.

The routes, query parameters and headers for each step are the wire contract: see orange-copy-paste-clipboard-backend/docs/architecture.md.

sync_restore_session does not wait for the restore.

  • It answers whether a session is coming back, from local state only.
  • It then hands the attempt to SyncClient::spawn_session_restore. The outcome arrives on sync:session-restored or sync:restore-gave-up.
  • The command returns in milliseconds, and RestoreOutcome.restoring is true from the first instant rather than only after a transient failure.

Why: the UI has nothing else to go on. While it awaited this command, it drew a sign-in form over a live session, and users signed in again (bug #17 in docs/bugfix-history.md).

Three local questions decide the answer, none of them touching the network:

Question Source Answer
Is a client already built for this launch? AppState.sync_client Yes -> use it, and do not re-read settings.json.
Did the user turn sync off? SyncConfig::enabled and enabled_known Off only counts when the file actually said so; a settings.json that would not open is not a sign-out.
Is there anything to restore? SyncClient::has_stored_session Keychain refresh token plus a user id from sync_state.json or the install session pointer. A store that will not answer counts as yes.

A delta pull asks only for rows newer than last_server_ts, and id_map.json records what this device pushed or pulled, not what it still holds. A row that was on the server before this device caught up, or that this device lost since, sits behind the cursor for good. The restore sweep is the way back.

  • What it does (sweep_locked, under flush_lock): walks the account from no cursor, page by page, and merges only rows with no local copy (MergeSource::Sweep). It never touches a local copy and never moves the cursor. It is the only merge that lifts the same-device skip, and only for rows missing here: a row this device wrote and then lost is exactly what it is for.
  • What it leaves out (sweep_admits): keys with queued work or a push in flight, taken once before the walk, and keys removed during this run (removed_this_run, recorded when the removal starts). Tombstones and local_only markers apply as in any merge. A restored row is never pushed back.
Trigger Path
Startup and sign-in trigger_initial_sync -> restore_if_owed. finalize_session owes one sweep on every sign-in (restore_owed in sync_state.json). If that launch’s flush fails, the next flush_and_pull that succeeds runs the check (restore_check_missed). In Manual mode the launch makes no flush, so the check waits for Sync now.
Sync now sync_now -> restore_if_owed, in every mode
A space key arrives The key path runs restore_sweep. In Manual mode it only sets restore_owed.
Restore from cloud (Account screen) sync_restore_from_cloud: flushes the queue, then sweeps whatever is owed

restore_if_owed sweeps only when restore_due: a sweep is owed, or either count rose past restore_mark, the counts the last sweep left:

  • missing - rows in this device’s record with no local copy, leaving out removed, queued and in-flight keys.
  • gap - the account’s own live rows (the /sync/breakdown total) minus those present here, where an own row whose download is running counts as present (restore_gap). Only the server can count a row this device never pulled. Unknown when the server cannot be asked, and then it never triggers.

A sweep that meets one of the account’s own live rows already here, with no id_map row and no removal recorded, writes the row (adopt_own_row). A wiped map is rebuilt this way, so its entries are kept with Keep history off and the gap after the sweep is real.

A count that falls lowers the mark, so the next rise is measured from there. Two more things owe a sweep: a row whose space key has not arrived (the pull moves past it all the same), and a blob download that ends without landing (BlobClaim). A sweep clears restore_owed before it walks, so an owe raised during the walk survives it, and a failed page puts back what was owed.

The answer is sync::types::RestoreOutcome { restored, downloading }, where downloading counts blob-backed rows still fetching. It is not the session restore’s RestoreOutcome above.

Limit. The first sweep after updating can bring back many older entries at once: everything a restart dropped under the old Keep history default, and any item an older build deleted while signed out, since those builds sent no tombstone and kept no marker for it.

  • Wraps reqwest::Client with the base URL, the Authorization: Bearer header (Supabase access token), and the X-Device-Id header on device-scoped calls.
  • On a 401, the client refreshes the Supabase session once and retries the original request. A 401 whose detail is device_revoked ends the session instead.
  • Requests time out after 10s (REQUEST_TIMEOUT_SECS); blob transfers use their own longer timeout.
  • A timeout or connect failure is retried twice, after 3s and then 8s (TRANSPORT_RETRY_DELAYS).
  • A 429, 502, 503 or 504 is retried up to 3 times (SERVER_RETRY_ATTEMPTS). Each wait is the server’s Retry-After, capped at 30s, or else 1s, 2s, 4s.

Maintains a persistent tokio-tungstenite WebSocket connection to the backend’s /ws endpoint. The connection handshake is part of the wire contract.

On each received message, dispatches to:

Event Action
sync:entry Unwrap the CEK (personal under UMK, else a carried space id through that space’s keyring) -> decrypt -> insert or update in history/notes -> emit sync:history-merged or sync:notes-merged. Personal entries are skipped in passive mode; space entries always apply, and may auto-copy. Deletes arrive as tombstones on this event.
device:online / device:offline Update sync status indicator via Tauri event
space:membership_changed Refresh spaces, emit space:membership-changed; an owner whose members lost their keys mints a new space key and redistributes
space:rekey Reconcile: adopt the ring the server holds for us (checked against key_fingerprint), prepend a newly minted key if we own the space and one is owed, and wrap for any member who lacks one
ping Respond with pong; this refreshes the device’s presence TTL server-side

The table lists the main events. The full dispatch, including user:presence, device:revoked, space:entry_removed, space:comment, space:history_opened, space:join_requested, space:join_decided, invite:received, invite:updated and announcement:new, is the match in ws_listener.rs.

On a dropped connection the listener reconnects after 5s, doubling the wait up to 60s.

{app_data}/sync_pending.json stores an ordered list of operations waiting to be pushed:

[
{ "op": "push", "entry_json": "<serialized encrypted push request>", "entry_type": "clipboard" },
{ "op": "delete", "client_id": "3f2a9c1e-7b4d-4e8a-9c2f-1d6b5e0a8f47", "entry_type": "clipboard" },
{ "op": "update", "entry_json": "<serialized encrypted push request>", "entry_type": "note" },
{ "op": "push_local", "client_id": "b81e4d02-5c9a-4f3e-a716-2e9d0c4b7a13", "entry_type": "clipboard" }
]

On reconnect, the queue is flushed in order before the delta pull, so local-device ordering holds under last-write-wins (LWW) conflict resolution.

Op payloads. push/update carry the finished ciphertext, ready to POST. push_local carries only an id. It is for the one push that cannot be pre-encrypted and parked:

  • It covers a blob-backed entry whose upload could not reach the server (an image, or a file entry’s ZIP archive). The blob has to go up before the entry can, so there is nothing to serialize while offline.
  • The flush re-reads the local entry and re-runs the whole push, blob included.
  • Only a retryable blob failure queues one. A genuine refusal (over the 5 MB limit, or the account out of room) is still a recorded skip.
  • Why: without it, an image or file copied offline was reported “not sent” and dropped, so it never synced even once the connection came back.

The in-flight file. A flush moves its ops through sync_pending.inflight.json rather than clearing the queue file first:

  • drain writes them there before emptying the queue.
  • settle requeues whatever did not send, and only then removes the file.
  • load folds a leftover copy back in at the front.
  • Between the two, pending_keys still names the drained ops, so the exit flush and the card badges treat them as queued.

Why: the extra write covers an asymmetry between op kinds.

  • A lost Push or Update is recovered. The server deduplicates by client_id, and the entry is still on this device to send again.
  • A lost Delete is not. The tombstone is the only record that the user deleted anything, so dropping it leaves the row on the server and the next pull hands the entry back.

Permanent rejections. settle requeues what could not send, which is not the same as what will not:

  • A 400, 413 or 422 (ApiError::is_permanent_rejection) means the server read the body and will refuse it identically on every later flush. The op is dropped and recorded as a skip the user can read, instead of being retried forever.
  • The list is deliberately short. A 401 or 403 is equally non-transient, but says the session is wrong rather than the payload. Dropping a queued entry for one of those would be data loss nobody asked for.
  • Push size is also checked locally before a send (refuses_inline_size), so the ordinary oversized case never reaches this path or the network.

Deletes are written ahead, and stay deleted.

  • delete_entries queues every tombstone with one write (push_many) before sending any, so a crash between the local removal and the send leaves the Delete on disk.
  • A landed tombstone leaves the queue in memory only (remove_delete). The fan-out writes the queue once when its last answer is in (BatchAnswer), so a large Clear all is not quadratic in disk writes.
  • A Delete that replays is a repeat tombstone, harmless unless the entry was uploaded again meanwhile. An accepted push therefore drops any Delete still queued for its entry (supersede_delete), but only while the entry is still here: a removal made while the push was in the air is the newer one.
  • With sync off, forward_deletes writes a local_only marker for each item this device knows is in the cloud (IdMap::mark_removed_offline), one write per call. No tombstone is sent: queued with no client, it would replay under whichever account signs in next. The item stays on the server and on other devices, and the marker keeps it off this one.
  • A merged live row for a key with a queued or in-flight removal is skipped, and a sweep re-checks removed_this_run before inserting.

One flush at a time. flush_and_pull is serialised on its own lock. Its triggers are manual Sync now, login, a socket reconnect, the background tick and a window refocus. Why: two runs at once start from the same cursor and walk the same pages. They race each other writing last_server_ts, and re-download the same blobs off a metered quota.

Triggers that work from the tray. Two of those triggers keep a device syncing while it is minimized to the tray, where the window never refocuses:

  • Every WebSocket reconnect runs a flush + delta pull in the listener, as well as its spaces reconcile. The socket only carries what arrives after it comes up. The flush + pull recovers a push that queued during the gap, and an entry another device sent while this one was down.
  • The 5-minute background loop runs in every non-manual mode, not only passive. It is a single delta pull from last_server_ts. It returns nothing when the socket already kept up, but flushes a stuck queue that no socket event happened to trigger.
  • Manual mode is the only one held back: it flushes solely on Sync now.

{app_data}/sync_pending_work.json names every entry key with an upload or a blob download that has started and not finished:

{ "upload": ["clipboard:3f2a9c1e-7b4d-4e8a-9c2f-1d6b5e0a8f47"], "download": ["note:b81e4d02-5c9a-4f3e-a716-2e9d0c4b7a13"] }
  • Recorded in spawn_push_clipboard_entry and spawn_push_note after their skip checks, and in merge_pulled before a blob download starts. Every change is written to the file at once, so it never names finished work or misses started work.
  • Cleared when the task ends (WorkGuard): after the server’s answer is recorded, the push is queued, the download lands and its id_map row is written, or the work is refused. Two tasks for one key clear it only when both end. A push whose slot comes after a quit began defers instead: the record stays.
  • Kept when the process ends first. SyncClient’s Drop closes the store before the runtime drops its tasks, so their guards leave the records alone.
  • Re-driven after each flush_and_pull that succeeds, so a delete another device made meanwhile has landed first and a re-sent upload never undoes it. An upload goes out as the user’s own push, an entry no longer here is forgotten, and a download becomes a restore owed. In Manual mode that waits for Sync now.
  • Reset with the id map when a different account signs in.

It is separate from sync_pending.json because queue ops carry ciphertext and replay rules; this file holds only keys.

Unreadable records. id_map.json, sync_pending.json, its in-flight file and sync_pending_work.json load through persist::load_json_checked. A file that is there and will not read or parse:

  • loads empty, with an unreadable flag set on its store,
  • is never written for the rest of the session, so it stays as it was,
  • and is noted in crash.log.

Any flag makes SyncClient::keep_keys answer “keep everything” for the session. A missing file is an empty record, as before.

All cryptography runs here. Nothing outside this module touches raw key material.

Function Description
derive_master(password, email) -> [u8; 32] One Argon2id pass over the password, salted with the normalized address; the exact parameters are the backend doc’s (they must match cross-device or unwrap fails)
derive_auth_key(master) -> String The HKDF half sent to Supabase as the account credential. The only password-derived value that leaves the device
derive_kek(master, kdf_salt) -> [u8; 32] The HKDF half that wraps the UMK; never leaves the device
derive_legacy_kek(password, kdf_salt) / unwrap_umk_legacy Read-only path for an envelope written before the split (umk-envelope-v1), so finalize_session can re-wrap it
wrap_umk(kek, umk) -> String / unwrap_umk(kek, b64) Wrap/unwrap the random UMK envelope (umk-envelope-v2); unwrap fails => wrong password
encrypt(key, plaintext, aad) -> String base64(nonce || AES-256-GCM(key, plaintext, aad))
decrypt(key, ciphertext_b64, aad) -> String Decode base64 -> split nonce -> AES-256-GCM decrypt
generate_device_keypair() -> (privkey, pubkey) Generates device keypair; privkey stored in OS keychain
x25519_shared_secret(privkey, peer_pubkey) -> [u8; 32] ECDH for device key handshake and space key wrapping
space_key_fingerprint(key) -> String Truncated hash the owner publishes so a member can tell a genuine keyring from one another member made up
random_key() -> [u8; 32] Random key: the UMK, a space key, or a per-entry CEK
wrap_key(wrapping_key, key) -> String / unwrap_key(...) Wrap/unwrap a CEK or space key; failure means wrong key
pkce_pair() -> (verifier, challenge) S256 pair for an OAuth or password-reset hop
store_reset_verifier / load_reset_verifier / clear_reset_verifier The reset verifier in the OS keychain, install-scoped - the two halves of a reset are usually separated by a restart
generate_recovery_code() -> Zeroizing<String> 150 bits, six groups of five, look-alike characters removed
normalize_recovery_code(input) Dashes and spaces out, uppercased - whatever the user types back derives the same key
wrap_umk_recovery / unwrap_umk_recovery The recovery envelope: Argon2id(code, kdf_salt) with AAD umk-recovery-v1

Encryption invariant: callers pass the UMK in at call time from the in-memory SyncClient state. It is never written to disk. crypto.rs receives it as a &[u8; 32] reference.

AAD (additional authenticated data) is the entry’s client_id. It binds each ciphertext to its entry, which blocks moving a ciphertext onto another entry.

Command Signature Description
sync_login (email, password, device_name) -> Result<SyncUser> Sign in via Supabase Auth; POST /auth/bootstrap (derive UMK from kdf_salt); POST /auth/devices (register device); cache the Supabase session
sync_logout () -> () Sign out of Supabase; clear UMK; optionally deactivate the device
sync_get_user () -> Option<SyncUser> Returns cached login info if authenticated
sync_get_status () -> SyncStatusInfo { connected, pending_count, skipped_count, skipped, last_synced_at }
sync_now () -> () Flush the queue, pull the delta, then sweep if a restore is due
sync_restore_from_cloud () -> RestoreOutcome Flush the queue, then sweep the whole account for rows missing here (Restore from cloud)
sync_set_enabled (enabled: bool) -> () Toggle sync; persists to settings.json
sync_set_mode (mode: String) -> () Cloud sync mode for this device, realtime, passive or manual; persists to settings.json
sync_get_mode () -> String Current cloud sync mode
sync_schedule_settings () -> () Schedule a settings round after a user edit to a ROAMING_STORAGE key (see Settings Sync); nothing to do without a SyncClient
sync_settings (json: String, round: u64) -> () One settings round (see Settings Sync). React invokes it on sync:collect-settings; json is its localStorage part, round the number the event carried
sync_settings_refused (json: String) -> () React refused values a newer build wrote; resets their base so the next round does not push over them
sync_reset_password (email: String) -> Result<()> Mint a PKCE pair, keep the verifier in the keychain, ask Supabase to mail a link to reset_page_url
sync_complete_password_reset (code, new_password, recovery_code?, device_name, start_over) -> Result<SyncUser> Redeem the emailed code, recover the UMK, re-wrap it under the new password, then sign in. start_over mints a new key and gives up the old data
sync_change_password (new_password: String) -> Result<()> Signed-in password change: re-wrap the in-memory UMK, then set the password. Cannot lose anything
sync_create_recovery_code () -> Result<String> Mint a code, wrap the UMK under it, store the envelope, return the code once. Also how regenerating works - storing revokes the previous code
sync_has_recovery_code () -> Result<Option<bool>> Whether the account has an envelope. None = not known (no session), which the UI must not read as “no”

More sync commands (summary). Names and one-line purposes only; the full signatures live in sync/commands.rs.

Auth and session:

Command Purpose
sync_signup Supabase sign-up, then bootstrap + device registration
sync_oauth_begin Phase 1 of Google sign-in: the browser handshake
sync_oauth_complete Phase 2: finalize the stashed session with the account password
sync_oauth_pending The OAuth attempt still waiting on a password
sync_oauth_cancel Discard a stashed OAuth session
sync_cancel_password_reset Drop the held reset session when the panel closes
sync_restore_session Whether a session is coming back, from local state (see above)

Skipped and unsynced items:

Command Purpose
sync_clear_skipped Dismiss the skipped-entries list
sync_retry_skipped Re-push everything previously skipped
sync_preview_unsynced Measure a bulk upload (count, image bytes, free space) first
sync_push_unsynced Push every local item the server has never seen
sync_push_entries Push the named items, pushed before or not
sync_unpush_entries Tombstone the named items server-side, keep local copies
sync_unpush_all Tombstone everything this account holds on the server

Server and state queries:

Command Purpose
sync_server_entry_count How many rows this account still has server-side
sync_server_breakdown Server rows split by clipboard/notes and by kind
sync_bulk_progress Where a bulk upload or removal has got to
sync_get_entry_owners Which account wrote each received entry
sync_get_entry_arrivals When each received entry reached this device
clock_offset_ms This machine’s measured clock error against the server
sync_get_deleted_markers Placeholders for items removed from a space
sync_get_entry_states Per-entry sync state for the card badges (useEntrySyncStates())
sync_catch_up Cheap refresh after the window returns to the foreground
sync_get_connection Whether this build knows which deployment to talk to

Devices and addressed invites:

Command Purpose
sync_get_quota Blob storage used and available
sync_list_devices Devices registered to this account
sync_revoke_device Deactivate another device (never this one)
sync_list_invites Received and sent invites
sync_send_invite Invite an existing account to a space by email
sync_accept_invite / sync_decline_invite Answer a received invite
sync_revoke_invite Withdraw an invite you sent

Space commands (all sharing goes through these):

Command Signature Description
spaces_list () -> Result<Vec<Space>> Reconcile with the server: fetch spaces, recover or mint keys, prune spaces we were removed from. Does network + key work
spaces_cached () -> Vec<Space> The cached list, no network. What presence ticks and the share menu read
space_create (name: String, share_history: Option<bool>) -> Result<Space> Create a space, mint its first key, register the wrapped key for yourself
space_join (invite_code: String) -> Result<()> Join by code (pasted links and casing are tolerated); the owner wraps a key for you on its next reconcile
space_leave (space_id: String) -> Result<()> Leave; the server clears the remaining members’ wrapped keys so the owner rekeys
space_delete (space_id: String) -> Result<()> Owner only; deletes the space for everyone
space_remove_member (space_id: String, member_user_id: String) -> Result<()> Owner only; removal triggers the rekey path
space_set_entry_shares (entry_id: String, entry_type: String, space_ids: Vec<String>) -> Result<()> The explicit share gesture. Re-pushes the entry with the CEK wrapped for exactly these spaces
sync_get_entry_shares () -> HashMap<String, Vec<String>> Space ids per item, keyed "clipboard:{id}" / "note:{id}" - feeds the card indicators and share checklists
sync_get_remote_entries () -> Vec<String> Same keys, for items another member wrote (their CEK unwrapped through a space keyring, never "personal") - the direction glyph on space rows
space_set_autocopy (space_id: String, enabled: bool) -> Result<()> Per-space, per-device: write incoming space entries to the clipboard
space_set_send_filter (space_id: String, filter: SendFilter) -> Result<()> What of yours flows into that space automatically; stored in the synced settings blob
space_get_send_filters () -> HashMap<String, SendFilter> All send filters, by space id

More space commands (summary):

Command Purpose
space_join_requests People waiting to be let into a space (for approvers)
space_my_join_requests Spaces this user asked to join and is waiting on
space_approve_join Let somebody in; the space key rides with the approval
space_decline_join Turn a join request down
space_set_members_can_approve Owner: choose whether members may approve joins
space_invite_link Build the shareable https link for an invite code
space_remove_entry Take a shared entry down from a space (owner, or its author)
space_set_share_history Owner: let members read what was shared before they joined
space_comment_add Post a comment on a shared entry
space_comments_list One entry’s comment thread, oldest first
space_comment_counts Comment tallies for a whole space’s feed
space_comment_delete Delete a comment (author or space owner)
space_clear_removed Drop the removed-entry placeholders for one space

kind: 'file' entries captured from CF_HDROP sync like an image, with one blob per entry. The difference: the blob is a ZIP of everything the entry names, so the single blob_key/blob_size columns carry any number of files and folders. This reuses the image blob path (upload_files_blob mirrors upload_image_blob); the crypto, quota, retry and skip handling are identical.

Push (spawn_push_clipboard_entry, the File arm):

  1. Sum the entry’s input bytes, recursing into folders (a folder path’s own metadata length is not its contents). Over 5 MB -> recorded skip, no upload.
  2. zip_paths_to_bytes packs each newline-separated path into one in-memory ZIP, preserving top-level names (disambiguated name (2) on a basename clash) and any folder structure. Empty/unreadable entries are skipped.
  3. Encrypt the archive with encrypt_bytes (same CEK as the entry’s row), then request-upload -> pre-signed PUT -> confirm-upload. A second, exact size gate rejects ciphertext over 5 MB.
  4. Inline encrypted_content is a tiny descriptor, {"archive":"zip"} (the ZIP is self-describing); blob_key/blob_size point at the object.

A retryable upload failure (server unreachable) queues a push_local and lights the amber “waiting to upload” badge, as for an image. A genuine refusal (over 5 MB, or the account out of room) is a recorded skip. Not signed in -> skip.

Receive (spawn_blob_files_merge, mirroring spawn_blob_image_merge):

  • When the entry is already here and every path it names exists, the row is newer metadata for it: the files stay, and nothing is downloaded.
  • Otherwise download the blob, decrypt it, and extract_zip_to_dir into {app_data}/received-files/{client_id}/. The archive goes into {client_id}.incoming first and is renamed into place once whole, and the old tree goes only after that. A failed extract leaves the old tree as it was. enclosed_name blocks zip-slip.
  • entry.content becomes the extracted top-level paths, so the entry copies and pastes as a normal file-drop on the receiving device.
  • A file entry from before this was wired has no blob_key. Nothing is materialized, and it stays local-only on the sender.

The same flow applies to video files (CF_HDROP paths to .mp4, .mov and so on). The 5 MB check is per clipboard entry (the recursive sum across that one clipboard event), not per file.

A Space is the only sharing primitive: persistent, live, with any number of members, and a user can be in several at once. One entry can land in all of them, which is why each entry has its own content key.

Encryption envelope. For every push the client mints a random 32-byte CEK, encrypts content and metadata once under it (AAD = client_id), then wraps the CEK:

  • once under the UMK - so your own devices can always read your own entry without holding any space key;
  • once under keyring[0] of each target space.

To read a shared entry, the client unwraps the CEK with the first carried space id it holds a key for. It tries that space’s keyring in order: an AES-GCM authentication failure means “try the next key”, so no epoch tracking is needed. The exact on-wire shape of the wrapped-key map and the routing array is the wire contract: orange-copy-paste-clipboard-backend/docs/architecture.md.

Where an entry goes is the union of two sources, evaluated on push:

  1. Explicit shares - the spaces the user picked from the card menu or bulk bar, recorded in id_map.json under entry_shares. Authoritative for entries already pushed.
  2. Send-filter matches - every space whose SendFilter { enabled, kinds, groups, content } matches, each evaluated independently. The default is enabled: false, so nothing flows automatically until the user turns it on. Filters live in the encrypted settings blob, so they roam between devices and the server never sees them. Editing a filter affects future entries only; history is never mass-shared retroactively.

No matches means personal-only: one wrap, no space_ids. Local group tags do not imply sharing; they are only filter inputs.

Space keys. Each space has a keyring (Vec<[u8; 32]>, newest first). The client recovers it from the server-side wrapped keyring, which is X25519-wrapped to a member’s identity public key. New entries encrypt under keyring[0].

Who hands a key over: any member holding it. reconcile_spaces wraps the ring for every member who lacks one, whichever member’s app is running. Three pieces make it safe:

  • SpaceOut.my_wrapped_by says whose public key opens our wrap. Null means the owner, so rows written before this keep working.
  • spaces.key_fingerprint is written by the owner alone, when it mints. A recipient checks the newest key of a received ring against it (crypto::space_key_fingerprint) and refuses on mismatch, because a correctly wrapped wrong key unwraps fine and then decrypts nothing. A refusal emits space:key-rejected and is not retried - waiting cannot turn a wrong key into the right one; the owner removing whoever sent it rekeys the space.
  • Minting stays the owner’s, so exactly one account decides what the current key is. A non-owner also sits out a pending rekey rather than handing over a ring about to be replaced.

Why: handing over used to be the owner’s job alone. A member who joined while the owner’s app was closed could neither read the space nor write to it until the owner’s app opened, and nobody could shorten that wait. Nothing is given up, because every member already holds the key in memory and could pass it on by other means.

A new member usually has the key before they ask.

  • attach_invite_key wraps the ring for the invitee’s identity key when the invite is sent, and PUT /invites/{id}/key parks it on the invite. Accepting moves it onto the membership.
  • The invite route refuses an address with no account, so the invitee’s key is registered by then.
  • Best-effort: the invite is valid without it, and the ordinary path still covers them.

Rekey. A member being removed (or leaving) clears the other members’ wraps and sets spaces.rekey_requested_at; the owner’s next reconcile prepends a fresh key and redistributes.

  • Older keys stay in the ring, so old entries stay readable.
  • The owner’s wrap is deliberately left alone - see bug #13.
  • Revocation is best-effort: the removed member keeps whatever it already pulled, and never receives the new key.

Sharing into a space with no key is queued, not refused.

  1. space_set_entry_shares records the intent whichever way.
  2. share_targets drops a keyless space on the way out, so nothing unreadable is pushed.
  3. flush_pending_shares re-pushes those entries when space:key-received fires.

The record in id_map.json is the queue, so it survives a restart and there is no second store to keep consistent. The UI shows it as a dimmed share chip and a “waiting” row in the share menus.

Four things reach the notification centre, not only a toast. Why: a toast fired while the window is hidden is a toast nobody saw, and all four happen when the user is usually elsewhere.

Raised by Kind Row
note_space_readable SpaceActivity A space became readable, and how many held shares went out with the key. One row per space (space-key:{id}), so a later rekey cannot raise a second - a rekey is not the user gaining access.
note_comment SpaceActivity Somebody commented on an entry we wrote, or named us. Keyed on the comment id so a reconnect replaying space:comment cannot report the same reply twice.
note_join_requested / note_join_approved SpaceInvite / SpaceActivity Somebody asked to join a space this user may approve, and the answer to this user’s own ask. Both are the same problem as the rows above: they land while the window is hidden, and the approver is the only thing standing between the joiner and a space.
note_space_key_rejected SyncWarning A keyring failed the fingerprint check. A warning because the space stays unreadable and nothing the user does in the app changes that.

note_comment is deliberately narrow and carries no comment text. It skips two other members talking on a third person’s item, since this user is not in that conversation. It leaves the decrypted body out because the notification store outlives the entry it points at.

Auto-copy. Per space and per device (space_autocopy:{space_id} in settings.json, deliberately not synced). Only WebSocket-delivered space entries can trigger it, never personal cloud-sync entries and never a pull page, so a backfill cannot flood the clipboard. The write goes through the shared suppress-then-write helper, so the watcher dedupe invariant holds and the active-clipboard id stays correct.

Passive cloud sync. sync_mode is device-local.

  • In passive, the WebSocket stays connected, so spaces, presence, invites and rekeys stay live.
  • Personal entries arriving over the socket are not applied. The 5-minute loop and manual Sync now pull them.
  • Pushes are always immediate.

Why it is safe: last_server_ts advances only in the pull path, never on a socket-applied entry. Anything skipped live is guaranteed to arrive on the next pull.

The account holds one settings blob, encrypted under the UMK. Each device merges it with its own values key by key; no device’s copy replaces the blob whole.

Roams (user preferences):

  • From localStorage (ROAMING_STORAGE in App.tsx): theme, layout, sort, paste_slots, group_names, group_colors
  • From settings.json (ROAMING_JSON_KEYS in sync/commands.rs): close_to_tray, start_minimized, notification, notif_copy, notif_paste, space_send_filters

Send filters ride in the blob so they roam between a user’s devices; the server never sees the plaintext group names they reference.

Per device (never applied from the blob):

  • keep_history and autosave (NO_LONGER_ROAMING). Older builds roamed them, so the blob keeps the account’s values for those builds to read, but this build never applies or changes them.
  • sync_enabled, sync_server_url, sync_mode, os_notifications, space_autocopy:{space_id} - each device decides independently
  • Window geometry, autostart, and every other settings.json key not listed above

One settings round (sync_settings, one at a time on settings_lock):

  1. schedule_settings_sync waits out the debounce (SETTINGS_DEBOUNCE_SECS, 2s after the latest call, so a burst of calls makes one round), then emits sync:collect-settings with the current round number. React answers with its localStorage part and that number. A round that waited on settings_lock behind another finds the number moved on: its snapshot predates what that round applied, so it asks for a fresh one and ends. Every round that gets past this check, and every sync_settings_refused, moves the number on.
  2. Pull the blob. One this device cannot decrypt ends the round with no push: it may be the account’s only copy.
  3. Merge (merge_settings) the account’s values, this device’s, and settings_base, this device’s roaming values as of its last round (in sync_state.json). A key changed here since the base keeps this device’s value; every other key takes the account’s. With no base (first round on this device or account), the account wins and local values only fill keys it lacks.
  4. Apply what the account changed. settings.json keys are written and stored into their in-memory flags at once; the localStorage keys go to React as sync:settings. When a base exists, it takes the values that landed at once, before any push, so a round that ends before step 6 (the push failed, another push won, or the app closed mid-push) does not leave them reading as changes made here. The localStorage values always land; the settings.json ones only when the write did. A first round has no base to add them to.
  5. Push only when the merge differs from the account’s blob, stamped later than the pulled one. The push names the pulled blob’s own updated_at (0 when there was none) as base_updated_at, so the server stores it only over the blob it was merged from (the rule: orange-copy-paste-clipboard-backend/docs/architecture.md, section 5.3). If the server reports that another push won, nothing was stored: the base does not move to the merge but keeps what step 4 gave it, and another round merges against the newer blob. Rounds repeat this way only while another device keeps storing a blob between this device’s pull and push.
  6. Move the base to the merged blob, unless the settings.json write failed.

Empty values never roam. is_unset reads null, "", "[]" and "{}" as no value, so an older build’s "" layout or "[]" groups cannot wipe a choice. A group list emptied here stays empty here: it is not pushed, and the account’s list is not put back while it still matches the base. React drops a layout, sort or theme value it does not know (from a newer build) and reports it through sync_settings_refused, so the next round does not read this device’s value as a change and push it over.

Triggers: sign-in and session restore (start_ws_listener), the settings:updated socket event (including the echo of this device’s own push), set_setting for a roaming key, a send-filter change, and a user edit to a ROAMING_STORAGE key, which React reports right after the write through scheduleSettingsSync in types.ts (the sync_schedule_settings command). That command is gated like set_setting: it schedules whenever a SyncClient exists, in any sync mode, and a round while signed out ends before its pull. Nothing else that writes those keys schedules: a value a round applied, the load-time cleanup of values an older build roamed, and the startup group recovery are not edits made here.

Why: a fresh install used to push its whole blob, defaults and blanks included, minutes after sign-in, over the account’s (bug #31 in docs/bugfix-history.md).

Limit. Two devices that change the same key in the same moment end on the value stored last. Changes to different keys both survive, but only on a server that honors base_updated_at. A server that predates it ignores the field (its request model does not forbid unknown fields), so this build still syncs with one as last-write-wins: that server keeps whichever push is stamped later, and a change another device makes in the same moment can be lost.

Sync adds these keys to the existing settings.json store:

Key Type Default Description
sync_enabled bool false Master toggle for all sync behavior
sync_server_url string DEFAULT_SERVER_URL Backend API base URL; set to override the compiled default (self-hosting)
supabase_url string DEFAULT_SUPABASE_URL Supabase project URL - used for auth (GoTrue)
supabase_anon_key string DEFAULT_SUPABASE_ANON_KEY Supabase anon (publishable) key - client-side auth only
reset_page_url string DEFAULT_RESET_PAGE_URL Where a password-reset mail lands; must match a Supabase Redirect URLs entry exactly
sync_mode string "realtime" realtime, passive or manual - how personal cloud-sync entries move on this device
space_autocopy:{space_id} bool false Write entries arriving from that space to the clipboard, on this device only
space_send_filters object {} Per-space SendFilter; synced, unlike the two keys above

The four DEFAULT_* values are compiled in from src-tauri/src/sync/config.rs, the single source for which deployment a build ships against. A key present and non-empty in settings.json wins over its constant; an absent or empty key falls back to it.

clipboard_watcher.rs - Background Polling Thread

Section titled “clipboard_watcher.rs - Background Polling Thread”
  • Dedicated thread with 220ms polling interval.
  • Windows: Uses GetClipboardSequenceNumber() to detect changes cheaply via a change token.
  • Linux: No change token available. The watcher reads the clipboard every cycle and compares it against the last captured content.
  • On change, calls capture_clipboard_change():
    • Checks suppress flag (skips if set by user action).
    • Reads clipboard via read_clipboard_capture().
    • Deduplicates against top history entry.
    • Pushes to history, emits clipboard:new-entry to all windows.
    • Updates active_clipboard_id and emits clipboard:active-id.
    • Shows copy notification if enabled.
    • If keep_history is enabled, marks history_dirty for background flush.
  • Advances the sequence token only when capture succeeds. If the clipboard was locked, the next poll retries.

Ctrl+Shift+C (handle_copy_shortcut):

  1. Toggle-hide copy popup if already visible.
  2. Set suppress flag (prevents watcher race).
  3. Simulate Ctrl+C (with 120ms delays before/after).
  4. Read the clipboard.
  5. Push to history (deduplicated).
  6. Update active_clipboard_id, emit clipboard:active-id.
  7. Only if step 5 inserted a new entry: emit clipboard:new-entry and show the copy popup with the entry preview.

Ctrl+Shift+V (handle_paste_shortcut):

  1. Toggle-hide paste popup if already visible.
  2. Lock history, grab top 200 recent (PASTE_HISTORY_CAP) + all pinned (bounded by MAX_PINNED).
  3. Emit paste-popup:entries to paste popup, show it near cursor.
Section titled “popup_windows.rs - Multi-Window Management”

Creates popup windows at startup (hidden, off-screen, frameless, transparent, always-on-top, skip-taskbar):

  • copy-popup (340x260) - Copy confirmation with preview.
  • paste-popup (360x540) - Quick paste list with keyboard shortcuts.
  • notification (320x104) - Brief “Copied”/“Pasted” toast at bottom-right of screen.

Hiding: hide_popup() moves the window to (-9999, -9999) before calling hide(). Why: an invisible window left in place would intercept mouse clicks on the content underneath.

The module also sets up a handler that hides all popups when the main window gains focus.

notifications.rs - Copy/Paste Notifications

Section titled “notifications.rs - Copy/Paste Notifications”

Manages the “notification” popup window that appears briefly at the bottom-right of the screen:

  • show_notification(app, entry, action) - Positions the notification window and emits a notification:show event with a NotificationPayload { kind, action }.
  • notify_if_enabled(app, entry) - Shows a “Copied” notification if both the master toggle (notification_enabled) and per-type flag (notif_copy) are enabled.
  • notify_paste_if_enabled(app, entry) - Shows a “Pasted” notification if both notification_enabled and notif_paste are enabled.

The frontend Notification.tsx component renders a dynamic label and icon (clipboard icon for “Copied”, paste icon for “Pasted”) based on the action field.

platform/mod.rs selects the correct submodule at compile time via #[cfg] gates and re-exports a uniform API. It also contains the cross-platform popup_position() function.

Function Windows (platform/windows.rs) Linux (platform/linux.rs)
simulate_copy() Releases Shift/Ctrl, sends Ctrl down, C down, C up, Ctrl up via keybd_event xdotool key --clearmodifiers ctrl+c (X11) or wtype -M ctrl -P c -p c -m ctrl (Wayland); ydotool if that fails
simulate_paste() Releases Shift/Ctrl, sends Ctrl down, V down, V up, Ctrl up via keybd_event xdotool key --clearmodifiers ctrl+v (X11) or wtype -M ctrl -P v -p v -m ctrl (Wayland); ydotool if that fails
popup_position(w, h) (cross-platform in mod.rs) - near cursor, clamped to work area (same)
cursor_pos() GetCursorPos() -> physical pixel coordinates xdotool getmouselocation -> parse x/y
work_area_for_point(x, y) MonitorFromPoint + GetMonitorInfoW xdpyinfo -> parse dimensions: line
scale_factor_for_point(x, y) GetDpiForMonitor -> DPI scaling factor Returns 1.0 (Wayland compositors handle scaling)

Note on simulate_paste (Windows): Before sending Ctrl+V, the function releases Shift and Ctrl. Why: if the user still held Shift from the shortcut, the OS would otherwise see Ctrl+Shift+V instead of Ctrl+V.

Note on Linux: is_wayland() returns true when WAYLAND_DISPLAY is set and non-empty, or when XDG_SESSION_TYPE is wayland. On Wayland the app tries wtype, then ydotool; on X11 it tries xdotool, then ydotool.

Sets up a system tray icon with a context menu:

  • Show Orange Copy Paste - Shows/unminimizes the main window.
  • Quit - Exits the app.

Left-clicking the tray icon also shows the main window. With the close_to_tray setting on, closing the main window hides it to the tray instead of quitting.

Tracks every move and resize, and saves position, size and maximized state to {app_data}/window-state.json shortly after. Restores on startup with guards: minimum 200x200 dimensions, and a re-center if the position is off-screen.

commands.rs - Window-Control & Lifecycle Commands

Section titled “commands.rs - Window-Control & Lifecycle Commands”

Handlers for the popup and splash windows plus a few app-level toggles. The frontend drives popup sizing and reveal through these.

Command Purpose
present_copy_popup Reveal the copy popup at a measured height (one IPC hop)
close_copy_popup Hide the copy popup
close_paste_popup Hide the paste popup
resize_copy_popup Resize the copy popup, re-clamped to its monitor
resize_paste_popup Resize the paste popup, re-clamped to its monitor
clamp_popup Snap a dragged popup back onto its monitor
close_notification Hide the copy/paste notification toast
close_splash Close the startup splash from its own webview
splash_set_updating Splash reports it is mid auto-update (holds the close timer)
ui_screen_changed Frontend reports the active screen
ui_space_changed Frontend reports the selected space
open_data_folder Open the app-data folder in the OS file manager
get_autostart Whether run-on-startup is enabled
set_autostart Toggle run-on-startup (refused in debug builds)

health.rs runs the panic hook, heartbeat watchdog, atomic writes and quarantine (see section 4.1, Shutdown and the rotation window).

load_state keeps a .bak of each state file’s last copy that parsed. When a file loads at under a quarter of its .bak, the old .bak moves to .bak.prev first, which nothing reads automatically. A real Clear all cannot come back through a recovery, and a loss still leaves the larger copy on disk. These commands are polled at startup because the matching events only reach listeners attached when they fired.

Command Purpose
health_degraded_reason Why the process is degraded, or None while healthy
health_trouble Why saving is not working, or None while it is
health_recovery_notice What a degraded previous session left behind and this one adopted
health_sealed_notice Which store could not be read at startup and is being left alone
health_restart_app Restart the app to rebuild in-memory state from disk

updater.rs is the signed self-update flow. Updates are off in tauri dev by design, so this path is only exercised from published builds.

Command Purpose
updater_check Ask the feed for a newer version
updater_pending Whatever the last check found, for a late-mounting window
updater_current_version The running version, for display
updater_download Download and signature-verify the pending bundle (emits updater:progress)
updater_skip_version Stop offering a version until a newer one appears
updater_install Install the downloaded bundle; ends the process

Rust pushes state changes to the webviews as namespaced domain:event Tauri events (kebab-case), distinct from the backend WebSocket messages the sync listener consumes (those are in section 4.6, ws_listener.rs). Several sync events below are the Tauri-side re-emit of a socket message the listener received. This table summarizes the emitted events; the code is authoritative.

Event Emitted from Meaning
clipboard:new-entry runtime/clipboard_watcher.rs A capture was added
clipboard:entry-deleted clipboard/commands.rs An entry (or bulk selection) was removed
clipboard:entry-pinned clipboard/commands.rs An entry’s pinned flag changed
clipboard:entry-groups-changed clipboard/commands.rs An entry’s group tags changed
clipboard:active-id clipboard/commands.rs The entry now in the OS clipboard changed
clipboard:copied runtime/hotkeys.rs Ctrl+Shift+C captured an entry (copy popup)
paste-popup:entries runtime/hotkeys.rs Entries for the paste popup
notification:show runtime/notifications.rs Show the copy/paste toast
notifications:changed notifications/commands.rs The notification feed changed
ui:cue notifications/mod.rs Play a sound cue by name
health:trouble health.rs Saving started failing, or recovered (None)
health:degraded health.rs The process entered a degraded state
updater:available updater.rs A newer version was found
updater:progress updater.rs Download progress, one event per whole percent
updater:ready updater.rs The bundle downloaded and verified
clock:offset-changed sync/mod.rs The measured clock offset changed
sync:status-changed sync/mod.rs, sync/ws_listener.rs Connection state changed
sync:signed-out sync/mod.rs The session was cleared
sync:device-revoked sync/mod.rs The server reported this device as revoked
sync:session-restored / sync:restore-gave-up sync/mod.rs Outcome of a background session restore
sync:oauth-ready sync/mod.rs The OAuth handshake landed; a password is needed
sync:history-merged / sync:notes-merged sync/mod.rs Pulled/merged entries; the UI re-fetches
sync:entry-queued sync/mod.rs A push was queued offline
sync:entry-skipped sync/mod.rs A push was refused (size or quota)
sync:entry-synced / sync:note-synced sync/mod.rs A push was acknowledged by the server
sync:collect-settings sync/mod.rs Ask React to hand down its localStorage settings. Payload { round }, passed back to sync_settings
sync:settings sync/commands.rs The localStorage keys a settings round took from the account
sync:device-presence sync/ws_listener.rs A device went online or offline
sync:join-requested / sync:join-decided sync/ws_listener.rs A join request was made / answered
sync:invite-received / sync:invite-updated sync/ws_listener.rs An addressed invite arrived / changed
sync:invite-answered sync/commands.rs An invite was answered on this device
space:membership-changed sync/ws_listener.rs Space membership changed
space:presence-changed sync/mod.rs A space member’s presence changed
space:comment-added / space:comment-removed sync/ws_listener.rs A comment on a shared entry changed
space:key-received / space:key-rejected sync/mod.rs A space keyring arrived / failed its fingerprint check
spaces:join-code / sync:password-reset lib.rs A deep link routed to a screen (section 4.6)

Backend WebSocket messages (such as device:online, settings:updated, space:membership_changed, space:rekey, announcement:new and ping) are received by ws_listener.rs, not emitted to the webview directly; see section 4.6.


Vite is configured for a multi-page build (five separate HTML entry points -> five separate JS bundles):

Window Entry HTML Entry Component Dimensions
main src/components/app/index.html App.tsx 920x560, resizable
copy-popup src/components/copy-popup/copy-popup.html CopyPopup.tsx 340x260, frameless
paste-popup src/components/paste-popup/paste-popup.html PastePopup.tsx 360x540, frameless
notification src/components/notifications/notification.html Notification.tsx 320x104, frameless
splash src/components/splash/splash.html SplashScreen.tsx 340x76, frameless

Dev server runs on port 1420 (fixed for Tauri dev mode).

types.ts defines the core ClipboardEntry interface matching the Rust struct, plus helpers:

interface ClipboardEntry {
id: string;
type: "text" | "image" | "file" | "html"; // serde renames "kind" → "type"
content: string;
timestamp: number;
pinned: boolean;
groups: string[];
label?: string; // e.g. "Image Mar 17, 2:45 PM"
}
type AppScreen = "clipboard" | "notes" | "spaces" | "shortcuts" | "account" | "settings";
type AppTheme = "dark" | "light";

Helpers: timeAgo(), truncateText(), filePaths(), fileNameFromPath(), isImageFile(), isVideoFile(), isUrl(), classifyFileEntry(), deriveDisplayKind(), imageDisplayName(), resolveImageSrc(), htmlFragment(), htmlPlainText(), groupColorIndex(), groupColor(), setGroupColorIndex(), removeGroupColor(), renameGroupColor().

Reusable React hooks under src/hooks/, extracted to de-duplicate cross-screen logic and cut re-renders:

Hook Purpose
useClickOutside Dismiss dropdowns/menus on an outside click
useMultiSelect Multi-select state (selected ids, toggle, range-select, clear)
useSelectionSummary Derived counts/metadata for the current selection
useRelativeTime Relative timestamps driven by one shared ticker (not a timer per card)
useLayoutTransition Animate the switch between the tiles and list layouts
useFileMeta Batched + cached file preview / missing-file lookups (dedupes IPC across cards and popups)

Every surface that shows a rejected invoke goes through userError(e, fallback) in src/userError.ts: toastError (and so every deferDestructive failure), the Spaces and Account screens’ errMsg, the comment thread and useUpdater. Never render the raw rejection string.

  • A rejection that is already a sentence (capital first letter, final period) is shown as it is. This is how the Rust messages written for people reach the screen, and why the Account screen’s two regexes on that text still work.
  • An API failure (<call tag> <status>: <detail> or <call tag>: <transport problem>, built in sync/client.rs) is mapped by status: 401, 402, 403 (with a separate email_unverified message), 429 and 5xx each get one plain message, and a transport failure reads as offline. Any other status shows the server’s detail only if it is a sentence.
  • The three Rust spellings of “no session” (not authenticated, not signed in, sync not enabled) all become “Sign in on the Account screen first.”
  • Anything else falls back to the caller’s own text.

App.tsx is the root of the main window. It owns:

  • entries: ClipboardEntry[] - the full clipboard history state.
  • notes: Note[] - note collection used by the Notes screen.
  • screen: AppScreen - which screen is currently active.
  • theme: AppTheme - dark/light mode (persisted to localStorage).
  • undoSnapshot - snapshot for “undo clear history” (5-second window).
  • activeClipboardId: string - ID of the entry currently in the OS clipboard.

Initialization (on mount):

  1. Subscribe to clipboard:new-entry events (prepend to state, deduplicated by ID).
  2. Subscribe to clipboard:entry-deleted events (remove from state).
  3. Subscribe to clipboard:active-id events (update activeClipboardId state).
  4. Fetch get_history and get_active_clipboard_id from Rust, merge with any entries already received via events. A rejected get_history is logged and retried twice, a second apart. If the last try also fails and no other read has landed meanwhile, a persistent error toast (key history-load) says so, rather than an empty list that reads as no history.

Focus resync: Listens to tauri://focus on the main window. On focus, it re-fetches the full history from Rust to catch up on events missed while the app was in the background. This re-read and the one after sync:history-merged keep the current list when they fail, and take down the history-load toast when they succeed. Until one succeeds, a failed focus re-read raises that toast again, because the app shows one toast at a time and a later one may have replaced it.

The main history view. Entries are grouped by day (“Today”, “Yesterday”, “Mar 6”) and sorted within each group.

Features:

  • Layout toggle: Tiles (CSS grid, variable heights) or List (full-width rows). Persisted to localStorage.
  • Sort: Newest, Oldest, A->Z, Z->A, Type. Persisted to localStorage.
  • Filters (search-filter/SearchFilter.tsx): persisted to localStorage (sc-f-*). The Cloud section and the Mine and From others chips are hidden while no account is signed in, which includes a session that is still restoring (sync_get_user returns nothing until it lands). Their saved choices then leave the list, the funnel badge and the filter strip alone, so they cannot narrow the history out of sight. They are kept, and apply again after sign-in.
  • Day groups: Collapsible with animated transitions.
  • Toolbar: Sort dropdown + layout toggle + clear-all button.
  • Empty state: Placeholder with Ctrl+Shift+C hint.
  • Progressive rendering: renders RENDER_INITIAL_COUNT = 200 cards up front and grows by RENDER_PAGE_SIZE = 50 as the user scrolls (IntersectionObserver, 600px rootMargin). Histories of 1,000+ entries stay responsive. A “You’re all caught up” footer appears at the true end.
  • Memoized cards: EntryCard (and NoteCard) are wrapped in React.memo, so typing in search or toggling selection does not re-render the whole list.

Renders a single ClipboardEntry with type-specific previews:

Type Preview
Text Truncated content (160 chars)
Image <img> via resolveImageSrc() (asset protocol for file-backed, data-URL for inline)
File (single image) Image preview loaded async via get_image_file_preview
File (single video) <video> with controls
File (single other) Filename only
File (multiple) Thumbnail strip (up to 3 images) + “+N” badge, expandable file list

Interactions:

  • Click -> copy to clipboard, 1.5s “Copied!” feedback.
  • Right-click -> context menu (Copy, Pin/Unpin, Save, Groups, Expand/Collapse, Delete) via CardMenu.
  • Relative timestamps update every 15 seconds.
  • “In clipboard” indicator: The entry currently in the OS clipboard gets an accent-colored border and chip. AppState tracks the id (active_clipboard_id) and pushes it to the frontend with the clipboard:active-id event. copy_entry, paste_entry, the clipboard watcher and the copy shortcut handler update it.

Footer chip overflow: The chip bar (type, pinned, saved, in-clipboard, user groups) uses flex-wrap. A measurement pass works out how many group chips fit on the first row and renders a “+N” overflow button for the rest. When all groups fit, no overflow button appears.

Cloud sync indicator: Each card can show a small cloud icon. It is not a field on the entry: the sync_get_entry_states command (useEntrySyncStates()) supplies the per-entry state, and the show_sync_badges setting gates the badge:

  • Filled cloud - synced (a server id is mapped for this entry and it is up to date)
  • Outline cloud - pending (queued in the pending queue, not yet acknowledged)
  • No icon - local-only (sync disabled, badges off, or entry predates sync enrollment)

Among its settings (the screen is the full list):

  • Number-key paste slots: how many entries the paste popup numbers (3-10, default 3). Persisted to localStorage.sc-paste-slots.
  • Keep history across restarts: on by default, per device. Off keeps only pinned, Saved, and cloud or space entries across a restart (History keeping, section 4.2). Setting keep_history in settings.json.
  • Auto-save copied entries: on by default, per device. Tags each new capture Saved. Setting autosave.
  • Close to system tray: hide to the system tray on close instead of quitting. Setting close_to_tray.
  • Start minimized: launch hidden in the tray. Setting start_minimized.
  • In-app popup: master toggle (notification) plus checkboxes for the copy and paste notifications (notif_copy, notif_paste).
  • Show sync badges on cards: toggle for the per-entry cloud icon on cards. Setting show_sync_badges.

Cloud-sync auth (login/logout), the connected-devices list and presence, sync status and the realtime/passive/manual mode control live on the Account screen (AccountScreen.tsx), not here.

  • Rich-text editing: Formatting toolbar with headings, lists, quotes, code, and inline styling.
  • Clipboard/group embeds: Insert clipboard references and group tags into note content.
  • Auto-save: Debounced save while typing plus flush-on-unmount behavior.
  • Pinning and groups: Pin notes and assign shared group tags.
  • Filtering: Search and filter notes by query, groups, date range, and pin state, plus the Clipboard Screen’s cloud, space and owner filters, which follow its signed-out rule.
  • Bulk actions: Multi-select delete/pin/group operations.

Spaces Screen (spaces-screen/SpacesScreen.tsx)

Section titled “Spaces Screen (spaces-screen/SpacesScreen.tsx)”

A dedicated sidebar screen (screen === "spaces") and the single home for sharing.

  • The left pane is the feed of the selected space. It uses the same preview components as the clipboard screen, with its own search, sort and tiles/list layout.
  • Feed membership is server truth only: entryShares["clipboard:{id}"].includes(space.id).
  • The right rail lists the user’s spaces with create and join forms, the received-invite strip (accept/decline), and sent invites with revoke.

The selected space’s header opens Space settings:

  • Incoming - auto-copy switch (space_set_autocopy).
  • Outgoing - auto-share master switch plus Content (clipboard / notes / both), Kinds, and Groups selectors that build the SendFilter (space_set_send_filter). Off by default, so nothing flows without an explicit choice; the header badge shows how many rules are active.
  • Members - owner badge, waiting for key for members the owner has not wrapped a key for yet, online dot, remove (owner only, which triggers the rekey).
  • Invite - invite code with copy code / copy link, and invite by email.
  • Leave or Delete (armed two-click).

It owns the live subscriptions space:presence-changed (re-reads spaces_cached, no network), space:membership-changed, space:key-received, and the invite events. Account-level sync management stays on the Account screen: login/logout, the enable toggle, server URL, devices, storage and the cloud sync mode.

Read-only reference page showing all keyboard shortcuts, organized by section: Global Shortcuts, Paste Popup, Copy Popup, Clipboard Cards, Pin & Save, Groups, System Groups, Search & Filter.

Shown after Ctrl+Shift+C near the cursor. Displays:

  • Type badge (Image / Text / N Files).
  • Content preview (text truncated to 200 chars, image thumbnail, file names).
  • Pin and Delete action buttons.
  • Auto-dismisses on blur (tauri://blur), Esc key, or close button.

Listens to clipboard:copied event from Rust.

Shown on Ctrl+Shift+V near the cursor. Displays:

  • Tabs: Recent / Pinned.
  • Numbered slots (1-9, 0): Press number key to instantly paste that entry.
  • Arrow key navigation + Enter to paste selected.
  • Expandable file entries for multi-file items.
  • Dynamic resize via invoke("resize_paste_popup", { width, height }).

Listens to paste-popup:entries event from Rust. Auto-dismisses on blur or Esc.

Component Purpose
Sidebar Navigation (6 screens: clipboard, notes, spaces, shortcuts, account, settings) + notifications bell + theme toggle. Icon-based, fixed position.
StatusPill “N text - M img - K files - X total” summary bar.
CardMenu Right-click context menu (Copy, Pin/Unpin, Save, Groups, Expand/Collapse, Delete). Portal to body. Uses direct DOM positioning in useLayoutEffect to avoid first-render flash at (0,0).
ToastNotification Timed notification with progress bar + optional action (Undo).
Notification Small bottom-right toast showing “Copied” or “Pasted” with dynamic icon. Separate webview window.
TooltipPortal CSS-driven tooltips via data-tooltip attributes.
WindowControls Frameless window buttons (minimize, maximize/restore, close).

The background capture pipeline and the two global-shortcut flows are described step-by-step in the Runtime Module above (clipboard_watcher.rs, hotkeys.rs).

6.2 Cloud Sync - Push (Local Capture -> Server)

Section titled “6.2 Cloud Sync - Push (Local Capture -> Server)”

Route, headers and response shape are the wire contract: orange-copy-paste-clipboard-backend/docs/architecture.md.

capture_clipboard_change() → history.push(entry)
│
└──► SyncClient.on_new_clipboard_entry(entry) [background runtime]
│
├─ cek = crypto::random_key()
├─ crypto::encrypt(cek, content, aad=client_id)
├─ crypto::encrypt(cek, metadata_json, aad=client_id)
├─ wrap cek under UMK ("personal") and under each target space key
├─ space_ids = explicit shares ∪ matching send filters
│
├─ online? ──► push to server (see wire contract)
│ on success: update id_map.json, set sync_status=Synced
│
└─ offline? ─► append to sync_pending.json
sync_status stays Pending
On startup / reconnect:
pull delta after {last_cursor}, paginated (route/params: see wire contract)
│
▼ (removals first, then entries)
drop_space_entry(space_id, client_id, entry_type, by_author=author==remover)
│ an entry withdrawn and later re-shared carries a server_ts newer
│ than its own removal record, so this order converges on the live
│ copy and the reverse order deletes something still shared
│
▼ (for each entry in response)
cek = unwrap_key(UMK, wrapped_keys["personal"]) ← own entry
or unwrap_key(space keyring, wrapped_keys[space_id]) ← shared entry
crypto::decrypt(cek, encrypted_content, aad=client_id) → plaintext
crypto::decrypt(cek, encrypted_metadata) → { groups, label, pinned }
│
├─ client_id already in local store?
│ └─ Yes → compare server_ts; apply if newer (LWW)
│ └─ No → insert as new entry
│ keep client_id as its id, record in id_map.json
│
├─ emit sync:history-merged (or sync:notes-merged) → React re-render
├─ flush_dirty_stores: the page reaches disk before the cursor moves
└─ advance the server cursor to last_server_ts (see wire contract)
next_cursor when the server sent one - it is already clamped to
the point both streams are complete to, so it can be behind the
newest entry received - otherwise the newest row seen either side
Repeat until next_cursor = null

A row this device wrote (device_id matches) is skipped as an echo. A restore sweep walks the same pages from no cursor, merges only rows missing here, and never moves the cursor (section 4.6).

6.4 Cloud Sync - Realtime (WebSocket -> Local)

Section titled “6.4 Cloud Sync - Realtime (WebSocket -> Local)”
WebSocket entry event received (event/payload shape: see wire contract)
│
▼
Same as Pull path above for the single entry
(skip if entry originated from this device_id;
skip personal entries in passive mode — the next pull will bring them)
A delete arrives as the same event carrying a tombstone marker:
│
▼
Find entry by client_id → history.remove(local_id)
emit clipboard:entry-deleted → React removes from state

History uses a MessagePack binary format for fast, compact disk storage:

  1. Metadata (history.bin) - Entry metadata serialized with MessagePack (rmp-serde) and written directly to disk (no compression). Image entries store an absolute file path instead of inline base64 data.
  2. Image store (images/) - Raw image bytes (PNG, JPEG, WebP, etc.) written to individual files named {id}_{label}.{ext}. On push(), data-URL images are immediately externalised to this directory, keeping in-memory footprint small.
  3. On load - File-path image entries are served to the frontend via Tauri’s convertFileSrc() asset protocol. Old inline data-URLs from previous sessions are automatically externalised on load.
  4. File cleanup - save_to_file deletes the image file or received-files folder of each entry removed since the last save, once that save lands (gone_files). Nothing scans the directories.
What Location Format When Saved When Loaded
Full history {app_data}/history.bin MessagePack binary (what it holds: section 4.2) Every 2s when dirty, and on exit On startup
Image files {app_data}/images/{id}_{label}.{ext} Raw binary image bytes (PNG/JPEG/WebP/etc.) On push to history Via asset protocol
Received files {app_data}/received-files/{client_id}/ Files/folders extracted from a synced file entry’s ZIP blob On pull of a file entry Via asset protocol
Note attachments {app_data}/note-attachments/{images,files}/ Raw bytes of images/files pasted, dropped, or attached in a note On attach (save_note_image/save_note_file) Via asset protocol
Settings {app_data}/settings.json JSON object { key: value } On set_setting On startup
Notes {app_data}/notes.bin MessagePack binary Every 2s when dirty On startup
Notifications {app_data}/notifications.bin MessagePack binary Every 2s when dirty On startup
Window geometry {app_data}/window-state.json { x, y, width, height, maximized } On every move/resize On startup
Theme preference localStorage.sc-theme "dark" or "light" On toggle On mount
Layout preference localStorage.sc-layout "tiles" or "list" On change On mount
Sort preference localStorage.sc-sort "newest" / "oldest" / "a-z" / "z-a" / "type" On change On mount
Paste slot count localStorage.sc-paste-slots "3" - "10" On change On popup show
Group names localStorage.sc-groups JSON string array On group edits On mount
Group colors localStorage.sc-group-colors JSON object (group -> palette index) On color change On mount
Sync state {app_data}/sync_state.json { last_server_ts, device_id, user_id, announcements_cursor, clock_offset_ms, settings_base, restore_owed, restore_mark, ... } After each pull, sweep and settings round On sync init
Sync offline queue {app_data}/sync_pending.json JSON array of pending push/delete/update/push_local ops (encrypted content, except push_local which is an id) On mutation when offline On reconnect
ID mapping {app_data}/id_map.json { "clipboard:42": "server-uuid", ... } plus entry_shares After each push On sync init
Sync work under way {app_data}/sync_pending_work.json { upload: [key], download: [key] }: entries with a transfer started and not finished Before a transfer touches the network, and on every flush On sync init

Upgrading from a build with pinned_entries.bin (ClipboardHistory::load_from_disk):

  • The file is folded into history.bin once, then removed, or renamed .retired when it will not go.
  • An older build with Keep history off never read history.bin, so that copy is stale. It is moved to history.bin.pre-upgrade, which nothing reads or writes, instead of being loaded. An empty .pre-upgrade marks the upgrade done when there was nothing to set aside.
  • A legacy file that could not go at all leaves pinned_entries.bin.folded, so later launches only retry the removal instead of laying its copies back over deletions.

Builds before this one also wrote boot_id.txt and sync_settings_local.json. Nothing reads either now.

Sync note: sync_pending.json, id_map.json and sync_pending_work.json are not safe to delete. The queue holds deletes the server has not had yet, and the id map holds the markers that keep items deleted while signed out off this device; without them those items come back. Losing sync_state.json causes a full re-pull from the server on the next startup.

7.3 {app_data} location and the identifier-rename migration

Section titled “7.3 {app_data} location and the identifier-rename migration”

Tauri resolves {app_data} from the bundle identifier in tauri.conf.json: on Windows %APPDATA%\<identifier>\, on Linux ~/.local/share/<identifier>/. The identifier is io.github.notrover.orange-copy-paste.

Temporary migration. The identifier was com.spect.orange-copy-paste up to v0.3.7; v0.3.8 renamed it.

  • migrate_legacy_app_data in lib.rs runs on the first launch of a renamed build, before any store loads. It copies the old identifier’s folder into the new one.
  • It copies rather than moves, so the old folder stays as a backup. It runs only while the new folder is still empty, so it does nothing on every later launch.
  • migrate_legacy_webview_storage (Windows only) does the same for localStorage. WebView2 keeps its profile outside {app_data}, in %LOCALAPPDATA%\<identifier>\EBWebView\, and only Default\Local Storage in it holds user data. It copies that folder, without leveldb’s LOCK file, and only while the new profile has no Local Storage, so it never overwrites or merges.
  • It runs in run() before the tauri Builder, not in setup: tauri creates the tauri.conf.json windows, and WebView2 its profile with them, before setup runs. It copies into a staging folder beside the target and renames it into place, so a failed copy leaves no partial store.
  • Both write their outcome to crash.log once setup has set the diag directory.
  • Installs that ran v0.3.8 or later already have a new profile, so the localStorage copy skips them by design. For a signed-in user the roaming localStorage keys (see Settings Sync) come back at the first settings round, unless an older build’s settings push from the reset PC wrote its empty values over the account; the per-device ones, such as the notes layout and sort order, stay at their defaults.
  • The OS keychain is not keyed by the identifier, so sign-in state carries over on its own.
  • The install directory, autostart entry and uninstall registry key are keyed by productName (“Orange Copy Paste”, unchanged). An update upgrades in place with no duplicate entries.
  • Both shims are temporary and go together. They are meant to be removed a few stable releases after the rename, once no install still holds data under the old identifier.

Why: {app_data} and the WebView2 profile are both keyed by the identifier. The rename alone would point a renamed build at an empty folder and an empty profile, and the user’s history, notes, settings and on-screen preferences would look wiped. The old files would be orphaned, not gone.


Only main and splash are declared in tauri.conf.json. The three popups (copy-popup, paste-popup, notification) are created programmatically at startup in runtime/popup_windows.rs.

Window Size Properties
main 920x560 Resizable (min 640x440), frameless, initially hidden (shown by window-state restore), dark bg #0e0e0e
splash 340x76 Frameless, transparent, no shadow, always-on-top, skip taskbar, not focusable, initially hidden (tauri.conf.json)
copy-popup 340x260 Frameless, transparent, no shadow, always-on-top, skip taskbar, not resizable
paste-popup 360x540 Same as copy-popup
notification 320x104 Same as copy-popup, plus ignore_cursor_events, positioned at bottom-right of screen

8.2 Permissions (capabilities/default.json)

Section titled “8.2 Permissions (capabilities/default.json)”

Applied to four windows: main, copy-popup, paste-popup and notification (not splash):

  • core:default - basic Tauri runtime
  • core:window:allow-show, allow-hide, allow-close, allow-set-position, allow-start-dragging, allow-minimize, allow-maximize, allow-unmaximize, and the is-visible/is-maximized queries
  • core:event:allow-listen, core:event:allow-unlisten - listen for custom events
  • global-shortcut:allow-register, allow-unregister, allow-is-registered - global keyboard shortcuts
  • autostart:allow-enable, allow-disable, allow-is-enabled - run on startup
  • updater:default, notification:default - self-update and OS notifications
  • Dev: bun run dev -> Vite on localhost:1420
  • Prod: bun run build -> tsc && vite build -> dist/
  • Bundle: tauri.conf.json targets nsis, deb, rpm and appimage. Which of them a release builds and publishes is in docs/releasing.md at the workspace root.
  • Release profile: opt-level = "z", LTO, single codegen unit, stripped symbols

These constraints span both this app and the backend. Breaking one breaks correctness, security or the offline-first guarantee.

The promises are the workspace root docs/architecture.md (Cross-Component Invariants) - that is where they are stated and where a new one is added. What only this file can say is the second column: which function, which file, and what has to be called to keep each one. Rows without a root counterpart (7, 8, 17, 18) are this app’s alone.

# Invariant App-side implication
1 Local store is always plaintext history.bin and notes.bin must never be encrypted. Encryption boundary = network only.
2 Sync is always optional App boots and operates fully without SyncClient initialized. sync_client: None is a valid steady state.
3 Server never sees plaintext crypto::encrypt must be called before any data leaves the process. The client.rs HTTP methods only accept pre-encrypted SyncEntry structs.
4 UMK never leaves the device The UMK (from unwrap_umk) is stored only in SyncClient’s memory field. Never written to any file, log, or IPC response. Cleared on sync_logout() or app exit.
5 Tombstones always propagate The delete commands go through forward_deletes. With a client, delete_entries writes every Delete to sync_pending.json before sending, offline included; with sync off, IdMap::mark_removed_offline writes a local_only marker. No merge or restore sweep may bring back a key that is queued, in flight, marked, or removed this run.
6 Capture pipeline is untouched clipboard_watcher.rs and hotkeys.rs must not have sync logic. The on_new_clipboard_entry call happens after history.push(), as a post-commit side-effect.
7 Suppress flag is respected SyncClient.on_new_clipboard_entry must only be called when a genuine new entry is inserted, not on suppress-skipped polls.

| 8 | Sync runtime never blocks the main runtime | SyncClient work runs in its dedicated background Tokio runtime. Entry points such as on_new_clipboard_entry are synchronous and spawn onto it through the stored handle - never block_on from the Tauri runtime. | | 9 | Cursor advances only on confirmed merge | POST /sync/cursor is sent only after the pulled entry is successfully decrypted and inserted into the local store, and after flush_dirty_stores has written the page to disk. | | 10 | ID mapping must survive restarts | id_map.json is flushed synchronously after each successful push response. A crash between push and flush is recoverable - the server deduplicates by client_id. | | 11 | Sharing is always opt-in | An entry gets a space_id only from an explicit share or an enabled send filter that matches it. Send filters default to off, and local group tags never share by themselves. | | 12 | File/video sync is size-gated | kind: 'file' entries exceeding 5 MB total must never be pushed. Emit sync:entry-skipped to the UI; do not silently drop. | | 13 | A space key is never lost while entries reference it | Space keyrings keep every key we have held, newest first, and are recovered from the server-side wrapped keyring on reconnect. A rekey prepends; it never replaces. | | 14 | Settings blob is encrypted | crypto::encrypt(UMK, settings_json) must be called before PUT /settings. Never send plaintext preferences over the network. | | 15 | Device-specific settings are never synced | sync_enabled, sync_server_url, sync_mode, space_autocopy:*, autostart, and window geometry never enter the blob: sync_settings keeps only ROAMING_JSON_KEYS from settings.json, and passes NO_LONGER_ROAMING through as the account has them, never applying them. | | 16 | Settings push is debounced | schedule_settings_sync() resets a 2-second timer. Never call PUT /settings directly from a mutation - always go through a round, which pulls before it pushes. | | 17 | Auto-copy cannot flood the clipboard | Only WebSocket-delivered space entries may auto-copy - never a pull page, never a personal entry - and the write sets the suppress flag before touching the clipboard. | | 18 | Passive mode never loses an entry | last_server_ts advances only in the pull path. An entry skipped live in passive mode must still arrive on the next interval or manual pull. |

Related: the wire contract is orange-copy-paste-clipboard-backend/docs/architecture.md; who may do what is the root docs/permissions.md; past regressions and their root causes are in docs/bugfix-history.md.