realtimeclipboard 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/cli/CLAUDE.md +28 -0
- package/cli/README.md +24 -0
- package/cli/realtimeclipboard.mjs +303 -0
- package/package.json +67 -0
- package/src/README.md +24 -0
- package/src/core/CLAUDE.md +31 -0
- package/src/core/README.md +18 -0
- package/src/core/bus.js +84 -0
- package/src/core/config.js +414 -0
- package/src/core/crypto.js +215 -0
- package/src/core/device.js +55 -0
- package/src/core/history.js +208 -0
- package/src/core/keys.js +150 -0
- package/src/core/paths.js +68 -0
- package/src/core/state.js +193 -0
- package/src/core/storage.js +182 -0
- package/src/transport/CLAUDE.md +30 -0
- package/src/transport/README.md +16 -0
- package/src/transport/protocol.js +68 -0
- package/src/transport/relay.js +423 -0
- package/src/transport/sse.js +244 -0
- package/src/transport/ws.js +76 -0
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "realtimeclipboard",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Live clipboard sharing. Static frontend of native ES modules — development needs no build, and `npm run build` exists only to assemble the deploy. Nothing here is needed to READ the app.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/akshaynikhare/RealtimeClipboard.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/akshaynikhare/RealtimeClipboard",
|
|
11
|
+
"bugs": "https://github.com/akshaynikhare/RealtimeClipboard/issues",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"clipboard",
|
|
14
|
+
"online-clipboard",
|
|
15
|
+
"clipboard-sync",
|
|
16
|
+
"share-text",
|
|
17
|
+
"end-to-end-encrypted",
|
|
18
|
+
"cli",
|
|
19
|
+
"pipe",
|
|
20
|
+
"ssh"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"realtimeclipboard": "cli/realtimeclipboard.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"cli/",
|
|
28
|
+
"src/core/",
|
|
29
|
+
"src/transport/",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"verify": "node tests/unit/static-check.mjs && node tests/unit/relay-url.mjs && node tests/unit/lock.mjs && node tests/unit/files.mjs && node tests/unit/transfer.mjs && node tests/unit/clipsize.mjs && node tests/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs",
|
|
38
|
+
"test": "npm run verify && node tests/dom/bundle.mjs && node tests/live/e2e.mjs && node tests/live/boot.mjs && node tests/live/boot.mjs --locked && node tests/live/fallback.mjs",
|
|
39
|
+
"test:static": "node tests/unit/static-check.mjs",
|
|
40
|
+
"test:lock": "node tests/unit/lock.mjs && node tests/dom/dialog.mjs",
|
|
41
|
+
"test:e2e": "node tests/live/e2e.mjs",
|
|
42
|
+
"test:boot": "node tests/live/boot.mjs && node tests/live/boot.mjs --locked",
|
|
43
|
+
"test:bundle": "node tests/dom/bundle.mjs",
|
|
44
|
+
"test:tiles": "node tests/dom/tiles.mjs",
|
|
45
|
+
"test:fallback": "node tests/live/fallback.mjs",
|
|
46
|
+
"test:cli": "node tests/live/cli.mjs",
|
|
47
|
+
"build": "node tools/build/build.mjs",
|
|
48
|
+
"build:site": "node tools/build/build.mjs _site && node tools/check/site-check.mjs _site",
|
|
49
|
+
"build:desktop": "node tools/build/build.mjs _desktop --desktop",
|
|
50
|
+
"build:og": "python tools/build/build-og-card.py",
|
|
51
|
+
"check:og": "python tools/build/build-og-card.py --check",
|
|
52
|
+
"check:csp": "node tools/build/build.mjs _site && node tools/check/csp-check.mjs",
|
|
53
|
+
"relay": "python -m uvicorn main:app --app-dir backend --port 8000",
|
|
54
|
+
"relay:up": "node tools/release/relay-up.mjs",
|
|
55
|
+
"changelog": "node tools/release/changelog.mjs",
|
|
56
|
+
"release": "node tools/release/release.mjs",
|
|
57
|
+
"serve": "python -m http.server 8080",
|
|
58
|
+
"prepare": "husky",
|
|
59
|
+
"prepublishOnly": "npm run verify && node tests/live/cli.mjs"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"esbuild": "^0.28.1",
|
|
63
|
+
"husky": "^9.1.7",
|
|
64
|
+
"jsdom": "^26.1.0"
|
|
65
|
+
},
|
|
66
|
+
"comment": "esbuild is used ONLY by tools/build/build.mjs, which assembles the deploy — src/ stays plain ES modules on disk, so `npm run serve` still needs no build and the .husky checks still run against real modules. Tests are filed by what they need to run: tests/unit needs nothing, tests/dom needs jsdom, tests/live needs a relay. `verify` is all of unit plus the three dom suites that need no build, which is what a pre-commit hook can afford; `test` adds dom/bundle.mjs and everything under live. Every suite skips cleanly when its prerequisite is absent. The app itself still ships with zero RUNTIME dependencies."
|
|
67
|
+
}
|
package/src/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# src/
|
|
2
|
+
|
|
3
|
+
The frontend. Plain ES modules — what is here is what the browser loads, so `python -m http.server`
|
|
4
|
+
is the whole development setup. The deploy is bundled from this tree but never replaces it.
|
|
5
|
+
|
|
6
|
+
`main.js` is the composition root: the one file that imports across layers and wires them together.
|
|
7
|
+
Everything else talks through `core/bus.js`.
|
|
8
|
+
|
|
9
|
+
| Directory | What it holds |
|
|
10
|
+
|---|---|
|
|
11
|
+
| [core/](core/) | bus, config, state, crypto, keys, storage, paths, device, history — no DOM, node-importable |
|
|
12
|
+
| [transport/](transport/) | the relay connection and its two interchangeable channels |
|
|
13
|
+
| [clipboard/](clipboard/) | the OS clipboard boundary and the capture tiers above it |
|
|
14
|
+
| [files/](files/) | peer-to-peer file transfer, chunking, thumbnails, the in-memory registry |
|
|
15
|
+
| [ui/](ui/) | everything that renders, split by role into primitives, shell, features and panels |
|
|
16
|
+
| [styles/](styles/) | design tokens and per-component CSS; `lazy/` is fetched on demand |
|
|
17
|
+
| [landing/](landing/) | the marketing page — a separate document from the app |
|
|
18
|
+
| [pages/](pages/) | static content pages, copied to the site root rather than bundled |
|
|
19
|
+
|
|
20
|
+
`core/` and `transport/` are also what the npm package ships, so the CLI in [../cli/](../cli/) runs
|
|
21
|
+
the same crypto and the same protocol as the browser.
|
|
22
|
+
|
|
23
|
+
Conventions and the import rules: [CLAUDE.md](CLAUDE.md). Design rationale:
|
|
24
|
+
[../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# src/core/ — rank 0
|
|
2
|
+
|
|
3
|
+
The bottom of the tree. **Nothing here may import from anywhere else in `src/`.**
|
|
4
|
+
|
|
5
|
+
Also the boundary the npm package ships (`package.json` `files:`), so `cli/realtimeclipboard.mjs`
|
|
6
|
+
runs these exact modules. Two consequences:
|
|
7
|
+
|
|
8
|
+
- **No `window`, no `document`, no DOM.** Guard anything ambient the way `config.js` guards
|
|
9
|
+
`location` and `paths.js` guards `document` — a bare reference throws at import time and takes
|
|
10
|
+
the whole graph down, including in the node tests.
|
|
11
|
+
- **A change here is a change to a published package**, not just to the site.
|
|
12
|
+
|
|
13
|
+
## Compatibility surfaces — changing these strands existing users
|
|
14
|
+
|
|
15
|
+
- **The key derivation is a wire format.** Salts, iteration counts and domain-separation strings in
|
|
16
|
+
`config.js` are baked into every share link in existence. `tests/unit/lock.mjs` holds golden
|
|
17
|
+
vectors that fail if you touch them; that failure is the feature. If the change is intended, say
|
|
18
|
+
so explicitly in the commit body.
|
|
19
|
+
- **`STORAGE_PREFIX`** — changing it orphans everyone's saved history and settings.
|
|
20
|
+
- **`paths.js` resolves from `document.baseURI`**, never from `import.meta.url`. Six modules used
|
|
21
|
+
to compute their own depth in the tree, and bundling moved all six at once with no error.
|
|
22
|
+
|
|
23
|
+
## Rules
|
|
24
|
+
|
|
25
|
+
- Every tunable constant in the app lives in `config.js`. A magic number anywhere else is a bug.
|
|
26
|
+
- `state.js` is not reactive. Mutate through its setters, which emit on the bus.
|
|
27
|
+
- Event names are `EV.*` constants in `bus.js`. Adding an event means adding a constant.
|
|
28
|
+
- A bus event that **reports** is never named the same as one that **commands** — `EV.LOCK_STATE`
|
|
29
|
+
and `"session:lock"` once shared a name and every `setKey()` opened the PIN dialog by itself.
|
|
30
|
+
- The share key and the session PIN are never logged, never stored, never transmitted. Only
|
|
31
|
+
`SHA-256(key)` and PBKDF2/HKDF output leave this directory.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# src/core/
|
|
2
|
+
|
|
3
|
+
Foundations. No DOM, no network, no imports from elsewhere in `src/` — which is what lets the CLI
|
|
4
|
+
and the node test suites run these files unchanged.
|
|
5
|
+
|
|
6
|
+
| File | What it does |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `bus.js` | Tiny pub/sub. The only channel between modules, and the `EV.*` name constants |
|
|
9
|
+
| `config.js` | Every tunable constant, the relay address, and the crypto parameters |
|
|
10
|
+
| `state.js` | Session state and the setters that announce changes |
|
|
11
|
+
| `crypto.js` | Room hashing, PBKDF2/HKDF derivation, AES-GCM. No libraries |
|
|
12
|
+
| `keys.js` | Share-key generation, normalisation, and the URL fragment |
|
|
13
|
+
| `storage.js` | localStorage, wrapped so a disabled-storage browser degrades instead of throwing |
|
|
14
|
+
| `paths.js` | Where the app is served from, resolved once for the whole codebase |
|
|
15
|
+
| `device.js` | A human-readable name for this device, shown in the peer list |
|
|
16
|
+
| `history.js` | In-session clip history — the model behind the history pane |
|
|
17
|
+
|
|
18
|
+
Rules that govern edits here: [CLAUDE.md](CLAUDE.md).
|
package/src/core/bus.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny pub/sub. The only way modules talk to each other.
|
|
3
|
+
*
|
|
4
|
+
* The rule that keeps this codebase modular: UI modules never import transport
|
|
5
|
+
* or clipboard modules, and vice versa. They publish and subscribe to events
|
|
6
|
+
* here. main.js is the only file that knows the full graph.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const listeners = new Map();
|
|
10
|
+
|
|
11
|
+
export function on(event, fn) {
|
|
12
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
13
|
+
listeners.get(event).add(fn);
|
|
14
|
+
return () => off(event, fn); // call the return value to unsubscribe
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function off(event, fn) {
|
|
18
|
+
listeners.get(event)?.delete(fn);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function emit(event, payload) {
|
|
22
|
+
const set = listeners.get(event);
|
|
23
|
+
if (!set) return;
|
|
24
|
+
// Copy first: a handler may unsubscribe itself mid-dispatch.
|
|
25
|
+
for (const fn of [...set]) {
|
|
26
|
+
try { fn(payload); }
|
|
27
|
+
catch (err) { console.error(`[bus] handler failed for "${event}"`, err); }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Canonical event names. Typos here are silent bugs, so use the constants. */
|
|
32
|
+
export const EV = {
|
|
33
|
+
// clipboard
|
|
34
|
+
TEXT_CAPTURED: "text:captured", // {text, how} — local capture, needs sending
|
|
35
|
+
TEXT_RECEIVED: "text:received", // {text, from} — arrived from a peer
|
|
36
|
+
TIER_CHANGED: "clipboard:tier", // {tier, note}
|
|
37
|
+
PENDING_CLIP: "clipboard:pending",// {pending, text} — arrived while unfocused
|
|
38
|
+
CLIP_OFFERED: "clipboard:offered",// {text} — arrived, but the editor has unsent work
|
|
39
|
+
PEER_JOINED: "peers:joined", // {name} — a device entered the session
|
|
40
|
+
PEER_LEFT: "peers:left", // {name}
|
|
41
|
+
PERMISSION: "clipboard:permission", // {state} granted|prompt|denied
|
|
42
|
+
IMAGE_CAPTURED: "clipboard:image", // {blob, name} — an image was copied or pasted
|
|
43
|
+
SYNC_MODE: "clipboard:mode", // {mode} live|manual
|
|
44
|
+
|
|
45
|
+
// transport
|
|
46
|
+
CONN_STATE: "conn:state", // {state, detail}
|
|
47
|
+
TRANSPORT: "conn:transport", // {mode, label, blocked, forced} — ws | sse, or nothing works
|
|
48
|
+
TRANSPORT_SELECT:"conn:transport:set",// {mode} — the user picked one; null = automatic
|
|
49
|
+
PEERS_CHANGED: "peers:changed", // {count, list}
|
|
50
|
+
INSTANCE_CHANGED:"conn:instance", // {from, to} — split-brain warning (OI-3)
|
|
51
|
+
KEY_COLLISION: "session:collision",// generated key was taken (OI-2)
|
|
52
|
+
ROOM_STATE: "conn:room", // {existing, hasLast} — what `welcome` said
|
|
53
|
+
|
|
54
|
+
// session
|
|
55
|
+
KEY_CHANGED: "session:key", // {key, locked}
|
|
56
|
+
/**
|
|
57
|
+
* {locked, verified} — see core/crypto.js.
|
|
58
|
+
*
|
|
59
|
+
* "lockstate", not "lock", and the extra syllable is load-bearing. The name
|
|
60
|
+
* used to be "session:lock", which is ALSO the imperative the UI emits to
|
|
61
|
+
* mean "lock this session" (main.js). One name, two opposite meanings: a
|
|
62
|
+
* report and an order. So every state.setKey() — one per session, on every
|
|
63
|
+
* single boot — delivered a report to the handler that acts on the order,
|
|
64
|
+
* and the app opened the "Lock this session" PIN dialog by itself the moment
|
|
65
|
+
* it started. Nothing failed, nothing was logged, and the dialog looked
|
|
66
|
+
* exactly like a feature.
|
|
67
|
+
*
|
|
68
|
+
* Announcements are past tense here (`session:key`, `peers:changed`) and
|
|
69
|
+
* commands are bare verbs (`session:lock`, `session:leave`). Keep it that
|
|
70
|
+
* way; the bus does no namespacing of its own and a collision is silent.
|
|
71
|
+
*/
|
|
72
|
+
LOCK_STATE: "session:lockstate",
|
|
73
|
+
FOUNDER: "session:founder", // {founder} — first into this room? null = not yet known
|
|
74
|
+
|
|
75
|
+
// files
|
|
76
|
+
FILES_CHANGED: "files:changed", // full list
|
|
77
|
+
FILE_ADDED: "files:added", // {file} — added locally, needs announcing
|
|
78
|
+
FILE_PROGRESS: "files:progress", // {id, percent}
|
|
79
|
+
TRANSFER_PATH: "files:path", // {id, path: "p2p" | "relay"}
|
|
80
|
+
|
|
81
|
+
// ui
|
|
82
|
+
TOAST: "ui:toast", // string
|
|
83
|
+
SETTINGS_CHANGED:"ui:settings", // {name, value} — a preference was toggled
|
|
84
|
+
};
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every tunable constant. Nothing else in the app hard-codes a limit —
|
|
3
|
+
* if you find a magic number elsewhere, it belongs here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Relay endpoint.
|
|
8
|
+
*
|
|
9
|
+
* Must be wss:// in production: the site is served over HTTPS from GitHub
|
|
10
|
+
* Pages, and a browser refuses a ws:// connection from an https:// page as
|
|
11
|
+
* mixed content. Localhost is exempt from that rule, which is why the dev
|
|
12
|
+
* branch can stay ws://.
|
|
13
|
+
*/
|
|
14
|
+
// Guarded: this module is imported by node-based tests where `location` does
|
|
15
|
+
// not exist, and a bare reference would throw at import time and take the whole
|
|
16
|
+
// graph down.
|
|
17
|
+
const IS_LOCAL = typeof location !== "undefined" &&
|
|
18
|
+
["localhost", "127.0.0.1", "[::1]"].includes(location.hostname);
|
|
19
|
+
|
|
20
|
+
/** The relay this build ships pointed at. Self-hosters change this one line. */
|
|
21
|
+
export const DEFAULT_RELAY_URL = "wss://realtimeclipboard.fastapicloud.dev";
|
|
22
|
+
const LOCAL_RELAY_URL = "ws://127.0.0.1:8000";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Key under which the chosen relay is remembered.
|
|
26
|
+
*
|
|
27
|
+
* The storage PREFIX lives here rather than in core/storage.js so that this
|
|
28
|
+
* module can read a setting without importing storage.js — which imports this
|
|
29
|
+
* one, and a cycle between "every constant" and "the thing that persists them"
|
|
30
|
+
* is the sort that works until someone moves a line to module scope.
|
|
31
|
+
*/
|
|
32
|
+
export const STORAGE_PREFIX = "realtimeclipboard.";
|
|
33
|
+
const RELAY_KEY = "relayUrl";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Accept only something that can actually be a relay, and normalise it.
|
|
37
|
+
*
|
|
38
|
+
* `http(s)://` is accepted and converted, because that is what a person copies
|
|
39
|
+
* out of a browser bar when their IT department gives them an address, and
|
|
40
|
+
* rejecting it would be pedantry with a support ticket attached.
|
|
41
|
+
*/
|
|
42
|
+
export function normaliseRelay(raw) {
|
|
43
|
+
if (!raw || typeof raw !== "string") return null;
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(raw.trim());
|
|
46
|
+
const scheme = { "http:": "ws:", "https:": "wss:", "ws:": "ws:", "wss:": "wss:" }[u.protocol];
|
|
47
|
+
if (!scheme) return null;
|
|
48
|
+
// Path, query and hash are meaningless here — the routes are fixed (/ws,
|
|
49
|
+
// /sse, /pub, /health) and a trailing slash would produce "//ws/<room>".
|
|
50
|
+
return `${scheme}//${u.host}`;
|
|
51
|
+
} catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stored = () => {
|
|
55
|
+
try { return JSON.parse(localStorage.getItem(STORAGE_PREFIX + RELAY_KEY)); }
|
|
56
|
+
catch { return null; }
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `?relay=` in the address, for builds that are deployed rather than visited:
|
|
61
|
+
* a corporate MSI transform, a macOS config profile, or a desktop shell that
|
|
62
|
+
* launches the webview at its own relay.
|
|
63
|
+
*
|
|
64
|
+
* It wins over the stored setting on purpose — the deployment is more current
|
|
65
|
+
* than whatever the machine remembers — and main.js persists it, so the switch
|
|
66
|
+
* survives the next launch without the flag.
|
|
67
|
+
*
|
|
68
|
+
* !! This is NOT an attack surface on the hosted site, and the reason is worth
|
|
69
|
+
* knowing: the CSP pins `connect-src` to this origin and the default relay, so
|
|
70
|
+
* a link carrying `?relay=` to anywhere else cannot open a connection at all.
|
|
71
|
+
* The override only does anything on a build whose operator also edited that
|
|
72
|
+
* meta tag — which is precisely the self-hosting case it exists for. The CSP is
|
|
73
|
+
* the enforcement; this is only the plumbing. !!
|
|
74
|
+
*/
|
|
75
|
+
const fromQuery = () => {
|
|
76
|
+
try { return new URLSearchParams(location.search).get("relay"); }
|
|
77
|
+
catch { return null; }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const RELAY_URL =
|
|
81
|
+
normaliseRelay(fromQuery())
|
|
82
|
+
?? normaliseRelay(stored())
|
|
83
|
+
?? (IS_LOCAL ? LOCAL_RELAY_URL : DEFAULT_RELAY_URL);
|
|
84
|
+
|
|
85
|
+
/** True when the app is not talking to the relay it was built against. */
|
|
86
|
+
export const RELAY_IS_CUSTOM = RELAY_URL !== DEFAULT_RELAY_URL && RELAY_URL !== LOCAL_RELAY_URL;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The same relay over plain HTTP, for the SSE+POST fallback and /stats.
|
|
90
|
+
*
|
|
91
|
+
* One hostname, two ways in — which is what makes the fallback worth having:
|
|
92
|
+
* IT allowlists a single domain (PRD §5.4) and both transports are covered by
|
|
93
|
+
* it. Derived rather than written twice so the two can never drift.
|
|
94
|
+
*/
|
|
95
|
+
export const RELAY_HTTP_URL = RELAY_URL.replace(/^ws/i, "http");
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How we talk to the relay.
|
|
99
|
+
*
|
|
100
|
+
* ws — WebSocket. Lower latency, one connection, the default.
|
|
101
|
+
* sse — Server-Sent Events downstream + fetch POST upstream (PRD §4.3 R3).
|
|
102
|
+
* Plain HTTP with no Upgrade, so it survives the TLS-inspecting
|
|
103
|
+
* proxies that eat WebSockets on corporate networks (§5.4).
|
|
104
|
+
*
|
|
105
|
+
* Both carry the identical envelopes from §6 — see transport/protocol.js.
|
|
106
|
+
*/
|
|
107
|
+
export const TRANSPORT = { WS: "ws", SSE: "sse" };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Clip size, derived — not chosen.
|
|
111
|
+
*
|
|
112
|
+
* A clip does not go on the wire as text. It is UTF-8 encoded, sealed with
|
|
113
|
+
* AES-GCM (which appends a 16-byte tag), base64'd, and dropped into a JSON
|
|
114
|
+
* envelope — and the relay rejects any frame over MAX_FRAME_BYTES
|
|
115
|
+
* (backend/main.py, 32 KB). Base64 alone inflates by a third, so the real
|
|
116
|
+
* limit is a BYTE budget on the wire and a character count is only an
|
|
117
|
+
* approximation of it.
|
|
118
|
+
*
|
|
119
|
+
* This is the same arithmetic files/chunker.js does for RELAY_CHUNK_BYTES, and
|
|
120
|
+
* it is here for the same reason: a hand-tuned second number drifts out of
|
|
121
|
+
* sync with the relay's cap, and the failure mode is silent.
|
|
122
|
+
*
|
|
123
|
+
* It had. MAX_CHARS was 50,000 — 66 KB on the wire against a 32 KB frame — and
|
|
124
|
+
* the constant cited FR-2.8 while contradicting it, because "max payload 32 KB"
|
|
125
|
+
* had been transcribed into a character count once and never recomputed. Every
|
|
126
|
+
* clip over ~24 KB was accepted by the editor, encrypted, sent, and dropped by
|
|
127
|
+
* the relay, with the rejection arriving as an async `error` frame long after
|
|
128
|
+
* the UI had said it went. Deriving the number is what stops that recurring;
|
|
129
|
+
* tests/unit/clipsize.mjs is what proves the derivation.
|
|
130
|
+
*
|
|
131
|
+
* MAX_CHARS is what the counter shows and what the editor guards on, because
|
|
132
|
+
* users think in characters and one ASCII character is one byte. MAX_BYTES is
|
|
133
|
+
* the truth, and is what the send path enforces: 24,000 CJK characters are
|
|
134
|
+
* 72,000 bytes and will not fit, whatever the counter says.
|
|
135
|
+
*/
|
|
136
|
+
const RELAY_FRAME_BYTES = 32 * 1024; // backend/main.py MAX_FRAME_BYTES
|
|
137
|
+
const CLIP_ENVELOPE_BYTES = 512; // {"t","originId","iv","payload"} + slack
|
|
138
|
+
const BASE64_EXPANSION = 4 / 3;
|
|
139
|
+
const GCM_TAG_BYTES = 16;
|
|
140
|
+
|
|
141
|
+
const CLIP_MAX_BYTES = Math.floor(
|
|
142
|
+
(RELAY_FRAME_BYTES - CLIP_ENVELOPE_BYTES) / BASE64_EXPANSION - GCM_TAG_BYTES,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Wire size of a string. Exported beside the limit it is measured against so
|
|
147
|
+
* the editor and the send path cannot disagree about what "too big" means —
|
|
148
|
+
* `String.length` counts UTF-16 units, which is not what the relay counts.
|
|
149
|
+
*/
|
|
150
|
+
export const textBytes = (s) => new TextEncoder().encode(s).length;
|
|
151
|
+
|
|
152
|
+
export const TEXT = {
|
|
153
|
+
MAX_BYTES: CLIP_MAX_BYTES, // the wire limit — authoritative
|
|
154
|
+
MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // the friendly one, for the counter
|
|
155
|
+
SUPPRESS_MS: 1500, // loop-suppression window after applying a remote clip (FR-2.6)
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const FILES = {
|
|
159
|
+
MAX_BYTES: 5 * 1024 * 1024, // 5 MB per file (FR-7.1)
|
|
160
|
+
MAX_COUNT: 20, // per session, memory only (FR-7.7)
|
|
161
|
+
THUMB_PX: 160, // longest edge (FR-7.2)
|
|
162
|
+
THUMB_QUALITY: 0.7,
|
|
163
|
+
/**
|
|
164
|
+
* Chunk size for the P2P data channel, which carries raw binary.
|
|
165
|
+
*
|
|
166
|
+
* The relay fallback CANNOT use this value directly: a chunk there is
|
|
167
|
+
* base64'd inside a JSON frame, and base64 (plus the AES-GCM tag and the
|
|
168
|
+
* envelope fields) inflates it by roughly a third — a 32 KB chunk becomes a
|
|
169
|
+
* ~44 KB frame and is rejected by the relay's own 32 KB cap. The relay chunk
|
|
170
|
+
* size is therefore DERIVED from this, in files/chunker.js as
|
|
171
|
+
* RELAY_CHUNK_BYTES, rather than being a second hand-tuned number that could
|
|
172
|
+
* drift out of sync with it.
|
|
173
|
+
*/
|
|
174
|
+
CHUNK_BYTES: 32 * 1024,
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* How long a file request lives before both ends give up on it.
|
|
178
|
+
*
|
|
179
|
+
* Both ends, deliberately: the holder's prompt counts down to a denial and
|
|
180
|
+
* the requester stops waiting, on the same number, so neither is left
|
|
181
|
+
* believing in a transfer the other has already abandoned. Approving into a
|
|
182
|
+
* peer that gave up thirty seconds ago sends 5 MB nowhere.
|
|
183
|
+
*
|
|
184
|
+
* Its own constant rather than a multiple of the ICE timeout, which is what
|
|
185
|
+
* it used to be. That coupling meant shortening the ICE race in a test also
|
|
186
|
+
* shortened how long a human had to answer a dialog, and tuning the network
|
|
187
|
+
* silently retuned the UI.
|
|
188
|
+
*/
|
|
189
|
+
REQUEST_TIMEOUT_MS: 15_000,
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* How long the sender waits for a data channel it has closed to confirm the
|
|
193
|
+
* close, before dropping the peer connection out from under it.
|
|
194
|
+
*
|
|
195
|
+
* bufferedAmount reaching zero only means SCTP accepted the bytes, not that
|
|
196
|
+
* the peer has them. The stream reset dc.close() sends is ordered behind the
|
|
197
|
+
* data already queued on that stream, so the close event is the nearest thing
|
|
198
|
+
* the transport offers to a delivery signal — worth one round trip. Bounded,
|
|
199
|
+
* because a wedged association must not hold a finished transfer open.
|
|
200
|
+
*/
|
|
201
|
+
CHANNEL_CLOSE_MS: 1_000,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const KEY = {
|
|
205
|
+
// Crockford-ish: no 0/O, no 1/I/L. Ambiguity here becomes a support ticket.
|
|
206
|
+
ALPHABET: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
|
|
207
|
+
LENGTH: 6, // PRD D3
|
|
208
|
+
LONG_LENGTH: 10, // "high security" option
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const CRYPTO = {
|
|
212
|
+
SALT: "realtimeclipboard-v1",
|
|
213
|
+
ITERATIONS: 250_000, // PBKDF2; derive once per session and cache (OI-8)
|
|
214
|
+
ROOM_HASH_BYTES: 16,
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Locked sessions — see LOCK below and core/crypto.js `deriveLocked`.
|
|
218
|
+
*
|
|
219
|
+
* The salt is a PREFIX, completed with the share key: the key is random per
|
|
220
|
+
* session, which is exactly what a salt is for. The open-session salt above
|
|
221
|
+
* is one global constant, so a single precomputed table covers every user on
|
|
222
|
+
* earth; here an attacker has to build one per key.
|
|
223
|
+
*
|
|
224
|
+
* 600k rather than 250k because the threat is different. An open session's
|
|
225
|
+
* secret is a 29-bit key an attacker has to guess; a locked session's secret
|
|
226
|
+
* is a PIN held by someone who may ALREADY have the link, so the PIN is the
|
|
227
|
+
* whole defence and every doubling of the iteration count is a doubling of
|
|
228
|
+
* their cost. It is paid once per session, behind the "unlocking" state.
|
|
229
|
+
*/
|
|
230
|
+
LOCK_SALT: "realtimeclipboard-lock-v1:",
|
|
231
|
+
LOCK_ITERATIONS: 600_000,
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* HKDF info strings: one PBKDF2 run, three independent outputs.
|
|
235
|
+
*
|
|
236
|
+
* Asking PBKDF2 itself for 64 bytes would cost DOUBLE — it reruns the full
|
|
237
|
+
* iteration count per 32-byte output block, and OI-8 already flags PBKDF2
|
|
238
|
+
* cost on a low-end Android. HKDF expansion is a couple of HMACs.
|
|
239
|
+
*/
|
|
240
|
+
LOCK_INFO: {
|
|
241
|
+
AES: "realtimeclipboard-lock/aes",
|
|
242
|
+
ROOM: "realtimeclipboard-lock/room",
|
|
243
|
+
AUTH: "realtimeclipboard-lock/auth",
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Locked sessions: the share key in the link, a PIN that never travels with it.
|
|
249
|
+
*
|
|
250
|
+
* The key is a bearer credential (PRD §7.1) and a link is a leaky thing — it
|
|
251
|
+
* gets forwarded, screenshotted, pasted into a group chat. A locked session
|
|
252
|
+
* adds a second secret that is never in the URL, never on disk, and never sent
|
|
253
|
+
* to the relay, so holding the link is not sufficient to read the clipboard.
|
|
254
|
+
*
|
|
255
|
+
* MIN_PIN is 6 and the PIN is free-form rather than 4-6 digits, because against
|
|
256
|
+
* someone who already has the link the key contributes nothing and the PIN is
|
|
257
|
+
* the entire secret: a 4-digit PIN is ~13 bits, which is minutes of offline
|
|
258
|
+
* guessing. The dialog states the number rather than an adjective.
|
|
259
|
+
*/
|
|
260
|
+
export const LOCK = {
|
|
261
|
+
MIN_PIN: 6,
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Fragment marker: `#!ABCDEF`. That a session is locked is not a secret — the
|
|
265
|
+
* PIN is — and the app has to know before it connects, so the flag rides in
|
|
266
|
+
* the link. Leading rather than trailing: chat clients that trim punctuation
|
|
267
|
+
* off a pasted URL trim the END of it.
|
|
268
|
+
*/
|
|
269
|
+
SIGIL: "!",
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sent once on creating a locked room, retained by the relay as the room's
|
|
273
|
+
* last clip and replayed to every joiner — so a joiner can tell "wrong PIN"
|
|
274
|
+
* from "first one here" by whether it decrypts. Receivers drop it instead of
|
|
275
|
+
* rendering it. See core/crypto.js and the beacon note in main.js.
|
|
276
|
+
*/
|
|
277
|
+
BEACON: String.fromCharCode(0) + "realtimeclipboard-lock-v1",
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Sent into the room being ABANDONED when the session is locked.
|
|
281
|
+
*
|
|
282
|
+
* Locking is a room change (see main.js): the lock flag is part of the room
|
|
283
|
+
* name, so the locking device leaves for a new, locked room and everyone
|
|
284
|
+
* else is simply left behind in the old one — connected, in sync with
|
|
285
|
+
* nobody, with no way to tell that from a quiet afternoon. This sentinel is
|
|
286
|
+
* the goodbye. A device that decrypts it closes its connection and says what
|
|
287
|
+
* happened, which is the difference between being removed and being
|
|
288
|
+
* mysteriously alone.
|
|
289
|
+
*
|
|
290
|
+
* A clip rather than a new frame type, for the same reason BEACON is one: it
|
|
291
|
+
* needs no relay change, so it works against a deployed relay, and it is
|
|
292
|
+
* sealed with the room key — the relay forwards a sentinel it cannot read.
|
|
293
|
+
*
|
|
294
|
+
* It does overwrite the room's retained last clip (backend `room.last`), and
|
|
295
|
+
* that is deliberate: the retained copy is what a late joiner to the dead
|
|
296
|
+
* room receives, so they are told the session moved instead of being handed
|
|
297
|
+
* a clip from a session they are no longer part of.
|
|
298
|
+
*/
|
|
299
|
+
EVICT: String.fromCharCode(0) + "realtimeclipboard-lock-evict-v1",
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* How long the goodbye gets to leave the machine before the socket is torn
|
|
303
|
+
* down under it.
|
|
304
|
+
*
|
|
305
|
+
* Not paranoia about WebSocket buffering — the SSE fallback batches upstream
|
|
306
|
+
* frames into a POST and its close() drops whatever is still queued
|
|
307
|
+
* (transport/sse.js), so closing in the same tick would send the sentinel to
|
|
308
|
+
* nobody on exactly the network where the fallback is in use. A quarter of a
|
|
309
|
+
* second, once, in a flow that is already opening a dialog.
|
|
310
|
+
*/
|
|
311
|
+
EVICT_FLUSH_MS: 250,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export const NET = {
|
|
315
|
+
HEARTBEAT_MS: 30_000, // must beat proxy idle reaping (PRD 5.4, FR-3.6)
|
|
316
|
+
BACKOFF_MIN_MS: 1_000,
|
|
317
|
+
BACKOFF_MAX_MS: 30_000,
|
|
318
|
+
ICE_TIMEOUT_MS: 5_000, // then fall back to relay chunks (FR-7.6)
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* How long a transport gets to become usable before we give up on it.
|
|
322
|
+
*
|
|
323
|
+
* A blocked WebSocket frequently does NOT fail: an intercepting proxy accepts
|
|
324
|
+
* the TCP connection, swallows the Upgrade, and leaves the socket hanging
|
|
325
|
+
* with no open, no close and no error — forever. Without this timer the app
|
|
326
|
+
* sits on "Connecting…" indefinitely and never tries the fallback, which is
|
|
327
|
+
* the exact failure this whole path exists for.
|
|
328
|
+
*
|
|
329
|
+
* Generous enough to survive a scale-to-zero cold start (PRD R2), which is
|
|
330
|
+
* seconds rather than milliseconds.
|
|
331
|
+
*/
|
|
332
|
+
PROBE_MS: 8_000,
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Consecutive attempts that never became usable before switching transport.
|
|
336
|
+
*
|
|
337
|
+
* Two, not one: a single failure is far more often a cold start or a flaky
|
|
338
|
+
* moment than a policy, and switching on it would put users on the slower
|
|
339
|
+
* path for no reason. Two failures in a row is a proxy.
|
|
340
|
+
*/
|
|
341
|
+
SWITCH_AFTER: 2,
|
|
342
|
+
|
|
343
|
+
/** Frames per upstream POST, and the byte budget for one (SSE path). */
|
|
344
|
+
POST_MAX_FRAMES: 16,
|
|
345
|
+
POST_MAX_BYTES: 256 * 1024,
|
|
346
|
+
|
|
347
|
+
/** How long the remembered transport choice is trusted. */
|
|
348
|
+
TRANSPORT_MEMORY_MS: 12 * 60 * 60 * 1000,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export const POLL_OPTIONS = { "Off": 0, "500ms": 500, "1s": 1000, "2s": 2000 };
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* How much of the OS clipboard the app takes on itself.
|
|
355
|
+
*
|
|
356
|
+
* live — anything you copy anywhere is picked up when this window has
|
|
357
|
+
* focus, and sent. The default, and what "shared clipboard" means.
|
|
358
|
+
* manual — nothing leaves this machine until you paste it in here or press
|
|
359
|
+
* Send. Receiving is unaffected.
|
|
360
|
+
*
|
|
361
|
+
* Manual exists because "live" means every password, token and private
|
|
362
|
+
* message you copy for any reason goes to every device in the session. That is
|
|
363
|
+
* the point of the product, and it is also a lot of trust to extend
|
|
364
|
+
* permanently — someone on a shared or work machine may want the sharing to be
|
|
365
|
+
* a deliberate act.
|
|
366
|
+
*/
|
|
367
|
+
export const SYNC_MODES = {
|
|
368
|
+
LIVE: "live",
|
|
369
|
+
MANUAL: "manual",
|
|
370
|
+
};
|
|
371
|
+
export const DEFAULT_SYNC_MODE = SYNC_MODES.LIVE;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The project's own addresses: where the code is, and where it asks for help.
|
|
375
|
+
*
|
|
376
|
+
* Derived from OWNER/NAME rather than written out four times, for the same
|
|
377
|
+
* reason RELAY_HTTP_URL is derived from RELAY_URL — a fork, a rename, or a
|
|
378
|
+
* moved account is then one edit, and the four links cannot drift into pointing
|
|
379
|
+
* at three different projects.
|
|
380
|
+
*
|
|
381
|
+
* Every link below was checked against a live GitHub on 2026-08-07 and returned
|
|
382
|
+
* 200: the repo, the issue chooser, and the sponsors page. Re-check the sponsors
|
|
383
|
+
* page if the account ever moves — a donate link that 404s costs more goodwill
|
|
384
|
+
* than no donate link. The .github/FUNDING.yml button is the same destination
|
|
385
|
+
* and has to move with it.
|
|
386
|
+
*/
|
|
387
|
+
export const REPO = {
|
|
388
|
+
OWNER: "akshaynikhare",
|
|
389
|
+
NAME: "RealtimeClipboard",
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
export const LINKS = {
|
|
393
|
+
REPO: `https://github.com/${REPO.OWNER}/${REPO.NAME}`,
|
|
394
|
+
ISSUES: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues`,
|
|
395
|
+
/**
|
|
396
|
+
* The chooser, not a blank issue: "report" that lands on a list of other
|
|
397
|
+
* people's bugs asks the user to find the button themselves, and a blank box
|
|
398
|
+
* asks them to guess what we need.
|
|
399
|
+
*
|
|
400
|
+
* `/new/choose` picks between the bug and feature forms in
|
|
401
|
+
* .github/ISSUE_TEMPLATE/ — which is also where the "do not paste your share
|
|
402
|
+
* key" warning lives, and that warning is the single most valuable thing on
|
|
403
|
+
* the page for this product.
|
|
404
|
+
*/
|
|
405
|
+
NEW_ISSUE: `https://github.com/${REPO.OWNER}/${REPO.NAME}/issues/new/choose`,
|
|
406
|
+
SPONSOR: `https://github.com/sponsors/${REPO.OWNER}`,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
export const IMAGES = {
|
|
410
|
+
/** Clipboard image types we will read and share. */
|
|
411
|
+
TYPES: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
|
412
|
+
/** Named so a received screenshot does not land as "blob" on disk. */
|
|
413
|
+
NAME_PREFIX: "clipboard-image",
|
|
414
|
+
};
|