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.
1. High-Level Overview
Section titled “1. High-Level Overview”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.
2. Tech Stack
Section titled “2. Tech Stack”2.1 Backend
Section titled “2.1 Backend”| 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/loginor/auth/refreshroute. The sync module talks to GoTrue over REST (sync/supabase.rs).
2.2 Frontend
Section titled “2.2 Frontend”| 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) |
3. Project Structure
Section titled “3. Project Structure”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
4. Rust Backend
Section titled “4. Rust Backend”4.1 Entry Point & Setup
Section titled “4.1 Entry Point & Setup”main.rs - Minimal entry: calls lib::run().
lib.rs - Orchestrates the entire startup sequence:
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 usestasklist/taskkill; on Linux,pgrep/kill -9. It waits first: see Shutdown and the rotation window.create_shared_history()- CreatesArc<Mutex<ClipboardHistory>>.AppState { .. }-run()buildsAppStateinline from the shared history and suppress flag.setup_runtime()- Called insidetauri::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/throughClipboardHistory::load_from_disk, which folds in a pre-upgradepinned_entries.binonce (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_storesevery 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 aSyncClientis 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.
- Configures the images directory (
- Registers all Tauri command handlers.
- Hooks
WindowEvent::Destroyedon the main window toexit(0)the entire process.
Shutdown and the rotation window
Section titled “Shutdown and the rotation window”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_quitfirst, 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 insync_pending_work.json, so the exit flush keeps its entry and the next launch runs it.crash.logrecords 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
Exitwrites withFlush::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.
4.2 State Management
Section titled “4.2 State Management”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 atAppState 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_captureat capture. On Windows it measures the OS handle first, so an oversized payload is never decoded into the process.upsert_syncedon the sync merge.drop_oversizedon 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_BYTESinsync/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_BYTESbounds a single entry (above). - Turning Keep history back on loses nothing. Startup loads
history.binwhatever 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_fileswhen its entry is removed and deleted once the save that drops the entry has landed, never for an id that is live again. Nothing scansimages/for orphans. - With Keep history off, the final exit drops what it does not write. Only the
RunEvent::Exitflush (Flush::Exit) callsdrop_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_unkeptkeeps an entry whose file the system clipboard names (CF_HDROP, read byfiles::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_tsmoves 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).
4.3 Clipboard Module
Section titled “4.3 Clipboard Module”history.rs - In-Memory History Store
Section titled “history.rs - In-Memory History Store”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.jsonand the pending queue, and the UI reads it via thesync_get_entry_statescommand (useEntrySyncStates()), gated by theshow_sync_badgessetting.
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 |
commands.rs - Tauri Command Handlers
Section titled “commands.rs - Tauri Command Handlers”The
generate_handler!/invoke_handlerblock insrc-tauri/src/lib.rsis 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.rswins.
| 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 aCapture:Entry,TooLarge(overMAX_TEXT_BYTES) orNothing. Every capture path goes through it.write_entry_to_clipboard(entry)- Writes aClipboardEntryback 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 arboardClipboardhandle in up to 6 attempts, 50ms apart, to ride out contention with the watcher thread or other apps.set_active_clipboard_id(app, id)- Updatesactive_clipboard_idinAppStateand emitsclipboard:active-idto the frontend. Called fromcopy_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)->DragQueryFileWto extract paths. - Write: Build
DROPFILESstruct + UTF-16 filename block ->GlobalAlloc->SetClipboardData(CF_HDROP). - Serialization: File paths stored as newline-delimited strings in
ClipboardEntry.content.
html.rs - Rich Text Clipboard (CF_HTML)
Section titled “html.rs - Rich Text Clipboard (CF_HTML)”Reads and writes HTML content via the CF_HTML registered clipboard format:
- Read: Extracts the HTML fragment from the
CF_HTMLformat header (StartFragment/EndFragment markers). - Write: Constructs a
CF_HTMLheader 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.
image.rs - Multi-Format Image Clipboard
Section titled “image.rs - Multi-Format Image Clipboard”Reading - tries formats in priority order:
- Registered custom formats:
"PNG","image/png","image/jpeg","image/webp","image/bmp","JFIF"- covers browsers, Snipping Tool, etc. - CF_HDROP - image file exposed as a shell file-drop.
- arboard fallback -
CF_DIB/CF_DIBV5for 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:
- Decode base64 -> image -> RGBA pixels before opening the clipboard.
OpenClipboardin up to 10 attempts, 50ms apart.EmptyClipboard-> write CF_DIB (BITMAPINFOHEADER + BGRA bottom-up pixel data) + registered “PNG” format.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.
4.4 Notes Module
Section titled “4.4 Notes Module”store.rs - Note Model and Storage
Section titled “store.rs - Note Model and Storage”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_atpinnedgroups
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()).
Persistence Behavior
Section titled “Persistence Behavior”- Note mutations set
notes_dirty = true. - The shared background flush thread writes
notes.binevery ~2s when dirty. - Notes are loaded during startup in
setup_runtime.
4.5 Notification Centre
Section titled “4.5 Notification Centre”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_invitenotification carries theinvite_idin its opaquedatamap. notifications_refreshre-reads the server’s invite list and retires any row that is no longer pending:resolvedbecomes"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. upsertpreserves the existingreadflag andcreated_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_changedrops 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_skipcan fire hundreds of times in a single push, so it usesraise_rollingon the fixed idsync-skipped. The one row carries the count and goes back to unread whenever the count moves.clear_skippeddismisses 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_idfolds 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, andupsertrefreshes 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:newat once, andpull_announcementshands the same rows to a device that was closed. Both key onannouncement:<id>, so a second landing changes nothing. Why: the case that matters is a user who is not looking. SyncState::announcements_cursormakes 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-bottomand portalled todocument.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_idin itsdataopens 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 onSpacesScreen.
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
Cueis 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_kindmaps aNotificationKindto one cue.raise_cuedlets a caller override it where the kind is too broad: a space becoming readable and a join being approved are bothspace_activity, and both wantunlocked. Why: a small set is one the user can learn. - Rust decides when, the webview decides what it sounds like.
notifications::cueemitsui:cuewith the name.src/sounds.tssynthesizes 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 insideruntime::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.
4.6 Cloud Sync Module
Section titled “4.6 Cloud Sync Module”Status: Implemented against the current Supabase-based backend contract, Rust module and React UI both. Location:
src-tauri/src/sync/(UI insrc/components/app/account-screen/andspaces-screen/)Auth path as built: identity comes from Supabase Auth, not from a backend login route.
sync/supabase.rshandles password login, signup, refresh and recovery;sync/oauth.rsadds 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: seeorange-copy-paste-clipboard-backend/docs/architecture.md.
Google sign-in (two phases, and why)
Section titled “Google sign-in (two phases, and why)”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
codeoff 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.
begin_oauthfinishes the handshake and probesbootstrapto learn whether the account already has an envelope (is_new). It stashes the session inpending_oauth, returnsOAuthBeginand emitssync:oauth-ready.sync_oauth_pendinglets 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.complete_oauthtakes 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_windowruns 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.
Deep links
Section titled “Deep links”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.
Password reset, and change password
Section titled “Password reset, and change password”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()sendsredirect_to = reset_page_urlplus 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_resetholds the session from the first exchange for reuse. - That session is dropped on success, and by
sync_cancel_password_resetwhen 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).
Recovery code
Section titled “Recovery code”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,Iand1removed. 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_saltare reused; only the secret and the AAD differ (umk-recovery-v1againstumk-envelope-v2, or-v1before 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
bootstrapreportsrecovery_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” reusesexport_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.
Overview
Section titled “Overview”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.
mod.rs - SyncClient
Section titled “mod.rs - SyncClient”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 storedon_update_clipboard_entry(entry)/on_update_note(note)- called from the pin, group and edit commandsdelete_entries(entry_type, items)- reached from the delete commands throughforward_deletes; writes the tombstones to the queue before sending them (see Deletes underpending_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 modeflush_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:
- Restore the Supabase session. On first login, bootstrap the account (fetch
kdf_salt, unwrap the UMK) and register the device (obtaindevice_id). - Open the WebSocket connection. Opening it schedules a settings round, which pulls before it pushes.
- Flush
sync_pending.json, then pull the delta afterlast_server_ts, page by page (flush_and_pull), decrypting and merging remote entries into the local store. - 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 onsync:session-restoredorsync:restore-gave-up. - The command returns in milliseconds, and
RestoreOutcome.restoringis 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. |
Restore sweep - rows missing here
Section titled “Restore sweep - rows missing here”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, underflush_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 andlocal_onlymarkers 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/breakdowntotal) 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.
client.rs - HTTP Client
Section titled “client.rs - HTTP Client”- Wraps
reqwest::Clientwith the base URL, theAuthorization: Bearerheader (Supabase access token), and theX-Device-Idheader 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_revokedends 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’sRetry-After, capped at 30s, or else 1s, 2s, 4s.
ws_listener.rs - WebSocket Listener
Section titled “ws_listener.rs - WebSocket Listener”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.
pending_queue.rs - Offline Queue
Section titled “pending_queue.rs - Offline Queue”{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:
drainwrites them there before emptying the queue.settlerequeues whatever did not send, and only then removes the file.loadfolds a leftover copy back in at the front.- Between the two,
pending_keysstill 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
PushorUpdateis recovered. The server deduplicates byclient_id, and the entry is still on this device to send again. - A lost
Deleteis 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_entriesqueues 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_deleteswrites alocal_onlymarker 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_runbefore 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.
pending_work.rs - Work Under Way
Section titled “pending_work.rs - Work Under Way”{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_entryandspawn_push_noteafter their skip checks, and inmerge_pulledbefore 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’sDropcloses the store before the runtime drops its tasks, so their guards leave the records alone. - Re-driven after each
flush_and_pullthat 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.
crypto.rs - Encryption Primitives
Section titled “crypto.rs - Encryption Primitives”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.
commands.rs - New Tauri Commands
Section titled “commands.rs - New Tauri Commands”| 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 |
File and Video Sync (5 MB Limit)
Section titled “File and Video Sync (5 MB Limit)”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):
- 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.
zip_paths_to_bytespacks each newline-separated path into one in-memory ZIP, preserving top-level names (disambiguatedname (2)on a basename clash) and any folder structure. Empty/unreadable entries are skipped.- Encrypt the archive with
encrypt_bytes(same CEK as the entry’s row), thenrequest-upload-> pre-signed PUT ->confirm-upload. A second, exact size gate rejects ciphertext over 5 MB. - Inline
encrypted_contentis a tiny descriptor,{"archive":"zip"}(the ZIP is self-describing);blob_key/blob_sizepoint 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_dirinto{app_data}/received-files/{client_id}/. The archive goes into{client_id}.incomingfirst 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_nameblocks zip-slip. entry.contentbecomes 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.
Spaces - Sync Module Integration
Section titled “Spaces - Sync Module Integration”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:
- Explicit shares - the spaces the user picked from the card menu or bulk bar,
recorded in
id_map.jsonunderentry_shares. Authoritative for entries already pushed. - Send-filter matches - every space whose
SendFilter { enabled, kinds, groups, content }matches, each evaluated independently. The default isenabled: 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_bysays whose public key opens our wrap. Null means the owner, so rows written before this keep working.spaces.key_fingerprintis 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 emitsspace:key-rejectedand 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_keywraps the ring for the invitee’s identity key when the invite is sent, andPUT /invites/{id}/keyparks 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.
space_set_entry_sharesrecords the intent whichever way.share_targetsdrops a keyless space on the way out, so nothing unreadable is pushed.flush_pending_sharesre-pushes those entries whenspace:key-receivedfires.
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 nowpull 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.
Settings Sync - What Gets Synced
Section titled “Settings Sync - What Gets Synced”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_STORAGEinApp.tsx):theme,layout,sort,paste_slots,group_names,group_colors - From
settings.json(ROAMING_JSON_KEYSinsync/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_historyandautosave(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.jsonkey not listed above
One settings round (sync_settings, one at a time on settings_lock):
schedule_settings_syncwaits out the debounce (SETTINGS_DEBOUNCE_SECS, 2s after the latest call, so a burst of calls makes one round), then emitssync:collect-settingswith the current round number. React answers with itslocalStoragepart and that number. A round that waited onsettings_lockbehind 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 everysync_settings_refused, moves the number on.- Pull the blob. One this device cannot decrypt ends the round with no push: it may be the account’s only copy.
- Merge (
merge_settings) the account’s values, this device’s, andsettings_base, this device’s roaming values as of its last round (insync_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. - Apply what the account changed.
settings.jsonkeys are written and stored into their in-memory flags at once; thelocalStoragekeys go to React assync: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. ThelocalStoragevalues always land; thesettings.jsonones only when the write did. A first round has no base to add them to. - 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) asbase_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. - Move the base to the merged blob, unless the
settings.jsonwrite 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.
config.rs - Sync Settings
Section titled “config.rs - Sync Settings”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.
4.7 Runtime Module
Section titled “4.7 Runtime Module”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-entryto all windows. - Updates
active_clipboard_idand emitsclipboard:active-id. - Shows copy notification if enabled.
- If
keep_historyis enabled, markshistory_dirtyfor background flush.
- Advances the sequence token only when capture succeeds. If the clipboard was locked, the next poll retries.
hotkeys.rs - Global Shortcut Handlers
Section titled “hotkeys.rs - Global Shortcut Handlers”Ctrl+Shift+C (handle_copy_shortcut):
- Toggle-hide copy popup if already visible.
- Set suppress flag (prevents watcher race).
- Simulate Ctrl+C (with 120ms delays before/after).
- Read the clipboard.
- Push to history (deduplicated).
- Update
active_clipboard_id, emitclipboard:active-id. - Only if step 5 inserted a new entry: emit
clipboard:new-entryand show the copy popup with the entry preview.
Ctrl+Shift+V (handle_paste_shortcut):
- Toggle-hide paste popup if already visible.
- Lock history, grab top 200 recent (
PASTE_HISTORY_CAP) + all pinned (bounded byMAX_PINNED). - Emit
paste-popup:entriesto paste popup, show it near cursor.
popup_windows.rs - Multi-Window Management
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 anotification:showevent with aNotificationPayload { 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 bothnotification_enabledandnotif_pasteare 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/ - OS Abstraction
Section titled “platform/ - OS Abstraction”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.
tray.rs - System Tray
Section titled “tray.rs - System Tray”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.
window_state.rs - Saved Window Geometry
Section titled “window_state.rs - Saved Window Geometry”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) |
4.8 Health & Self-Update
Section titled “4.8 Health & Self-Update”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 |
4.9 Events Emitted to the Frontend
Section titled “4.9 Events Emitted to the Frontend”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.
5. Frontend
Section titled “5. Frontend”5.1 Build & Entry Points
Section titled “5.1 Build & Entry Points”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).
5.2 Shared Types
Section titled “5.2 Shared Types”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().
5.3 Shared Hooks
Section titled “5.3 Shared Hooks”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) |
Showing a command error
Section titled “Showing a command error”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 insync/client.rs) is mapped by status: 401, 402, 403 (with a separateemail_unverifiedmessage), 429 and 5xx each get one plain message, and a transport failure reads as offline. Any other status shows the server’sdetailonly 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.
5.4 Main App
Section titled “5.4 Main App”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 tolocalStorage).undoSnapshot- snapshot for “undo clear history” (5-second window).activeClipboardId: string- ID of the entry currently in the OS clipboard.
Initialization (on mount):
- Subscribe to
clipboard:new-entryevents (prepend to state, deduplicated by ID). - Subscribe to
clipboard:entry-deletedevents (remove from state). - Subscribe to
clipboard:active-idevents (updateactiveClipboardIdstate). - Fetch
get_historyandget_active_clipboard_idfrom Rust, merge with any entries already received via events. A rejectedget_historyis logged and retried twice, a second apart. If the last try also fails and no other read has landed meanwhile, a persistent error toast (keyhistory-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.
5.5 Screens
Section titled “5.5 Screens”Clipboard Screen (ClipboardScreen.tsx)
Section titled “Clipboard Screen (ClipboardScreen.tsx)”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 tolocalStorage(sc-f-*). TheCloudsection and theMineandFrom otherschips are hidden while no account is signed in, which includes a session that is still restoring (sync_get_userreturns 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 = 200cards up front and grows byRENDER_PAGE_SIZE = 50as the user scrolls (IntersectionObserver, 600pxrootMargin). Histories of 1,000+ entries stay responsive. A “You’re all caught up” footer appears at the true end. - Memoized cards:
EntryCard(andNoteCard) are wrapped inReact.memo, so typing in search or toggling selection does not re-render the whole list.
Entry Card (EntryCard.tsx)
Section titled “Entry Card (EntryCard.tsx)”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.
AppStatetracks the id (active_clipboard_id) and pushes it to the frontend with theclipboard:active-idevent.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)
Settings Screen (SettingsScreen.tsx)
Section titled “Settings Screen (SettingsScreen.tsx)”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 tolocalStorage.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). Settingkeep_historyinsettings.json.Auto-save copied entries: on by default, per device. Tags each new capture Saved. Settingautosave.Close to system tray: hide to the system tray on close instead of quitting. Settingclose_to_tray.Start minimized: launch hidden in the tray. Settingstart_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. Settingshow_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.
Notes Screen (NotesScreen.tsx)
Section titled “Notes Screen (NotesScreen.tsx)”- 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 keyfor 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.
Shortcuts Screen (ShortcutsScreen.tsx)
Section titled “Shortcuts Screen (ShortcutsScreen.tsx)”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.
5.6 Popups
Section titled “5.6 Popups”Copy Popup (CopyPopup.tsx)
Section titled “Copy Popup (CopyPopup.tsx)”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.
Paste Popup (PastePopup.tsx)
Section titled “Paste Popup (PastePopup.tsx)”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.
5.7 UI Components
Section titled “5.7 UI Components”| 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). |
6. Data Flows
Section titled “6. Data Flows”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.1 Frontend State Sync
Section titled “6.1 Frontend State Sync”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 Pending6.3 Cloud Sync - Pull (Server -> Local)
Section titled “6.3 Cloud Sync - Pull (Server -> Local)”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 = nullA 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 state7. Persistence & Storage
Section titled “7. Persistence & Storage”7.1 Binary Persistence Format
Section titled “7.1 Binary Persistence Format”History uses a MessagePack binary format for fast, compact disk storage:
- 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. - Image store (
images/) - Raw image bytes (PNG, JPEG, WebP, etc.) written to individual files named{id}_{label}.{ext}. Onpush(), data-URL images are immediately externalised to this directory, keeping in-memory footprint small. - 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. - File cleanup -
save_to_filedeletes 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.
7.2 Storage Locations
Section titled “7.2 Storage Locations”| 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.binonce, then removed, or renamed.retiredwhen it will not go. - An older build with Keep history off never read
history.bin, so that copy is stale. It is moved tohistory.bin.pre-upgrade, which nothing reads or writes, instead of being loaded. An empty.pre-upgrademarks 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_datainlib.rsruns 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 forlocalStorage. WebView2 keeps its profile outside{app_data}, in%LOCALAPPDATA%\<identifier>\EBWebView\, and onlyDefault\Local Storagein it holds user data. It copies that folder, without leveldb’sLOCKfile, and only while the new profile has noLocal Storage, so it never overwrites or merges.- It runs in
run()before the tauri Builder, not insetup: tauri creates thetauri.conf.jsonwindows, and WebView2 its profile with them, beforesetupruns. 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.logoncesetuphas set the diag directory. - Installs that ran v0.3.8 or later already have a new profile, so the
localStoragecopy skips them by design. For a signed-in user the roaminglocalStoragekeys (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.
8. Tauri Configuration & Permissions
Section titled “8. Tauri Configuration & Permissions”8.1 Windows
Section titled “8.1 Windows”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 runtimecore:window:allow-show,allow-hide,allow-close,allow-set-position,allow-start-dragging,allow-minimize,allow-maximize,allow-unmaximize, and theis-visible/is-maximizedqueriescore:event:allow-listen,core:event:allow-unlisten- listen for custom eventsglobal-shortcut:allow-register,allow-unregister,allow-is-registered- global keyboard shortcutsautostart:allow-enable,allow-disable,allow-is-enabled- run on startupupdater:default,notification:default- self-update and OS notifications
8.3 Build
Section titled “8.3 Build”- Dev:
bun run dev-> Vite onlocalhost:1420 - Prod:
bun run build->tsc && vite build->dist/ - Bundle:
tauri.conf.jsontargetsnsis,deb,rpmandappimage. Which of them a release builds and publishes is indocs/releasing.mdat the workspace root. - Release profile:
opt-level = "z", LTO, single codegen unit, stripped symbols
9. Cross-System Invariants
Section titled “9. Cross-System Invariants”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.