realtimeclipboard 0.3.0 → 0.4.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/README.md +5 -4
- package/cli/realtimeclipboard.mjs +10 -1
- package/package.json +2 -2
- package/src/core/bus.js +19 -2
- package/src/core/config.js +91 -13
- package/src/core/device.js +17 -8
- package/src/core/keys.js +12 -0
- package/src/core/native.js +59 -0
- package/src/core/state.js +87 -4
- package/src/transport/protocol.js +28 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# RealtimeClipboard — an end-to-end encrypted online clipboard that syncs text between devices
|
|
2
2
|
|
|
3
3
|
RealtimeClipboard is a free, open-source online clipboard: open it on two devices, type the
|
|
4
|
-
same
|
|
4
|
+
same short key, and whatever you copy on one is ready to paste on the
|
|
5
5
|
other. No account, no install, no database. Files travel peer-to-peer and never
|
|
6
6
|
touch the server.
|
|
7
7
|
|
|
@@ -28,7 +28,8 @@ is proven, and [Known limitations](#known-limitations) for what is not.
|
|
|
28
28
|
|
|
29
29
|
- **Sync clipboard text between devices** — Windows, macOS, Android, ChromeOS and Linux
|
|
30
30
|
- **Works across different networks**, not just the same Wi-Fi, and not just the same LAN
|
|
31
|
-
- **No account, no sign-up, no email** — a
|
|
31
|
+
- **No account, no sign-up, no email** — a short key is the whole identity of a session
|
|
32
|
+
(six characters on the web, ten in the installed apps, and either works on both)
|
|
32
33
|
- **End-to-end encrypted** in the browser with AES-GCM; `PBKDF2` derives the key, `SHA-256` routes the room
|
|
33
34
|
- **Peer-to-peer file transfer** over a WebRTC data channel, 5 MB per file
|
|
34
35
|
- **Copy and paste images** — a screenshot copied on one machine previews on the other
|
|
@@ -40,7 +41,7 @@ is proven, and [Known limitations](#known-limitations) for what is not.
|
|
|
40
41
|
|
|
41
42
|
Moving a snippet between a work laptop, a desktop and a phone is
|
|
42
43
|
disproportionately annoying. The alternatives want an account, an install with
|
|
43
|
-
admin rights, or an email to yourself. This wants a
|
|
44
|
+
admin rights, or an email to yourself. This wants a short key.
|
|
44
45
|
|
|
45
46
|
## How RealtimeClipboard compares to Snapdrop, PairDrop, LocalSend and AirDrop
|
|
46
47
|
|
|
@@ -85,7 +86,7 @@ with a short key, and share a single clipboard between them.
|
|
|
85
86
|
|
|
86
87
|
### How do I sync my clipboard between my phone and my PC?
|
|
87
88
|
|
|
88
|
-
Open RealtimeClipboard on both, type the same
|
|
89
|
+
Open RealtimeClipboard on both, type the same short key on each, and copy
|
|
89
90
|
something. It arrives on the other device ready to paste. Nothing to install, so
|
|
90
91
|
it works on a machine where you do not have admin rights.
|
|
91
92
|
|
|
@@ -55,7 +55,14 @@ const VERSION = JSON.parse(
|
|
|
55
55
|
/* ------------------------------------------------------------------ args -- */
|
|
56
56
|
|
|
57
57
|
function parse(args) {
|
|
58
|
-
const opts = {
|
|
58
|
+
const opts = {
|
|
59
|
+
relay: null, pin: null, once: false, json: false, timeout: 0, quiet: false,
|
|
60
|
+
// A CLI key gets pasted into shells, scripts and CI configs, where length is
|
|
61
|
+
// friction with no QR code to fall back on — so unlike the installed desktop
|
|
62
|
+
// app this stays short unless asked. REALTIMECLIPBOARD_LONG is the way to
|
|
63
|
+
// ask once, for a machine that generates a lot of them.
|
|
64
|
+
long: !!process.env.REALTIMECLIPBOARD_LONG,
|
|
65
|
+
};
|
|
59
66
|
const rest = [];
|
|
60
67
|
for (let i = 0; i < args.length; i++) {
|
|
61
68
|
const a = args[i];
|
|
@@ -86,6 +93,8 @@ Options
|
|
|
86
93
|
--relay <url> relay to use (default ${DEFAULT_RELAY_URL})
|
|
87
94
|
or set REALTIMECLIPBOARD_RELAY
|
|
88
95
|
--pin <pin> join a locked session (or set REALTIMECLIPBOARD_PIN)
|
|
96
|
+
--long new: a 10-character key (~49 bits) instead of 6 (~29 bits)
|
|
97
|
+
or set REALTIMECLIPBOARD_LONG
|
|
89
98
|
--once watch: exit after the first clip
|
|
90
99
|
--json one JSON object per line instead of raw text
|
|
91
100
|
--timeout <s> give up after this many seconds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "realtimeclipboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"node": ">=22"
|
|
35
35
|
},
|
|
36
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",
|
|
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/unit/syncmode.mjs && node tests/dom/dialog.mjs && node tests/dom/whatsnew.mjs && node tests/dom/tiles.mjs && node tests/dom/guide.mjs && node tests/dom/editor.mjs && node tests/dom/offer.mjs",
|
|
38
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
39
|
"test:static": "node tests/unit/static-check.mjs",
|
|
40
40
|
"test:lock": "node tests/unit/lock.mjs && node tests/dom/dialog.mjs",
|
package/src/core/bus.js
CHANGED
|
@@ -31,8 +31,18 @@ export function emit(event, payload) {
|
|
|
31
31
|
/** Canonical event names. Typos here are silent bugs, so use the constants. */
|
|
32
32
|
export const EV = {
|
|
33
33
|
// clipboard
|
|
34
|
-
TEXT_CAPTURED: "text:captured", // {text, how} —
|
|
35
|
-
TEXT_RECEIVED: "text:received", // {text, from} — arrived from a peer
|
|
34
|
+
TEXT_CAPTURED: "text:captured", // {text, how} — a clip settled here, needs sending
|
|
35
|
+
TEXT_RECEIVED: "text:received", // {text, from} — a clip arrived from a peer
|
|
36
|
+
/**
|
|
37
|
+
* Typing, not clips. {text, caret} out, {text, caret, name, from} in.
|
|
38
|
+
*
|
|
39
|
+
* The pair above is the COMMIT channel: a clip that settled, bound for
|
|
40
|
+
* history, the OS clipboard and the size cap. This pair is the VIEW channel —
|
|
41
|
+
* what the text looks like mid-keystroke. Keeping them apart is what stops a
|
|
42
|
+
* streamed sentence becoming six history entries and six clipboard writes.
|
|
43
|
+
*/
|
|
44
|
+
TEXT_TYPED: "text:typed",
|
|
45
|
+
TEXT_STREAMED: "text:streamed",
|
|
36
46
|
TIER_CHANGED: "clipboard:tier", // {tier, note}
|
|
37
47
|
PENDING_CLIP: "clipboard:pending",// {pending, text} — arrived while unfocused
|
|
38
48
|
CLIP_OFFERED: "clipboard:offered",// {text} — arrived, but the editor has unsent work
|
|
@@ -70,6 +80,13 @@ export const EV = {
|
|
|
70
80
|
* way; the bus does no namespacing of its own and a collision is silent.
|
|
71
81
|
*/
|
|
72
82
|
LOCK_STATE: "session:lockstate",
|
|
83
|
+
/**
|
|
84
|
+
* {required} — a locked link is open and its PIN has not been given, so there
|
|
85
|
+
* is no session and nothing behind the UI is connected to anything. Reported
|
|
86
|
+
* separately from LOCK_STATE because `state.locked` is still false at that
|
|
87
|
+
* point: no session was ever opened to be locked.
|
|
88
|
+
*/
|
|
89
|
+
LOCK_REQUIRED: "session:lockrequired",
|
|
73
90
|
FOUNDER: "session:founder", // {founder} — first into this room? null = not yet known
|
|
74
91
|
|
|
75
92
|
// files
|
package/src/core/config.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* if you find a magic number elsewhere, it belongs here.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { IS_DESKTOP } from "./native.js";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Relay endpoint.
|
|
8
10
|
*
|
|
@@ -153,6 +155,46 @@ export const TEXT = {
|
|
|
153
155
|
MAX_BYTES: CLIP_MAX_BYTES, // the wire limit — authoritative
|
|
154
156
|
MAX_CHARS: Math.floor(CLIP_MAX_BYTES / 1000) * 1000, // the friendly one, for the counter
|
|
155
157
|
SUPPRESS_MS: 1500, // loop-suppression window after applying a remote clip (FR-2.6)
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Typing is streamed for the far editor to render; a CLIP is still a discrete
|
|
161
|
+
* thing, made when the text settles. The split is what keeps history, the OS
|
|
162
|
+
* clipboard write, the dedupe and the size cap resting on whole thoughts
|
|
163
|
+
* instead of on `h`, `he`, `hel`.
|
|
164
|
+
*
|
|
165
|
+
* COMMIT_IDLE_MS is generous because the commit is invisible: the text is
|
|
166
|
+
* already on the far screen via the stream, so this only decides when it
|
|
167
|
+
* becomes a clip. A mid-sentence thinking pause is 300-500 ms, and anything
|
|
168
|
+
* under that would fragment one sentence into six history entries.
|
|
169
|
+
*/
|
|
170
|
+
COMMIT_IDLE_MS: 800,
|
|
171
|
+
|
|
172
|
+
/** 10 sends/sec — half the relay's 20/s `stream` budget, as cursors.js does. */
|
|
173
|
+
STREAM_THROTTLE_MS: 100,
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Above this, typing is not streamed at all and the text syncs on commit only.
|
|
177
|
+
*
|
|
178
|
+
* A stream frame carries the WHOLE text, which is what lets this feature exist
|
|
179
|
+
* without diffs, positions or operational transform. That trade only holds
|
|
180
|
+
* while the text is small: re-sending 20 KB per keystroke would be 200 KB/s
|
|
181
|
+
* through the relay for one person editing a stack trace.
|
|
182
|
+
*
|
|
183
|
+
* The degradation is invisible because big text is pasted, never typed —
|
|
184
|
+
* nobody collaboratively types a stack trace — so the case streaming is for
|
|
185
|
+
* (a URL, a token, a sentence) is entirely inside this bound.
|
|
186
|
+
*/
|
|
187
|
+
STREAM_MAX_BYTES: 4096,
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How long a local copy outranks an arriving clip.
|
|
191
|
+
*
|
|
192
|
+
* Two devices in Clipboard mode both being used by a human means every copy on
|
|
193
|
+
* one overwrites the other's system clipboard, and the moment that actually
|
|
194
|
+
* hurts is "I copied something, went to paste it, and it was gone". Inside
|
|
195
|
+
* this window the arriving clip is queued and offered instead of written.
|
|
196
|
+
*/
|
|
197
|
+
LOCAL_COPY_GRACE_MS: 10_000,
|
|
156
198
|
};
|
|
157
199
|
|
|
158
200
|
export const FILES = {
|
|
@@ -204,8 +246,19 @@ export const FILES = {
|
|
|
204
246
|
export const KEY = {
|
|
205
247
|
// Crockford-ish: no 0/O, no 1/I/L. Ambiguity here becomes a support ticket.
|
|
206
248
|
ALPHABET: "23456789ABCDEFGHJKMNPQRSTVWXYZ",
|
|
207
|
-
LENGTH: 6, // PRD D3
|
|
208
|
-
LONG_LENGTH: 10, // "high security" option
|
|
249
|
+
LENGTH: 6, // PRD D3 — the web default
|
|
250
|
+
LONG_LENGTH: 10, // "high security" option, PRD §7.3
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Installed builds default to the long key; the web keeps six.
|
|
254
|
+
*
|
|
255
|
+
* PRD §7.3's argument for six characters is convenience, and the convenience
|
|
256
|
+
* is a phone keyboard. An installed app links a device by QR or by a copied
|
|
257
|
+
* link, so it is the one surface that does not pay the typing cost the short
|
|
258
|
+
* key buys — and it is the surface running unattended all day reading every
|
|
259
|
+
* copy, which is the case §7.3 says should take the 10-character option.
|
|
260
|
+
*/
|
|
261
|
+
DEFAULT_LONG: IS_DESKTOP,
|
|
209
262
|
};
|
|
210
263
|
|
|
211
264
|
export const CRYPTO = {
|
|
@@ -351,25 +404,38 @@ export const NET = {
|
|
|
351
404
|
export const POLL_OPTIONS = { "Off": 0, "500ms": 500, "1s": 1000, "2s": 2000 };
|
|
352
405
|
|
|
353
406
|
/**
|
|
354
|
-
* How
|
|
407
|
+
* How far the sync reaches on THIS device. One ladder, three rungs, each adding
|
|
408
|
+
* exactly one thing to the one below it:
|
|
409
|
+
*
|
|
410
|
+
* off — nothing leaves and nothing arrives. The editor is private again.
|
|
411
|
+
* manual — the session syncs in the app window. The OS clipboard is neither
|
|
412
|
+
* read nor written; the app is a window onto the room.
|
|
413
|
+
* live — the OS clipboard is wired to the room in both directions. What you
|
|
414
|
+
* copy anywhere goes out; what arrives is copied here.
|
|
355
415
|
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
416
|
+
* The room itself is unconditional: every connected device sees every clip in
|
|
417
|
+
* the app view whatever rung it is on. The rung only decides how deep into the
|
|
418
|
+
* machine the connection goes, which is why the labels name the destination
|
|
419
|
+
* (Off / App / Clipboard) rather than a manner of working.
|
|
360
420
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
* a deliberate act.
|
|
421
|
+
* The stored strings are a compatibility surface. "live" and "manual" are on
|
|
422
|
+
* disk for every existing user under STORAGE_PREFIX, and relabelling the UI is
|
|
423
|
+
* free while renaming these silently resets the one preference where being
|
|
424
|
+
* wrong matters most. Change the labels in ui/features/syncMode.js instead.
|
|
366
425
|
*/
|
|
367
426
|
export const SYNC_MODES = {
|
|
368
|
-
|
|
427
|
+
OFF: "off",
|
|
369
428
|
MANUAL: "manual",
|
|
429
|
+
LIVE: "live",
|
|
370
430
|
};
|
|
371
431
|
export const DEFAULT_SYNC_MODE = SYNC_MODES.LIVE;
|
|
372
432
|
|
|
433
|
+
/** Does this rung let the OS clipboard be read or written? */
|
|
434
|
+
export const bindsClipboard = (mode) => mode === SYNC_MODES.LIVE;
|
|
435
|
+
|
|
436
|
+
/** Does this rung put anything on the wire at all? */
|
|
437
|
+
export const sharesSession = (mode) => mode !== SYNC_MODES.OFF;
|
|
438
|
+
|
|
373
439
|
/**
|
|
374
440
|
* The project's own addresses: where the code is, and where it asks for help.
|
|
375
441
|
*
|
|
@@ -406,6 +472,18 @@ export const LINKS = {
|
|
|
406
472
|
SPONSOR: `https://github.com/sponsors/${REPO.OWNER}`,
|
|
407
473
|
};
|
|
408
474
|
|
|
475
|
+
/**
|
|
476
|
+
* The app offer in the header — the desktop build and the PWA install, sharing
|
|
477
|
+
* one quiet row (ui/features/install.js).
|
|
478
|
+
*
|
|
479
|
+
* A minute is long enough to read a sentence you did not ask for, finish the
|
|
480
|
+
* paste you came here for, and come back to it; short enough that the header is
|
|
481
|
+
* the header again by the time you next look up. Only the × is remembered — a
|
|
482
|
+
* row that timed out has not been declined, so the offer returns on a later
|
|
483
|
+
* visit.
|
|
484
|
+
*/
|
|
485
|
+
export const OFFER = { DISMISS_MS: 60_000 };
|
|
486
|
+
|
|
409
487
|
export const IMAGES = {
|
|
410
488
|
/** Clipboard image types we will read and share. */
|
|
411
489
|
TYPES: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
package/src/core/device.js
CHANGED
|
@@ -18,16 +18,25 @@
|
|
|
18
18
|
|
|
19
19
|
import { read, write } from "./storage.js";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Windows | Android | iOS | macOS | Linux | Unknown.
|
|
23
|
+
*
|
|
24
|
+
* Exported because the desktop guide describes the tray icon, and the tray
|
|
25
|
+
* behaves differently enough on each platform that one paragraph covering all
|
|
26
|
+
* three would be wrong on two of them.
|
|
27
|
+
*/
|
|
28
|
+
export function os() {
|
|
29
|
+
const ua = globalThis.navigator?.userAgent ?? "";
|
|
30
|
+
return /Windows/i.test(ua) ? "Windows" :
|
|
31
|
+
/Android/i.test(ua) ? "Android" :
|
|
32
|
+
/iPhone|iPad|iPod/i.test(ua) ? "iOS" :
|
|
33
|
+
/Mac OS X|Macintosh/i.test(ua) ? "macOS" :
|
|
34
|
+
/Linux/i.test(ua) ? "Linux" : "Unknown";
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
function detect() {
|
|
22
38
|
const ua = navigator.userAgent;
|
|
23
39
|
|
|
24
|
-
const os =
|
|
25
|
-
/Windows/i.test(ua) ? "Windows" :
|
|
26
|
-
/Android/i.test(ua) ? "Android" :
|
|
27
|
-
/iPhone|iPad|iPod/i.test(ua) ? "iOS" :
|
|
28
|
-
/Mac OS X|Macintosh/i.test(ua) ? "macOS" :
|
|
29
|
-
/Linux/i.test(ua) ? "Linux" : "Unknown";
|
|
30
|
-
|
|
31
40
|
// Order matters: Edge and Opera both contain "Chrome", Chrome contains "Safari".
|
|
32
41
|
const browser =
|
|
33
42
|
/Edg\//i.test(ua) ? "Edge" :
|
|
@@ -36,7 +45,7 @@ function detect() {
|
|
|
36
45
|
/Chrome\//i.test(ua) ? "Chrome" :
|
|
37
46
|
/Safari\//i.test(ua) ? "Safari" : "Browser";
|
|
38
47
|
|
|
39
|
-
return `${browser} · ${os}`;
|
|
48
|
+
return `${browser} · ${os()}`;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
/** Persisted so a device keeps its name across reloads, and stays renameable. */
|
package/src/core/keys.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Share-key generation and normalisation. */
|
|
2
2
|
|
|
3
3
|
import { KEY, LOCK } from "./config.js";
|
|
4
|
+
import * as state from "./state.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Cryptographically random key from the unambiguous alphabet.
|
|
@@ -32,6 +33,17 @@ export function entropyBits(length) {
|
|
|
32
33
|
|
|
33
34
|
export const LENGTHS = { NORMAL: KEY.LENGTH, LONG: KEY.LONG_LENGTH };
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* How long the NEXT key should be.
|
|
38
|
+
*
|
|
39
|
+
* One implementation, because there were three and two of them ignored the
|
|
40
|
+
* setting entirely: the first-run key and the collision retry both emitted six
|
|
41
|
+
* characters however the app was configured — which is precisely the two places
|
|
42
|
+
* a key is generated FOR the user rather than at their request.
|
|
43
|
+
*/
|
|
44
|
+
export const nextLength = () =>
|
|
45
|
+
state.get().settings.longKeys ? LENGTHS.LONG : LENGTHS.NORMAL;
|
|
46
|
+
|
|
35
47
|
/**
|
|
36
48
|
* Normalise before ANY use — hashing, comparison, display.
|
|
37
49
|
*
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows there is a native shell underneath.
|
|
3
|
+
*
|
|
4
|
+
* Rank 0 and DOM-free, because the answer also decides the default key length
|
|
5
|
+
* in config.js — and core/ is imported by the node tests and shipped in the npm
|
|
6
|
+
* package that cli/ runs on.
|
|
7
|
+
*
|
|
8
|
+
* Before this module existed, three files feature-tested `globalThis.__TAURI__`
|
|
9
|
+
* independently and all three failed the same way at once: `withGlobalTauri`
|
|
10
|
+
* had never been switched on in tauri.conf.json, so the global did not exist,
|
|
11
|
+
* T0 never started, and the desktop app silently degraded to a browser tab that
|
|
12
|
+
* cannot watch the clipboard. One owner, one feature test, one place to look.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const g = globalThis;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `__TAURI_INTERNALS__` is injected into every Tauri webview; `__TAURI__` only
|
|
19
|
+
* when `withGlobalTauri` is on. Both are checked so the SURFACE answer stays
|
|
20
|
+
* correct even if that flag is ever turned off again — which is exactly the
|
|
21
|
+
* failure this module was written after.
|
|
22
|
+
*/
|
|
23
|
+
const IN_TAURI = typeof g.__TAURI_INTERNALS__ === "object"
|
|
24
|
+
|| typeof g.__TAURI__ === "object";
|
|
25
|
+
|
|
26
|
+
/** `document` first: jsdom has both, and the DOM suites want to be "web". */
|
|
27
|
+
const IN_NODE = typeof document === "undefined" && !!g.process?.versions?.node;
|
|
28
|
+
|
|
29
|
+
export const SURFACE = IN_TAURI ? "desktop" : IN_NODE ? "cli" : "web";
|
|
30
|
+
export const IS_DESKTOP = SURFACE === "desktop";
|
|
31
|
+
export const IS_WEB = SURFACE === "web";
|
|
32
|
+
export const IS_CLI = SURFACE === "cli";
|
|
33
|
+
|
|
34
|
+
/** Call a command in desktop/src-tauri/src/main.rs. Null anywhere else. */
|
|
35
|
+
export async function invoke(command, args) {
|
|
36
|
+
const core = g.__TAURI__?.core;
|
|
37
|
+
if (!core?.invoke) return null;
|
|
38
|
+
try {
|
|
39
|
+
return await core.invoke(command, args);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.warn(`[realtimeclipboard] native "${command}" failed:`, err);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to an event the shell emits. Resolves to an unsubscribe function,
|
|
48
|
+
* or null off the desktop so callers can treat "no shell" as ordinary.
|
|
49
|
+
*/
|
|
50
|
+
export async function listen(event, fn) {
|
|
51
|
+
const api = g.__TAURI__?.event;
|
|
52
|
+
if (!api?.listen) return null;
|
|
53
|
+
try {
|
|
54
|
+
return await api.listen(event, ({ payload }) => fn(payload));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.warn(`[realtimeclipboard] native listen "${event}" failed:`, err);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/core/state.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { emit, EV } from "./bus.js";
|
|
10
|
-
import { DEFAULT_SYNC_MODE } from "./config.js";
|
|
10
|
+
import { DEFAULT_SYNC_MODE, SYNC_MODES, KEY, TEXT } from "./config.js";
|
|
11
|
+
import * as storage from "./storage.js";
|
|
11
12
|
|
|
12
13
|
const state = {
|
|
13
14
|
key: null,
|
|
@@ -45,20 +46,89 @@ const state = {
|
|
|
45
46
|
tier: "T1", // clipboard capture tier, see clipboard/capture.js
|
|
46
47
|
lastSent: "", // dedupe guard (FR-2.7)
|
|
47
48
|
suppressUntil: 0, // loop-suppression deadline (FR-2.6)
|
|
49
|
+
lastLocalCopyAt: 0, // a local copy outranks an arriving clip — see recentLocalCopy()
|
|
48
50
|
settings: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// off | manual | live — one ladder, see config.js SYNC_MODES. Reading and
|
|
52
|
+
// writing the OS clipboard are both derived from this and neither has a
|
|
53
|
+
// switch of its own: two controls over one behaviour eventually disagree,
|
|
54
|
+
// and then the app looks like it is ignoring its own setting.
|
|
55
|
+
syncMode: DEFAULT_SYNC_MODE,
|
|
52
56
|
autoaccept: false,
|
|
53
57
|
thumbs: true,
|
|
54
58
|
images: true,
|
|
55
59
|
cursors: true,
|
|
60
|
+
longKeys: KEY.DEFAULT_LONG, // installed builds default to 10 chars
|
|
61
|
+
closeToTray: true, // desktop only; the X button hides, Quit is in the tray
|
|
56
62
|
poll: "1s",
|
|
57
63
|
},
|
|
58
64
|
};
|
|
59
65
|
|
|
60
66
|
export const get = () => state;
|
|
61
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Seed settings from storage.
|
|
70
|
+
*
|
|
71
|
+
* Runs first in boot(), and the ordering is the point: resolveKey() asks how
|
|
72
|
+
* long the next key should be, and used to be answered from the defaults
|
|
73
|
+
* because the panel that restored preferences had not initialised yet.
|
|
74
|
+
*
|
|
75
|
+
* A key absent from the saved object keeps its default above. That is what
|
|
76
|
+
* lets a setting be ADDED without silently turning it off for everyone who
|
|
77
|
+
* saved their preferences under an earlier build.
|
|
78
|
+
*/
|
|
79
|
+
export function restore() {
|
|
80
|
+
const saved = storage.loadSettings();
|
|
81
|
+
if (saved) Object.assign(state.settings, saved);
|
|
82
|
+
|
|
83
|
+
migrateReceiving(saved);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One-time move to the long key on installed builds.
|
|
87
|
+
*
|
|
88
|
+
* Anyone who toggled any switch before this shipped has an explicit
|
|
89
|
+
* `longKeys: false` they never chose — persist() writes the whole settings
|
|
90
|
+
* object, so the old default was recorded as a decision. Gated on a marker so
|
|
91
|
+
* it happens once and a later deliberate "off" stands.
|
|
92
|
+
*
|
|
93
|
+
* Nothing existing is invalidated: this only changes the NEXT key generated.
|
|
94
|
+
*/
|
|
95
|
+
if (KEY.DEFAULT_LONG && !storage.read("longKeyDefault")) {
|
|
96
|
+
state.settings.longKeys = true;
|
|
97
|
+
storage.write("longKeyDefault", KEY.LONG_LENGTH);
|
|
98
|
+
storage.saveSettings(state.settings);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Receiving used to be its own switch, defaulting on and independent of the
|
|
104
|
+
* mode, so Manual stopped this machine SENDING while incoming clips still
|
|
105
|
+
* landed on its system clipboard. Both directions are the mode's now.
|
|
106
|
+
*
|
|
107
|
+
* Anyone who turned that switch off asked for exactly one thing: nothing
|
|
108
|
+
* arriving should touch their clipboard. On the new ladder only Manual honours
|
|
109
|
+
* that, so they are moved there rather than silently promoted to a Live rung
|
|
110
|
+
* that does the thing they opted out of. The dead keys go with them, which is
|
|
111
|
+
* also what makes this run once — after the next save they are not on disk.
|
|
112
|
+
*/
|
|
113
|
+
function migrateReceiving(saved) {
|
|
114
|
+
if (!saved) return;
|
|
115
|
+
if (!("autowrite" in saved) && !("autoread" in saved)) return;
|
|
116
|
+
|
|
117
|
+
if (saved.autowrite === false && state.settings.syncMode === SYNC_MODES.LIVE) {
|
|
118
|
+
state.settings.syncMode = SYNC_MODES.MANUAL;
|
|
119
|
+
}
|
|
120
|
+
delete state.settings.autowrite;
|
|
121
|
+
delete state.settings.autoread;
|
|
122
|
+
storage.saveSettings(state.settings);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Change a setting and remember it. The only path that persists one. */
|
|
126
|
+
export function saveSetting(name, value) {
|
|
127
|
+
state.settings[name] = value;
|
|
128
|
+
storage.saveSettings(state.settings);
|
|
129
|
+
emit(EV.SETTINGS_CHANGED, { name, value });
|
|
130
|
+
}
|
|
131
|
+
|
|
62
132
|
export function setKey({ key, roomHash, aesKey, locked = false, authToken = null }) {
|
|
63
133
|
state.key = key;
|
|
64
134
|
state.roomHash = roomHash ?? state.roomHash;
|
|
@@ -191,3 +261,16 @@ export function setSetting(name, value) {
|
|
|
191
261
|
/** Mute local capture briefly after applying a remote clip (FR-2.6). */
|
|
192
262
|
export function suppress(ms) { state.suppressUntil = Date.now() + ms; }
|
|
193
263
|
export function isSuppressed() { return Date.now() < state.suppressUntil; }
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* When this machine last put something on its own clipboard.
|
|
267
|
+
*
|
|
268
|
+
* Distinct from `suppressUntil`, which is about not echoing our own writes back
|
|
269
|
+
* to the room. This is about precedence: for a few seconds after you copy
|
|
270
|
+
* something here, what you copied is what you are about to paste, and a clip
|
|
271
|
+
* arriving from another device does not get to take it away.
|
|
272
|
+
*/
|
|
273
|
+
export function markLocalCopy() { state.lastLocalCopyAt = Date.now(); }
|
|
274
|
+
export function recentLocalCopy() {
|
|
275
|
+
return Date.now() - state.lastLocalCopyAt < TEXT.LOCAL_COPY_GRACE_MS;
|
|
276
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export const T = {
|
|
9
9
|
HELLO: "hello",
|
|
10
10
|
CLIP: "clip",
|
|
11
|
+
STREAM: "stream",
|
|
11
12
|
PING: "ping",
|
|
12
13
|
PONG: "pong",
|
|
13
14
|
WELCOME: "welcome",
|
|
@@ -33,6 +34,33 @@ export const clip = ({ payload, iv, originId }) => ({
|
|
|
33
34
|
t: T.CLIP, payload, iv, originId, ts: Date.now(),
|
|
34
35
|
});
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Typing, for the far editor to render. A VIEW frame, and the distinction from
|
|
39
|
+
* `clip` is the whole design:
|
|
40
|
+
*
|
|
41
|
+
* clip — a discrete thing that settled. Goes to history, to the OS
|
|
42
|
+
* clipboard, through the dedupe and against the size cap. Retained
|
|
43
|
+
* by the relay and replayed to late joiners (FR-3.3).
|
|
44
|
+
* stream — what the text looks like right now. Renders and nothing else. Not
|
|
45
|
+
* retained, not replayed, never written to anyone's clipboard.
|
|
46
|
+
*
|
|
47
|
+
* It carries the WHOLE text rather than a diff, which is what lets live typing
|
|
48
|
+
* exist here without positions or operational transform. That trade is bounded
|
|
49
|
+
* by TEXT.STREAM_MAX_BYTES — above it the editor stops streaming and the text
|
|
50
|
+
* syncs on commit only, because re-sending 20 KB per keystroke is not a thing
|
|
51
|
+
* anybody should do to a relay.
|
|
52
|
+
*
|
|
53
|
+
* `name` rides along for the same reason it does on a cursor frame: nothing
|
|
54
|
+
* replays these, so a device that joins mid-sentence would otherwise render an
|
|
55
|
+
* unattributed caret until the next roster frame happened to arrive.
|
|
56
|
+
*
|
|
57
|
+
* Everything but `t` and `originId` is sealed by main.js encryptFrame(), so the
|
|
58
|
+
* relay forwards a blob and never sees a keystroke.
|
|
59
|
+
*/
|
|
60
|
+
export const stream = ({ text, caret, name, originId }) => ({
|
|
61
|
+
t: T.STREAM, text, caret, name, originId,
|
|
62
|
+
});
|
|
63
|
+
|
|
36
64
|
export const ping = () => ({ t: T.PING });
|
|
37
65
|
|
|
38
66
|
export const fileMeta = ({ id, name, size, type, thumb, originId }) => ({
|