From 8e603f7db2880263a7a52ebe9ec375af36743b70 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 14:21:09 +0530 Subject: [PATCH 001/145] feat(sync): vendor @offgrid/sync into desktop + integration plan (M0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M0 of docs/SYNC_INTEGRATION_PLAN.md. Brings the public sync engine in so the pro integration can consume it; no behaviour wired yet. - Vendors shared/packages/sync -> desktop/packages/sync following the existing convention (@offgrid/clipboard|design|models|rag are git-tracked copies consumed via file: deps). Records provenance (offgridVendoredFrom: commit 9b671b5) in the vendored package.json, because the existing copies have silently DRIFTED from shared/ and that should be visible. - Adds runtime deps the engine needs: bonjour-service (pure-JS mDNS, no native build, used by the node-discovery adapter), tweetnacl, tweetnacl-util, js-sha512. - Engine is consumed UNCHANGED: the mobile lane is working in the same package and two sessions editing it is the one guaranteed conflict. Engine changes go through the plan's 'Engine asks'. Gate: 24/24 package tests pass from the vendored copy; tsc clean on tsconfig.node.json and tsconfig.web.json; ./node + ./node-discovery subpath exports resolve (NodeTcpTransport, NodeDiscovery). Plan correction in the same commit: two engine asks were withdrawn after checking the real build. Streaming/HTTP transfer (createFileRequestStreaming/Http, createFileCompleteStreaming, verifyFileIntegrity) and ACK (createFileAck) already exist, so large-model transfer is NOT blocked on the other lane — those are host-wiring rules this lane owns instead. --- docs/SYNC_INTEGRATION_PLAN.md | 147 ++ package-lock.json | 75 +- package.json | 5 + .../sync/dist/adapters/node-discovery.d.mts | 17 + .../sync/dist/adapters/node-discovery.d.ts | 17 + packages/sync/dist/adapters/node-discovery.js | 117 ++ .../sync/dist/adapters/node-discovery.mjs | 59 + packages/sync/dist/adapters/node-tcp.d.mts | 12 + packages/sync/dist/adapters/node-tcp.d.ts | 12 + packages/sync/dist/adapters/node-tcp.js | 82 + packages/sync/dist/adapters/node-tcp.mjs | 47 + .../sync/dist/adapters/rn-discovery.d.mts | 38 + packages/sync/dist/adapters/rn-discovery.d.ts | 38 + packages/sync/dist/adapters/rn-discovery.js | 122 ++ packages/sync/dist/adapters/rn-discovery.mjs | 64 + packages/sync/dist/adapters/rn-tcp.d.mts | 52 + packages/sync/dist/adapters/rn-tcp.d.ts | 52 + packages/sync/dist/adapters/rn-tcp.js | 76 + packages/sync/dist/adapters/rn-tcp.mjs | 51 + packages/sync/dist/chunk-UMHRNOI2.mjs | 74 + packages/sync/dist/index-D7PLqM1E.d.mts | 264 +++ packages/sync/dist/index-D7PLqM1E.d.ts | 264 +++ packages/sync/dist/index.d.mts | 575 ++++++ packages/sync/dist/index.d.ts | 575 ++++++ packages/sync/dist/index.js | 1565 +++++++++++++++++ packages/sync/dist/index.mjs | 1381 +++++++++++++++ packages/sync/dist/portable/index.d.mts | 165 ++ packages/sync/dist/portable/index.d.ts | 165 ++ packages/sync/dist/portable/index.js | 181 ++ packages/sync/dist/portable/index.mjs | 145 ++ packages/sync/dist/transport-1cXLtrs5.d.mts | 22 + packages/sync/dist/transport-1cXLtrs5.d.ts | 22 + packages/sync/package.json | 61 + packages/sync/src/adapters/node-discovery.ts | 68 + packages/sync/src/adapters/node-tcp.ts | 56 + packages/sync/src/adapters/rn-discovery.ts | 103 ++ packages/sync/src/adapters/rn-tcp.ts | 93 + packages/sync/src/cap.ts | 37 + packages/sync/src/crypto/index.ts | 210 +++ packages/sync/src/discovery/index.ts | 115 ++ packages/sync/src/engine.ts | 256 +++ packages/sync/src/index.ts | 46 + packages/sync/src/oplog.ts | Bin 0 -> 5390 bytes packages/sync/src/orchestrator.ts | 61 + packages/sync/src/pairing/index.ts | 319 ++++ packages/sync/src/portable/bundle.ts | 73 + packages/sync/src/portable/engine.ts | 153 ++ packages/sync/src/portable/index.ts | 9 + packages/sync/src/portable/merge.ts | 23 + packages/sync/src/portable/types.ts | 49 + packages/sync/src/protocol/index.ts | 287 +++ packages/sync/src/state-sync.ts | 42 + packages/sync/src/transfer/index.ts | 512 ++++++ packages/sync/src/transport.ts | 28 + packages/sync/src/types/index.ts | 291 +++ packages/sync/src/wire.ts | 126 ++ packages/sync/test/cap.test.mjs | 85 + packages/sync/test/discovery.test.mjs | 40 + packages/sync/test/handshake.test.mjs | 101 ++ packages/sync/test/node-tcp.test.mjs | 52 + packages/sync/test/portable-engine.test.mjs | 225 +++ packages/sync/test/portable.test.mjs | 94 + packages/sync/test/reconnect.test.mjs | 113 ++ packages/sync/tsconfig.json | 12 + 64 files changed, 10220 insertions(+), 1 deletion(-) create mode 100644 docs/SYNC_INTEGRATION_PLAN.md create mode 100644 packages/sync/dist/adapters/node-discovery.d.mts create mode 100644 packages/sync/dist/adapters/node-discovery.d.ts create mode 100644 packages/sync/dist/adapters/node-discovery.js create mode 100644 packages/sync/dist/adapters/node-discovery.mjs create mode 100644 packages/sync/dist/adapters/node-tcp.d.mts create mode 100644 packages/sync/dist/adapters/node-tcp.d.ts create mode 100644 packages/sync/dist/adapters/node-tcp.js create mode 100644 packages/sync/dist/adapters/node-tcp.mjs create mode 100644 packages/sync/dist/adapters/rn-discovery.d.mts create mode 100644 packages/sync/dist/adapters/rn-discovery.d.ts create mode 100644 packages/sync/dist/adapters/rn-discovery.js create mode 100644 packages/sync/dist/adapters/rn-discovery.mjs create mode 100644 packages/sync/dist/adapters/rn-tcp.d.mts create mode 100644 packages/sync/dist/adapters/rn-tcp.d.ts create mode 100644 packages/sync/dist/adapters/rn-tcp.js create mode 100644 packages/sync/dist/adapters/rn-tcp.mjs create mode 100644 packages/sync/dist/chunk-UMHRNOI2.mjs create mode 100644 packages/sync/dist/index-D7PLqM1E.d.mts create mode 100644 packages/sync/dist/index-D7PLqM1E.d.ts create mode 100644 packages/sync/dist/index.d.mts create mode 100644 packages/sync/dist/index.d.ts create mode 100644 packages/sync/dist/index.js create mode 100644 packages/sync/dist/index.mjs create mode 100644 packages/sync/dist/portable/index.d.mts create mode 100644 packages/sync/dist/portable/index.d.ts create mode 100644 packages/sync/dist/portable/index.js create mode 100644 packages/sync/dist/portable/index.mjs create mode 100644 packages/sync/dist/transport-1cXLtrs5.d.mts create mode 100644 packages/sync/dist/transport-1cXLtrs5.d.ts create mode 100644 packages/sync/package.json create mode 100644 packages/sync/src/adapters/node-discovery.ts create mode 100644 packages/sync/src/adapters/node-tcp.ts create mode 100644 packages/sync/src/adapters/rn-discovery.ts create mode 100644 packages/sync/src/adapters/rn-tcp.ts create mode 100644 packages/sync/src/cap.ts create mode 100644 packages/sync/src/crypto/index.ts create mode 100644 packages/sync/src/discovery/index.ts create mode 100644 packages/sync/src/engine.ts create mode 100644 packages/sync/src/index.ts create mode 100644 packages/sync/src/oplog.ts create mode 100644 packages/sync/src/orchestrator.ts create mode 100644 packages/sync/src/pairing/index.ts create mode 100644 packages/sync/src/portable/bundle.ts create mode 100644 packages/sync/src/portable/engine.ts create mode 100644 packages/sync/src/portable/index.ts create mode 100644 packages/sync/src/portable/merge.ts create mode 100644 packages/sync/src/portable/types.ts create mode 100644 packages/sync/src/protocol/index.ts create mode 100644 packages/sync/src/state-sync.ts create mode 100644 packages/sync/src/transfer/index.ts create mode 100644 packages/sync/src/transport.ts create mode 100644 packages/sync/src/types/index.ts create mode 100644 packages/sync/src/wire.ts create mode 100644 packages/sync/test/cap.test.mjs create mode 100644 packages/sync/test/discovery.test.mjs create mode 100644 packages/sync/test/handshake.test.mjs create mode 100644 packages/sync/test/node-tcp.test.mjs create mode 100644 packages/sync/test/portable-engine.test.mjs create mode 100644 packages/sync/test/portable.test.mjs create mode 100644 packages/sync/test/reconnect.test.mjs create mode 100644 packages/sync/tsconfig.json diff --git a/docs/SYNC_INTEGRATION_PLAN.md b/docs/SYNC_INTEGRATION_PLAN.md new file mode 100644 index 00000000..040a5108 --- /dev/null +++ b/docs/SYNC_INTEGRATION_PLAN.md @@ -0,0 +1,147 @@ +# `@offgrid/sync` → Off Grid AI Desktop — implementation plan + +Owner: the **desktop** lane. The **mobile** lane runs in parallel on the same engine package. +Status: plan. Nothing below is "done" until it is **verified through the real user path** — code +present + wired is not closure (same bar as `docs/GAPS_BACKLOG.md`). + +## Scope (first cut, per product direction) + +1. **State sync over the LAN** — chats / workspace / projects / model settings converge across a + user's devices. +2. **Model transfer** — a model downloaded on one device can be moved to another (phone ↔ desktop). +3. **Ambient file sharing** — as designed in `../sync/docs/AMBIENT_SHARING.md` (policy + queue + + watcher on top of the existing transport). + +## Non-negotiable placement rules + +| Thing | Where | Why | +|---|---|---| +| Sync **engine** (crypto, pairing, wire protocol, transfer, op-log) | `@offgrid/sync` in `shared/` — **public** | The encryption and wire format must be auditable. That is the whole point of publishing it. | +| Desktop **integration** of that engine (service, IPC, UI, settings) | `pro/` (the `desktop-pro` submodule) | Sync is a **pro feature**. Core must not carry pro business logic. | +| Core's share | `proCatalog` entry + `locked: !isPro` nav item → `UpgradeScreen`; dimmed `ProPlaceholder` in Settings | The inert shell only. | +| Pro renderer → main | generic `proInvoke` / `proOn` passthrough | Do **not** add per-feature namespaces to the core preload. | + +Commit order for pro changes: land in `desktop-pro` first, then bump the submodule pointer in +`desktop` with `git add pro`. + +## Cross-lane contract (desktop ↔ mobile, read this first) + +The one guaranteed conflict is two sessions editing `shared/packages/sync`. Therefore: + +- **The desktop lane consumes `@offgrid/sync` UNCHANGED.** It already builds and passes 24/24 tests. +- If desktop needs an engine change, it is raised as a **package-level ask** in this doc's + "Engine asks" section rather than edited in place. The mobile lane does the same. +- Host adapters are injected, never assumed: `NodeTcpTransport` + `NodeDiscovery` + (`bonjour-service`) on desktop; the RN TCP/Zeroconf modules on mobile. The package never imports + either — that seam already exists (`src/adapters/{node,rn}-{tcp,discovery}.ts`) and must stay. + +### Engine asks (raise here, do not hand-edit the package) + +- ~~**A-1 (blocks M4, large models).**~~ **WITHDRAWN — not an engine gap.** Verified against the + vendored build: the engine already exposes a streaming and an HTTP-accelerated vocabulary — + `createFileRequestStreaming`, `createFileCompleteStreaming`, `createFileRequestHttp`, + `createFileAcceptHttp`, `verifyFileIntegrity`. The in-memory `chunkFile` / `reassembleChunks` + simply coexist with it. So this is a **host-wiring rule the desktop lane owns**, not a blocker on + the other lane: **model transfer MUST use the streaming/HTTP path and must never call + `chunkFile` / `reassembleChunks`** (an earlier in-memory cut was rejected for exactly this — see + `AMBIENT_SHARING.md`). M4 is therefore **not blocked**. +- ~~**A-2 (ACK semantics).**~~ **RESTATED as a host-wiring rule.** `createFileAck` and + `verifyFileIntegrity` exist in the engine, so the vocabulary is there. G-007 was a defect in + *EasyShare's desktop* resolve timing, not in the engine: it resolved `sendFile(true)` after + emitting chunks rather than after peer confirmation. **Our integration must resolve only on a + correlated positive ACK following the peer's durable write + integrity check**, and must surface + negative ACKs. "Synced" that does not mean "written and verified on the peer" silently loses data. +- **A-3 (multi-device).** The transport is single-connection today; syncing to several devices needs + concurrent connections. The policy layer already models a set of device ids, so this is transport + work only. +- **A-4 (security, and this package is going PUBLIC).** G-001: bespoke iterated-SHA-512 passphrase + derivation + hash challenge/response, and `sharedSecret` persisted in plaintext (electron-store / + AsyncStorage). G-002: no payload-shape validation at the protocol boundary; peer-controlled + messages are cast. Publishing "audit our crypto" while shipping a bespoke KDF and plaintext + secrets invites the opposite conclusion. Should be fixed **before** the repo is public, and it is + engine-level, so it belongs to whoever owns the package — not a desktop-lane side edit. + +## Milestones + +Each milestone states its **verification gate**. No milestone is done without it. + +### M0 — Vendor the engine into desktop + +- Copy `shared/packages/sync` → `desktop/packages/sync` (the existing convention: `desktop/packages/*` + are real, git-tracked copies consumed via `file:` deps, as `@offgrid/clipboard|design|models|rag` + already are). Note those copies have **drifted** from `shared/` — record the source commit in the + vendored `package.json` so drift is visible rather than silent. +- Add `@offgrid/sync` as a `file:./packages/sync` dep, plus `bonjour-service` (pure-JS mDNS, no + native build) for `node-discovery`. +- **Gate:** `npx tsc --noEmit` clean on both tsconfigs; the package's own 24 tests pass from the + vendored copy; `npm run build` produces a working bundle. + +### M1 — Pairing + discovery + transport, headless and real + +`pro/main/sync/`: +- `sync-service.ts` — composes `NodeDiscovery` + `NodeTcpTransport` + the engine. Owns lifecycle and + teardown (no leaked sockets/timers). +- `sync-store.ts` — persistence behind a **small interface** (`getPairedDevices`, `addPairedDevice`, + `getSettings`) so the service runs headless in tests. SQLite-backed impl satisfies it. This mirrors + EasyShare's `ConnectionStorage` seam, which is what made its real test possible. +- Device cap enforced via the engine's `cap` export (2 free / 3+ paid). +- **Gate:** two real service instances pair over **real TCP loopback** in a test, exchange an + encrypted message, and tear down cleanly. Fakes only at the persistence boundary. Falsify it: + break the pairing check → test goes red. + +### M2 — Devices surface + inert core shell + +- `pro/renderer/screens/Devices.tsx` — discovered devices, pair/unpair, connection state, transfer + list. Desktop-first density per `docs/DESIGN.md` (multi-column grid, not one row per 1900px line). +- Register the view through pro's view-router; register a Settings section via + `registerProSettings`. +- Core: `proCatalog` entry + `locked: !isPro` nav item → `UpgradeScreen`. **No pro logic in core.** +- **Gate:** Playwright e2e asserts the surface renders; free build shows the locked upgrade screen; + screenshots read and validated (not just captured) before they go in a PR. + +### M3 — State sync: chat / projects / model settings converge ← **the "does it actually work" gate** + +- Use the engine's `oplog` + `state-sync` (Lamport + last-writer-wins; already pure and tested). +- `pro/main/sync/state-bridge.ts` — maps desktop SQLite entities (chats, projects, model settings) + to op-log records and applies inbound ops idempotently. Pure mapping isolated from I/O so it is + unit-testable; the DB write is the thin edge. +- Conflict policy is the engine's LWW — do **not** re-implement it in the bridge (single source of + truth, per the DRY rule). +- **Gate:** two real app instances on one machine, separate `OFFGRID_USER_DATA` profiles, pair over + loopback; create a chat on A → it appears on B; edit the same record on both while "offline" → + both converge to the same LWW winner. Asserted on the **UI**, not just the DB. + +### M4 — Model transfer (NOT blocked; use the engine's streaming/HTTP path — see A-1) + +- Move a downloaded model between devices: streaming, resumable, checksum-verified, and registered + in the receiver's model catalog (`models/` + `active-model.json`) so it is immediately usable. +- Must not buffer whole files (A-1). Reuse EasyShare's proven streaming + HTTP-accelerated path. +- **Gate:** a real multi-GB-class transfer lands byte-identical (checksum) and the receiving app can + load the model. Interrupt mid-transfer → resumes or fails cleanly, never a corrupt half-model + presented as usable. + +### M5 — Ambient file sharing (needs the `sharing/*` layer in the engine) + +The policy / queue / watcher layer currently lives in the **sync repo** under `@easyshare/shared`, +**not** in `@offgrid/sync`. Porting it is an engine change → coordinate, do not hand-edit. +Then: compose watcher → policy → `FileSender` (over the sync transport) in `pro/main/`, plus the +share-mode matrix in Settings. macOS watcher at the OS boundary +(`NSMetadataQuery` on `kMDItemIsScreenCapture` + FSEvents), every event through `shouldEmit` +(dedup + anti-loop on the app's own save dir). +- **Gate:** an observed screenshot reaches the paired peer with no user interaction, is **not** + re-shared on receipt, and `off` genuinely sends nothing. + +## Risks + +- **Vendoring drift.** `desktop/packages/*` copies already differ from `shared/`. Record the source + commit; re-vendor deliberately. +- **Two lanes, one engine.** Mitigated by the contract above; the engine asks are the pressure valve. +- **Sync that silently loses data** is worse than no sync. A-2 (ACK semantics) is the reason M3's + gate asserts convergence on the UI of a second real instance rather than trusting a resolved promise. +- **Pro submodule flow.** Land in `desktop-pro` first, then bump the pointer; never commit pro source + into the public repo. + +## Immediate next action + +M0, then M1's loopback pairing test — that test is the cheapest honest answer to "does the sync +actually work", and everything after it builds on the same seam. diff --git a/package-lock.json b/package-lock.json index 4a591f84..6a9d2d13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@offgrid/design": "file:./packages/design", "@offgrid/models": "file:./packages/models", "@offgrid/rag": "file:./packages/rag", + "@offgrid/sync": "file:./packages/sync", "@phosphor-icons/react": "^2.1.10", "@scure/bip39": "^2.2.0", "@tabler/icons-react": "^3.36.1", @@ -29,12 +30,14 @@ "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", "better-sqlite3-multiple-ciphers": "^12.11.1", + "bonjour-service": "^1.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "electron-updater": "^6.8.9", "get-windows": "^9.3.0", "hash-wasm": "^4.12.0", + "js-sha512": "^0.9.0", "jszip": "^3.10.1", "kdbxweb": "^2.1.1", "kokoro-js": "^1.2.1", @@ -52,6 +55,8 @@ "remark-parse": "^11.0.0", "sharp": "^0.35.2", "tailwind-merge": "^3.4.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "unified": "^11.0.5" }, "devDependencies": { @@ -3494,6 +3499,12 @@ "node": ">= 18" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -3832,6 +3843,10 @@ "resolved": "packages/rag", "link": true }, + "node_modules/@offgrid/sync": { + "resolved": "packages/sync", + "link": true + }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz", @@ -8783,6 +8798,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bonjour-service": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.3.tgz", + "integrity": "sha512-2Kd5UYlFUVgAKMTyuBLl6w49wqfOnbxHqmuH0oCl/n7TfAikR0zoowNOP5BU4dfXmm+Vr9JyEN370auSMx+CNg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -10038,6 +10063,18 @@ "node": ">= 10.0.0" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -13619,6 +13656,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-sha512": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.9.0.tgz", + "integrity": "sha512-mirki9WS/SUahm+1TbAPkqvbCiCfOAAsyXeHxK1UkullnJVVqoJG2pL9ObvT05CN+tM7fxhfYm0NbXn+1hWoZg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15679,6 +15722,19 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -18988,6 +19044,12 @@ "b4a": "^1.6.4" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -19218,6 +19280,18 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -20848,7 +20922,6 @@ "packages/sync": { "name": "@offgrid/sync", "version": "0.0.1", - "extraneous": true, "license": "AGPL-3.0-only", "dependencies": { "bonjour-service": "^1.2.1", diff --git a/package.json b/package.json index 0e9bcd9c..7e84e167 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@offgrid/design": "file:./packages/design", "@offgrid/models": "file:./packages/models", "@offgrid/rag": "file:./packages/rag", + "@offgrid/sync": "file:./packages/sync", "@phosphor-icons/react": "^2.1.10", "@scure/bip39": "^2.2.0", "@tabler/icons-react": "^3.36.1", @@ -60,12 +61,14 @@ "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", "better-sqlite3-multiple-ciphers": "^12.11.1", + "bonjour-service": "^1.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "electron-updater": "^6.8.9", "get-windows": "^9.3.0", "hash-wasm": "^4.12.0", + "js-sha512": "^0.9.0", "jszip": "^3.10.1", "kdbxweb": "^2.1.1", "kokoro-js": "^1.2.1", @@ -83,6 +86,8 @@ "remark-parse": "^11.0.0", "sharp": "^0.35.2", "tailwind-merge": "^3.4.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "unified": "^11.0.5" }, "devDependencies": { diff --git a/packages/sync/dist/adapters/node-discovery.d.mts b/packages/sync/dist/adapters/node-discovery.d.mts new file mode 100644 index 00000000..3b024339 --- /dev/null +++ b/packages/sync/dist/adapters/node-discovery.d.mts @@ -0,0 +1,17 @@ +import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.mjs'; + +declare class NodeDiscovery implements DiscoveryService { + private bonjour; + private browser?; + private published?; + private foundCb?; + private lostCb?; + start(): Promise; + advertise(device: DeviceInfo): Promise; + stopAdvertising(): Promise; + onDeviceFound(callback: (device: DiscoveredDevice) => void): void; + onDeviceLost(callback: (deviceId: string) => void): void; + stop(): Promise; +} + +export { NodeDiscovery }; diff --git a/packages/sync/dist/adapters/node-discovery.d.ts b/packages/sync/dist/adapters/node-discovery.d.ts new file mode 100644 index 00000000..19ea5043 --- /dev/null +++ b/packages/sync/dist/adapters/node-discovery.d.ts @@ -0,0 +1,17 @@ +import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.js'; + +declare class NodeDiscovery implements DiscoveryService { + private bonjour; + private browser?; + private published?; + private foundCb?; + private lostCb?; + start(): Promise; + advertise(device: DeviceInfo): Promise; + stopAdvertising(): Promise; + onDeviceFound(callback: (device: DiscoveredDevice) => void): void; + onDeviceLost(callback: (deviceId: string) => void): void; + stop(): Promise; +} + +export { NodeDiscovery }; diff --git a/packages/sync/dist/adapters/node-discovery.js b/packages/sync/dist/adapters/node-discovery.js new file mode 100644 index 00000000..205f477c --- /dev/null +++ b/packages/sync/dist/adapters/node-discovery.js @@ -0,0 +1,117 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/adapters/node-discovery.ts +var node_discovery_exports = {}; +__export(node_discovery_exports, { + NodeDiscovery: () => NodeDiscovery +}); +module.exports = __toCommonJS(node_discovery_exports); +var import_bonjour_service = require("bonjour-service"); + +// src/discovery/index.ts +var TXT_DEVICE_ID = "id"; +var TXT_DEVICE_NAME = "name"; +var TXT_PLATFORM = "platform"; +var TXT_VERSION = "version"; +function createTxtRecord(device) { + return { + [TXT_DEVICE_ID]: device.id, + [TXT_DEVICE_NAME]: device.name, + [TXT_PLATFORM]: device.platform, + [TXT_VERSION]: device.version + }; +} +function parseTxtRecord(txt, host, port) { + const id = txt[TXT_DEVICE_ID]; + const name = txt[TXT_DEVICE_NAME]; + const platform = txt[TXT_PLATFORM]; + const version = txt[TXT_VERSION]; + if (!id || !name || !platform || !version) { + return null; + } + return { + id, + name, + platform, + version, + host, + port + }; +} +function createDiscoveredDevice(device) { + return { + ...device, + lastSeen: Date.now() + }; +} + +// src/adapters/node-discovery.ts +var SERVICE_TYPE = "offgrid"; +var NodeDiscovery = class { + bonjour = new import_bonjour_service.Bonjour(); + browser; + published; + foundCb; + lostCb; + async start() { + this.browser = this.bonjour.find({ type: SERVICE_TYPE }); + this.browser.on("up", (service) => { + const txt = service.txt ?? {}; + const host = service.addresses?.find((a) => a.includes(".")) ?? service.host ?? ""; + const info = parseTxtRecord(txt, host, service.port); + if (info) this.foundCb?.(createDiscoveredDevice(info)); + }); + this.browser.on("down", (service) => { + const txt = service.txt ?? {}; + this.lostCb?.(txt.id || service.name); + }); + } + async advertise(device) { + this.published = this.bonjour.publish({ + name: `OffGrid-${device.id}`, + type: SERVICE_TYPE, + port: device.port, + txt: createTxtRecord(device) + }); + } + async stopAdvertising() { + await new Promise((resolve) => { + if (!this.published) return resolve(); + this.published.stop?.(() => resolve()); + this.published = void 0; + setTimeout(resolve, 50); + }); + } + onDeviceFound(callback) { + this.foundCb = callback; + } + onDeviceLost(callback) { + this.lostCb = callback; + } + async stop() { + this.browser?.stop(); + await this.stopAdvertising(); + this.bonjour.destroy(); + } +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + NodeDiscovery +}); diff --git a/packages/sync/dist/adapters/node-discovery.mjs b/packages/sync/dist/adapters/node-discovery.mjs new file mode 100644 index 00000000..824e06cb --- /dev/null +++ b/packages/sync/dist/adapters/node-discovery.mjs @@ -0,0 +1,59 @@ +import { + createDiscoveredDevice, + createTxtRecord, + parseTxtRecord +} from "../chunk-UMHRNOI2.mjs"; + +// src/adapters/node-discovery.ts +import { Bonjour } from "bonjour-service"; +var SERVICE_TYPE = "offgrid"; +var NodeDiscovery = class { + bonjour = new Bonjour(); + browser; + published; + foundCb; + lostCb; + async start() { + this.browser = this.bonjour.find({ type: SERVICE_TYPE }); + this.browser.on("up", (service) => { + const txt = service.txt ?? {}; + const host = service.addresses?.find((a) => a.includes(".")) ?? service.host ?? ""; + const info = parseTxtRecord(txt, host, service.port); + if (info) this.foundCb?.(createDiscoveredDevice(info)); + }); + this.browser.on("down", (service) => { + const txt = service.txt ?? {}; + this.lostCb?.(txt.id || service.name); + }); + } + async advertise(device) { + this.published = this.bonjour.publish({ + name: `OffGrid-${device.id}`, + type: SERVICE_TYPE, + port: device.port, + txt: createTxtRecord(device) + }); + } + async stopAdvertising() { + await new Promise((resolve) => { + if (!this.published) return resolve(); + this.published.stop?.(() => resolve()); + this.published = void 0; + setTimeout(resolve, 50); + }); + } + onDeviceFound(callback) { + this.foundCb = callback; + } + onDeviceLost(callback) { + this.lostCb = callback; + } + async stop() { + this.browser?.stop(); + await this.stopAdvertising(); + this.bonjour.destroy(); + } +}; +export { + NodeDiscovery +}; diff --git a/packages/sync/dist/adapters/node-tcp.d.mts b/packages/sync/dist/adapters/node-tcp.d.mts new file mode 100644 index 00000000..8f129bbb --- /dev/null +++ b/packages/sync/dist/adapters/node-tcp.d.mts @@ -0,0 +1,12 @@ +import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.mjs'; + +declare class NodeTcpTransport implements TransportBridge { + private server?; + /** The port actually bound after listen() (useful when listening on 0). */ + boundPort?: number; + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; + connect(host: string, port: number): Promise; + stop(): Promise; +} + +export { NodeTcpTransport }; diff --git a/packages/sync/dist/adapters/node-tcp.d.ts b/packages/sync/dist/adapters/node-tcp.d.ts new file mode 100644 index 00000000..f0ff8da2 --- /dev/null +++ b/packages/sync/dist/adapters/node-tcp.d.ts @@ -0,0 +1,12 @@ +import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.js'; + +declare class NodeTcpTransport implements TransportBridge { + private server?; + /** The port actually bound after listen() (useful when listening on 0). */ + boundPort?: number; + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; + connect(host: string, port: number): Promise; + stop(): Promise; +} + +export { NodeTcpTransport }; diff --git a/packages/sync/dist/adapters/node-tcp.js b/packages/sync/dist/adapters/node-tcp.js new file mode 100644 index 00000000..0d1de37c --- /dev/null +++ b/packages/sync/dist/adapters/node-tcp.js @@ -0,0 +1,82 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/adapters/node-tcp.ts +var node_tcp_exports = {}; +__export(node_tcp_exports, { + NodeTcpTransport: () => NodeTcpTransport +}); +module.exports = __toCommonJS(node_tcp_exports); +var import_net = __toESM(require("net")); +function wrap(socket) { + const id = `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`; + socket.on("error", () => socket.destroy()); + return { + id, + remoteHost: socket.remoteAddress ?? void 0, + send: (data) => socket.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), + onData: (cb) => socket.on("data", (d) => cb(new Uint8Array(d.buffer, d.byteOffset, d.byteLength))), + onClose: (cb) => socket.on("close", () => cb()), + close: () => socket.destroy() + }; +} +var NodeTcpTransport = class { + server; + /** The port actually bound after listen() (useful when listening on 0). */ + boundPort; + listen(port, onConnection) { + return new Promise((resolve, reject) => { + const server = import_net.default.createServer((socket) => onConnection(wrap(socket))); + server.once("error", reject); + server.listen(port, () => { + const addr = server.address(); + if (addr && typeof addr === "object") this.boundPort = addr.port; + this.server = server; + resolve(); + }); + }); + } + connect(host, port) { + return new Promise((resolve, reject) => { + const socket = import_net.default.createConnection({ host, port }, () => resolve(wrap(socket))); + socket.once("error", reject); + }); + } + stop() { + return new Promise((resolve) => { + if (!this.server) return resolve(); + this.server.close(() => resolve()); + this.server = void 0; + }); + } +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + NodeTcpTransport +}); diff --git a/packages/sync/dist/adapters/node-tcp.mjs b/packages/sync/dist/adapters/node-tcp.mjs new file mode 100644 index 00000000..caf9b78f --- /dev/null +++ b/packages/sync/dist/adapters/node-tcp.mjs @@ -0,0 +1,47 @@ +// src/adapters/node-tcp.ts +import net from "net"; +function wrap(socket) { + const id = `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`; + socket.on("error", () => socket.destroy()); + return { + id, + remoteHost: socket.remoteAddress ?? void 0, + send: (data) => socket.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), + onData: (cb) => socket.on("data", (d) => cb(new Uint8Array(d.buffer, d.byteOffset, d.byteLength))), + onClose: (cb) => socket.on("close", () => cb()), + close: () => socket.destroy() + }; +} +var NodeTcpTransport = class { + server; + /** The port actually bound after listen() (useful when listening on 0). */ + boundPort; + listen(port, onConnection) { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => onConnection(wrap(socket))); + server.once("error", reject); + server.listen(port, () => { + const addr = server.address(); + if (addr && typeof addr === "object") this.boundPort = addr.port; + this.server = server; + resolve(); + }); + }); + } + connect(host, port) { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }, () => resolve(wrap(socket))); + socket.once("error", reject); + }); + } + stop() { + return new Promise((resolve) => { + if (!this.server) return resolve(); + this.server.close(() => resolve()); + this.server = void 0; + }); + } +}; +export { + NodeTcpTransport +}; diff --git a/packages/sync/dist/adapters/rn-discovery.d.mts b/packages/sync/dist/adapters/rn-discovery.d.mts new file mode 100644 index 00000000..ccfa7c9b --- /dev/null +++ b/packages/sync/dist/adapters/rn-discovery.d.mts @@ -0,0 +1,38 @@ +import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.mjs'; + +/** Minimal shape of a react-native-zeroconf resolved service. */ +interface RnZeroconfService { + txt?: Record; + addresses?: string[]; + host?: string; + port: number; + name: string; +} +/** Minimal shape of the react-native-zeroconf instance we use. Publish methods + * are optional — not every RN zeroconf build can advertise; discovery still + * works one-way (we browse; a peer that can advertise gets found and dialed). */ +interface RnZeroconf { + on(event: 'resolved', cb: (service: RnZeroconfService) => void): void; + on(event: 'remove', cb: (name: string) => void): void; + on(event: 'error', cb: (err: unknown) => void): void; + scan(type: string, protocol: string, domain: string): void; + stop(): void; + removeDeviceListeners?(): void; + publishService?(type: string, protocol: string, domain: string, name: string, port: number, txt?: Record): void; + unpublishService?(name: string): void; +} +declare class RnDiscovery implements DiscoveryService { + private readonly zeroconf; + private foundCb?; + private lostCb?; + private publishedName?; + constructor(zeroconf: RnZeroconf); + start(): Promise; + advertise(device: DeviceInfo): Promise; + stopAdvertising(): Promise; + onDeviceFound(callback: (device: DiscoveredDevice) => void): void; + onDeviceLost(callback: (deviceId: string) => void): void; + stop(): Promise; +} + +export { RnDiscovery, type RnZeroconf, type RnZeroconfService }; diff --git a/packages/sync/dist/adapters/rn-discovery.d.ts b/packages/sync/dist/adapters/rn-discovery.d.ts new file mode 100644 index 00000000..7bf4d38e --- /dev/null +++ b/packages/sync/dist/adapters/rn-discovery.d.ts @@ -0,0 +1,38 @@ +import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.js'; + +/** Minimal shape of a react-native-zeroconf resolved service. */ +interface RnZeroconfService { + txt?: Record; + addresses?: string[]; + host?: string; + port: number; + name: string; +} +/** Minimal shape of the react-native-zeroconf instance we use. Publish methods + * are optional — not every RN zeroconf build can advertise; discovery still + * works one-way (we browse; a peer that can advertise gets found and dialed). */ +interface RnZeroconf { + on(event: 'resolved', cb: (service: RnZeroconfService) => void): void; + on(event: 'remove', cb: (name: string) => void): void; + on(event: 'error', cb: (err: unknown) => void): void; + scan(type: string, protocol: string, domain: string): void; + stop(): void; + removeDeviceListeners?(): void; + publishService?(type: string, protocol: string, domain: string, name: string, port: number, txt?: Record): void; + unpublishService?(name: string): void; +} +declare class RnDiscovery implements DiscoveryService { + private readonly zeroconf; + private foundCb?; + private lostCb?; + private publishedName?; + constructor(zeroconf: RnZeroconf); + start(): Promise; + advertise(device: DeviceInfo): Promise; + stopAdvertising(): Promise; + onDeviceFound(callback: (device: DiscoveredDevice) => void): void; + onDeviceLost(callback: (deviceId: string) => void): void; + stop(): Promise; +} + +export { RnDiscovery, type RnZeroconf, type RnZeroconfService }; diff --git a/packages/sync/dist/adapters/rn-discovery.js b/packages/sync/dist/adapters/rn-discovery.js new file mode 100644 index 00000000..c1c153ed --- /dev/null +++ b/packages/sync/dist/adapters/rn-discovery.js @@ -0,0 +1,122 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/adapters/rn-discovery.ts +var rn_discovery_exports = {}; +__export(rn_discovery_exports, { + RnDiscovery: () => RnDiscovery +}); +module.exports = __toCommonJS(rn_discovery_exports); + +// src/discovery/index.ts +var TXT_DEVICE_ID = "id"; +var TXT_DEVICE_NAME = "name"; +var TXT_PLATFORM = "platform"; +var TXT_VERSION = "version"; +function createTxtRecord(device) { + return { + [TXT_DEVICE_ID]: device.id, + [TXT_DEVICE_NAME]: device.name, + [TXT_PLATFORM]: device.platform, + [TXT_VERSION]: device.version + }; +} +function parseTxtRecord(txt, host, port) { + const id = txt[TXT_DEVICE_ID]; + const name = txt[TXT_DEVICE_NAME]; + const platform = txt[TXT_PLATFORM]; + const version = txt[TXT_VERSION]; + if (!id || !name || !platform || !version) { + return null; + } + return { + id, + name, + platform, + version, + host, + port + }; +} +function createDiscoveredDevice(device) { + return { + ...device, + lastSeen: Date.now() + }; +} + +// src/adapters/rn-discovery.ts +var SERVICE_TYPE = "offgrid"; +var PROTOCOL = "tcp"; +var DOMAIN = "local."; +var RnDiscovery = class { + constructor(zeroconf) { + this.zeroconf = zeroconf; + } + zeroconf; + foundCb; + lostCb; + publishedName; + async start() { + this.zeroconf.on("resolved", (svc) => { + const txt = svc.txt ?? {}; + const ipv4 = svc.addresses?.find((a) => a.includes(".")); + const host = ipv4 ?? svc.host ?? svc.addresses?.[0] ?? ""; + const info = parseTxtRecord(txt, host, svc.port); + if (info) this.foundCb?.(createDiscoveredDevice(info)); + }); + this.zeroconf.on("remove", (name) => { + const m = /OffGrid-([^.]+)/.exec(name); + this.lostCb?.(m ? m[1] : name); + }); + this.zeroconf.on("error", () => { + }); + this.zeroconf.scan(SERVICE_TYPE, PROTOCOL, DOMAIN); + } + async advertise(device) { + const name = `OffGrid-${device.id}`; + this.publishedName = name; + if (typeof this.zeroconf.publishService === "function") { + this.zeroconf.publishService(SERVICE_TYPE, PROTOCOL, DOMAIN, name, device.port, createTxtRecord(device)); + } else { + console.warn("[sync] zeroconf.publishService unavailable \u2014 browse-only on this device"); + } + } + async stopAdvertising() { + if (this.publishedName && typeof this.zeroconf.unpublishService === "function") { + this.zeroconf.unpublishService(this.publishedName); + } + this.publishedName = void 0; + } + onDeviceFound(callback) { + this.foundCb = callback; + } + onDeviceLost(callback) { + this.lostCb = callback; + } + async stop() { + await this.stopAdvertising(); + this.zeroconf.stop(); + this.zeroconf.removeDeviceListeners?.(); + } +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + RnDiscovery +}); diff --git a/packages/sync/dist/adapters/rn-discovery.mjs b/packages/sync/dist/adapters/rn-discovery.mjs new file mode 100644 index 00000000..fac3d261 --- /dev/null +++ b/packages/sync/dist/adapters/rn-discovery.mjs @@ -0,0 +1,64 @@ +import { + createDiscoveredDevice, + createTxtRecord, + parseTxtRecord +} from "../chunk-UMHRNOI2.mjs"; + +// src/adapters/rn-discovery.ts +var SERVICE_TYPE = "offgrid"; +var PROTOCOL = "tcp"; +var DOMAIN = "local."; +var RnDiscovery = class { + constructor(zeroconf) { + this.zeroconf = zeroconf; + } + zeroconf; + foundCb; + lostCb; + publishedName; + async start() { + this.zeroconf.on("resolved", (svc) => { + const txt = svc.txt ?? {}; + const ipv4 = svc.addresses?.find((a) => a.includes(".")); + const host = ipv4 ?? svc.host ?? svc.addresses?.[0] ?? ""; + const info = parseTxtRecord(txt, host, svc.port); + if (info) this.foundCb?.(createDiscoveredDevice(info)); + }); + this.zeroconf.on("remove", (name) => { + const m = /OffGrid-([^.]+)/.exec(name); + this.lostCb?.(m ? m[1] : name); + }); + this.zeroconf.on("error", () => { + }); + this.zeroconf.scan(SERVICE_TYPE, PROTOCOL, DOMAIN); + } + async advertise(device) { + const name = `OffGrid-${device.id}`; + this.publishedName = name; + if (typeof this.zeroconf.publishService === "function") { + this.zeroconf.publishService(SERVICE_TYPE, PROTOCOL, DOMAIN, name, device.port, createTxtRecord(device)); + } else { + console.warn("[sync] zeroconf.publishService unavailable \u2014 browse-only on this device"); + } + } + async stopAdvertising() { + if (this.publishedName && typeof this.zeroconf.unpublishService === "function") { + this.zeroconf.unpublishService(this.publishedName); + } + this.publishedName = void 0; + } + onDeviceFound(callback) { + this.foundCb = callback; + } + onDeviceLost(callback) { + this.lostCb = callback; + } + async stop() { + await this.stopAdvertising(); + this.zeroconf.stop(); + this.zeroconf.removeDeviceListeners?.(); + } +}; +export { + RnDiscovery +}; diff --git a/packages/sync/dist/adapters/rn-tcp.d.mts b/packages/sync/dist/adapters/rn-tcp.d.mts new file mode 100644 index 00000000..c38c24d3 --- /dev/null +++ b/packages/sync/dist/adapters/rn-tcp.d.mts @@ -0,0 +1,52 @@ +import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.mjs'; + +/** Minimal shape of a react-native-tcp-socket socket we use. */ +interface RnSocket { + remoteAddress?: string; + on(event: 'data', cb: (data: unknown) => void): void; + on(event: 'close', cb: () => void): void; + on(event: 'error', cb: (err: unknown) => void): void; + write(data: unknown): void; + destroy(): void; +} +/** Minimal shape of a react-native-tcp-socket server we use. */ +interface RnTcpServer { + listen(opts: { + port: number; + host?: string; + }, cb?: () => void): void; + address(): { + port: number; + } | string | null; + on(event: 'error', cb: (err: unknown) => void): void; + close(): void; +} +/** Minimal shape of the react-native-tcp-socket module we use. */ +interface RnTcpModule { + createServer(onConnection: (socket: RnSocket) => void): RnTcpServer; + createConnection(opts: { + host: string; + port: number; + }, cb?: () => void): RnSocket; +} +/** Bytes <-> wire conversion. Injected because RN needs its Buffer polyfill and + * react-native-tcp-socket may deliver 'data' as a (base64) string on Android. */ +interface ByteCodec { + /** Normalize an inbound 'data' payload (Buffer or string) to raw bytes. */ + toBytes(data: unknown): Uint8Array; + /** Convert raw bytes into what socket.write() expects (a Buffer). */ + fromBytes(bytes: Uint8Array): unknown; +} +declare class RnTcpTransport implements TransportBridge { + private readonly tcp; + private readonly codec; + private server?; + /** Port actually bound after listen() (we listen on 0 and advertise this). */ + boundPort?: number; + constructor(tcp: RnTcpModule, codec: ByteCodec); + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; + connect(host: string, port: number): Promise; + stop(): Promise; +} + +export { type ByteCodec, type RnSocket, type RnTcpModule, type RnTcpServer, RnTcpTransport }; diff --git a/packages/sync/dist/adapters/rn-tcp.d.ts b/packages/sync/dist/adapters/rn-tcp.d.ts new file mode 100644 index 00000000..c542b7e2 --- /dev/null +++ b/packages/sync/dist/adapters/rn-tcp.d.ts @@ -0,0 +1,52 @@ +import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.js'; + +/** Minimal shape of a react-native-tcp-socket socket we use. */ +interface RnSocket { + remoteAddress?: string; + on(event: 'data', cb: (data: unknown) => void): void; + on(event: 'close', cb: () => void): void; + on(event: 'error', cb: (err: unknown) => void): void; + write(data: unknown): void; + destroy(): void; +} +/** Minimal shape of a react-native-tcp-socket server we use. */ +interface RnTcpServer { + listen(opts: { + port: number; + host?: string; + }, cb?: () => void): void; + address(): { + port: number; + } | string | null; + on(event: 'error', cb: (err: unknown) => void): void; + close(): void; +} +/** Minimal shape of the react-native-tcp-socket module we use. */ +interface RnTcpModule { + createServer(onConnection: (socket: RnSocket) => void): RnTcpServer; + createConnection(opts: { + host: string; + port: number; + }, cb?: () => void): RnSocket; +} +/** Bytes <-> wire conversion. Injected because RN needs its Buffer polyfill and + * react-native-tcp-socket may deliver 'data' as a (base64) string on Android. */ +interface ByteCodec { + /** Normalize an inbound 'data' payload (Buffer or string) to raw bytes. */ + toBytes(data: unknown): Uint8Array; + /** Convert raw bytes into what socket.write() expects (a Buffer). */ + fromBytes(bytes: Uint8Array): unknown; +} +declare class RnTcpTransport implements TransportBridge { + private readonly tcp; + private readonly codec; + private server?; + /** Port actually bound after listen() (we listen on 0 and advertise this). */ + boundPort?: number; + constructor(tcp: RnTcpModule, codec: ByteCodec); + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; + connect(host: string, port: number): Promise; + stop(): Promise; +} + +export { type ByteCodec, type RnSocket, type RnTcpModule, type RnTcpServer, RnTcpTransport }; diff --git a/packages/sync/dist/adapters/rn-tcp.js b/packages/sync/dist/adapters/rn-tcp.js new file mode 100644 index 00000000..d9f7fa70 --- /dev/null +++ b/packages/sync/dist/adapters/rn-tcp.js @@ -0,0 +1,76 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/adapters/rn-tcp.ts +var rn_tcp_exports = {}; +__export(rn_tcp_exports, { + RnTcpTransport: () => RnTcpTransport +}); +module.exports = __toCommonJS(rn_tcp_exports); +function wrap(socket, codec) { + socket.on("error", () => socket.destroy()); + return { + id: socket.remoteAddress ?? "rn-peer", + remoteHost: socket.remoteAddress, + send: (data) => socket.write(codec.fromBytes(data)), + onData: (cb) => socket.on("data", (d) => cb(codec.toBytes(d))), + onClose: (cb) => socket.on("close", cb), + close: () => socket.destroy() + }; +} +var RnTcpTransport = class { + constructor(tcp, codec) { + this.tcp = tcp; + this.codec = codec; + } + tcp; + codec; + server; + /** Port actually bound after listen() (we listen on 0 and advertise this). */ + boundPort; + listen(port, onConnection) { + return new Promise((resolve, reject) => { + const server = this.tcp.createServer((socket) => onConnection(wrap(socket, this.codec))); + server.on("error", reject); + server.listen({ port, host: "0.0.0.0" }, () => { + const addr = server.address(); + if (addr && typeof addr === "object") this.boundPort = addr.port; + this.server = server; + resolve(); + }); + }); + } + connect(host, port) { + return new Promise((resolve, reject) => { + const socket = this.tcp.createConnection({ host, port }, () => resolve(wrap(socket, this.codec))); + socket.on("error", reject); + }); + } + stop() { + return new Promise((resolve) => { + this.server?.close(); + this.server = void 0; + resolve(); + }); + } +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + RnTcpTransport +}); diff --git a/packages/sync/dist/adapters/rn-tcp.mjs b/packages/sync/dist/adapters/rn-tcp.mjs new file mode 100644 index 00000000..aa46dc84 --- /dev/null +++ b/packages/sync/dist/adapters/rn-tcp.mjs @@ -0,0 +1,51 @@ +// src/adapters/rn-tcp.ts +function wrap(socket, codec) { + socket.on("error", () => socket.destroy()); + return { + id: socket.remoteAddress ?? "rn-peer", + remoteHost: socket.remoteAddress, + send: (data) => socket.write(codec.fromBytes(data)), + onData: (cb) => socket.on("data", (d) => cb(codec.toBytes(d))), + onClose: (cb) => socket.on("close", cb), + close: () => socket.destroy() + }; +} +var RnTcpTransport = class { + constructor(tcp, codec) { + this.tcp = tcp; + this.codec = codec; + } + tcp; + codec; + server; + /** Port actually bound after listen() (we listen on 0 and advertise this). */ + boundPort; + listen(port, onConnection) { + return new Promise((resolve, reject) => { + const server = this.tcp.createServer((socket) => onConnection(wrap(socket, this.codec))); + server.on("error", reject); + server.listen({ port, host: "0.0.0.0" }, () => { + const addr = server.address(); + if (addr && typeof addr === "object") this.boundPort = addr.port; + this.server = server; + resolve(); + }); + }); + } + connect(host, port) { + return new Promise((resolve, reject) => { + const socket = this.tcp.createConnection({ host, port }, () => resolve(wrap(socket, this.codec))); + socket.on("error", reject); + }); + } + stop() { + return new Promise((resolve) => { + this.server?.close(); + this.server = void 0; + resolve(); + }); + } +}; +export { + RnTcpTransport +}; diff --git a/packages/sync/dist/chunk-UMHRNOI2.mjs b/packages/sync/dist/chunk-UMHRNOI2.mjs new file mode 100644 index 00000000..974e92ad --- /dev/null +++ b/packages/sync/dist/chunk-UMHRNOI2.mjs @@ -0,0 +1,74 @@ +// src/discovery/index.ts +var MDNS_SERVICE_TYPE = "_easyshare._tcp"; +var MDNS_SERVICE_NAME = "EasyShare"; +var MDNS_DOMAIN = "local"; +var TXT_DEVICE_ID = "id"; +var TXT_DEVICE_NAME = "name"; +var TXT_PLATFORM = "platform"; +var TXT_VERSION = "version"; +function createTxtRecord(device) { + return { + [TXT_DEVICE_ID]: device.id, + [TXT_DEVICE_NAME]: device.name, + [TXT_PLATFORM]: device.platform, + [TXT_VERSION]: device.version + }; +} +function parseTxtRecord(txt, host, port) { + const id = txt[TXT_DEVICE_ID]; + const name = txt[TXT_DEVICE_NAME]; + const platform = txt[TXT_PLATFORM]; + const version = txt[TXT_VERSION]; + if (!id || !name || !platform || !version) { + return null; + } + return { + id, + name, + platform, + version, + host, + port + }; +} +function createDiscoveredDevice(device) { + return { + ...device, + lastSeen: Date.now() + }; +} +function isDeviceStale(device, maxAgeMs = 3e4) { + return Date.now() - device.lastSeen > maxAgeMs; +} +function filterStaleDevices(devices, maxAgeMs = 3e4) { + return devices.filter((device) => !isDeviceStale(device, maxAgeMs)); +} +function updateDeviceList(devices, newDevice) { + const existingIndex = devices.findIndex((d) => d.id === newDevice.id); + if (existingIndex >= 0) { + const updated = [...devices]; + updated[existingIndex] = { ...newDevice, lastSeen: Date.now() }; + return updated; + } + return [...devices, newDevice]; +} +function removeDevice(devices, deviceId) { + return devices.filter((d) => d.id !== deviceId); +} + +export { + MDNS_SERVICE_TYPE, + MDNS_SERVICE_NAME, + MDNS_DOMAIN, + TXT_DEVICE_ID, + TXT_DEVICE_NAME, + TXT_PLATFORM, + TXT_VERSION, + createTxtRecord, + parseTxtRecord, + createDiscoveredDevice, + isDeviceStale, + filterStaleDevices, + updateDeviceList, + removeDevice +}; diff --git a/packages/sync/dist/index-D7PLqM1E.d.mts b/packages/sync/dist/index-D7PLqM1E.d.mts new file mode 100644 index 00000000..8f427f44 --- /dev/null +++ b/packages/sync/dist/index-D7PLqM1E.d.mts @@ -0,0 +1,264 @@ +type DevicePlatform = 'macos' | 'windows' | 'linux' | 'android' | 'ios'; +interface DeviceInfo { + id: string; + name: string; + platform: DevicePlatform; + version: string; + host: string; + port: number; +} +interface DiscoveredDevice extends DeviceInfo { + lastSeen: number; +} +interface PairedDevice extends DeviceInfo { + sharedSecret: string; + pairedAt: number; + lastConnected?: number; +} +interface PairingChallenge { + challenge: string; + timestamp: number; +} +interface PairingResponse { + response: string; + deviceInfo: DeviceInfo; +} +type PairingStatus = 'idle' | 'waiting' | 'verifying' | 'success' | 'failed'; +type TransferType = 'text' | 'file' | 'files'; +interface TransferMetadata { + id: string; + type: TransferType; + timestamp: number; + direction: 'send' | 'receive'; + deviceId: string; + deviceName: string; +} +interface TextTransfer extends TransferMetadata { + type: 'text'; + content: string; +} +interface FileTransfer extends TransferMetadata { + type: 'file'; + fileName: string; + fileSize: number; + mimeType: string; + filePath?: string; + durationMs?: number; + speedBytesPerSec?: number; +} +interface FilesTransfer extends TransferMetadata { + type: 'files'; + files: Array<{ + fileName: string; + fileSize: number; + mimeType: string; + filePath?: string; + }>; + totalSize: number; +} +type Transfer = TextTransfer | FileTransfer | FilesTransfer; +interface TransferProgress { + transferId: string; + bytesTransferred: number; + totalBytes: number; + percentage: number; + currentFile?: string; + speedBytesPerSec?: number; + etaSeconds?: number; + elapsedMs?: number; +} +interface TransferQueueItem { + id: string; + fileName: string; + fileSize: number; + status: 'pending' | 'transferring' | 'completed' | 'failed'; + progress: number; + direction: 'send' | 'receive'; +} +type MessageType = 'ping' | 'pong' | 'pair_request' | 'pair_challenge' | 'pair_response' | 'pair_confirm' | 'pair_reject' | 'hello' | 'text' | 'file_request' | 'file_accept' | 'file_reject' | 'file_chunk' | 'file_complete' | 'file_ack' | 'app' | 'error'; +interface Message { + type: MessageType; + id: string; + timestamp: number; + payload?: unknown; +} +/** Generic encrypted application message: a channel name + arbitrary payload. + * Lets features (memory sync, clipboard sync, ...) ride the paired channel + * without each needing its own protocol message type. */ +interface AppMessage extends Message { + type: 'app'; + payload: { + channel: string; + data: unknown; + }; +} +/** Reconnect greeting: identifies the device so an already-paired peer can + * resume with the stored shared secret, skipping the pairing handshake. */ +interface HelloMessage extends Message { + type: 'hello'; + payload: { + deviceInfo: DeviceInfo; + }; +} +interface PingMessage extends Message { + type: 'ping'; +} +interface PongMessage extends Message { + type: 'pong'; +} +interface PairRequestMessage extends Message { + type: 'pair_request'; + payload: { + deviceInfo: DeviceInfo; + }; +} +interface PairChallengeMessage extends Message { + type: 'pair_challenge'; + payload: PairingChallenge; +} +interface PairResponseMessage extends Message { + type: 'pair_response'; + payload: PairingResponse; +} +interface PairConfirmMessage extends Message { + type: 'pair_confirm'; + payload: { + deviceInfo: DeviceInfo; + }; +} +interface PairRejectMessage extends Message { + type: 'pair_reject'; + payload: { + reason: string; + }; +} +interface TextMessage extends Message { + type: 'text'; + payload: { + content: string; + }; +} +interface FileRequestMessage extends Message { + type: 'file_request'; + payload: { + fileName: string; + fileSize: number; + mimeType: string; + checksum: string; + httpUrl?: string; + }; +} +interface FileAcceptMessage extends Message { + type: 'file_accept'; + payload: { + requestId: string; + uploadUrl?: string; + }; +} +interface FileRejectMessage extends Message { + type: 'file_reject'; + payload: { + requestId: string; + reason: string; + }; +} +interface FileChunkMessage extends Message { + type: 'file_chunk'; + payload: { + requestId: string; + chunkIndex: number; + totalChunks: number; + data: string; + }; +} +interface FileCompleteMessage extends Message { + type: 'file_complete'; + payload: { + requestId: string; + checksum: string; + }; +} +interface FileAckMessage extends Message { + type: 'file_ack'; + payload: { + requestId: string; + success: boolean; + }; +} +interface ErrorMessage extends Message { + type: 'error'; + payload: { + code: string; + message: string; + originalMessageId?: string; + }; +} +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; +type PairingStep = 'idle' | 'connecting' | 'sending_request' | 'waiting_for_passphrase' | 'deriving_key' | 'sending_challenge' | 'waiting_for_challenge' | 'responding_to_challenge' | 'verifying_response' | 'confirming' | 'success' | 'failed'; +interface ConnectionState { + status: ConnectionStatus; + device?: DeviceInfo; + error?: string; + /** Verbose status message for UI display */ + statusMessage?: string; + /** Current step in the pairing process */ + pairingStep?: PairingStep; +} +interface AppSettings { + deviceName: string; + deviceId: string; + autoAcceptFromPaired: boolean; + saveDirectory: string; + notificationsEnabled: boolean; +} +interface StoredData { + settings: AppSettings; + pairedDevices: PairedDevice[]; + transferHistory: Transfer[]; +} + +declare const MDNS_SERVICE_TYPE = "_easyshare._tcp"; +declare const MDNS_SERVICE_NAME = "EasyShare"; +declare const MDNS_DOMAIN = "local"; +declare const TXT_DEVICE_ID = "id"; +declare const TXT_DEVICE_NAME = "name"; +declare const TXT_PLATFORM = "platform"; +declare const TXT_VERSION = "version"; +/** + * Create TXT record data for mDNS advertisement + */ +declare function createTxtRecord(device: DeviceInfo): Record; +/** + * Parse TXT record data from mDNS discovery + */ +declare function parseTxtRecord(txt: Record, host: string, port: number): DeviceInfo | null; +/** + * Create a DiscoveredDevice from DeviceInfo + */ +declare function createDiscoveredDevice(device: DeviceInfo): DiscoveredDevice; +/** + * Check if a discovered device is stale (not seen recently) + */ +declare function isDeviceStale(device: DiscoveredDevice, maxAgeMs?: number): boolean; +/** + * Filter out stale devices from a list + */ +declare function filterStaleDevices(devices: DiscoveredDevice[], maxAgeMs?: number): DiscoveredDevice[]; +/** + * Update or add a device to a list of discovered devices + */ +declare function updateDeviceList(devices: DiscoveredDevice[], newDevice: DiscoveredDevice): DiscoveredDevice[]; +/** + * Remove a device from the list by ID + */ +declare function removeDevice(devices: DiscoveredDevice[], deviceId: string): DiscoveredDevice[]; +interface DiscoveryService { + start(): Promise; + stop(): Promise; + advertise(device: DeviceInfo): Promise; + stopAdvertising(): Promise; + onDeviceFound(callback: (device: DiscoveredDevice) => void): void; + onDeviceLost(callback: (deviceId: string) => void): void; +} + +export { updateDeviceList as $, type AppMessage as A, type PairingStep as B, type ConnectionState as C, type DiscoveryService as D, type ErrorMessage as E, type FileAcceptMessage as F, type PingMessage as G, type HelloMessage as H, type PongMessage as I, TXT_DEVICE_ID as J, TXT_DEVICE_NAME as K, TXT_PLATFORM as L, type Message as M, TXT_VERSION as N, type Transfer as O, type PairingStatus as P, type TransferMetadata as Q, type TransferQueueItem as R, type StoredData as S, type TransferProgress as T, type TransferType as U, createDiscoveredDevice as V, createTxtRecord as W, filterStaleDevices as X, isDeviceStale as Y, parseTxtRecord as Z, removeDevice as _, type DeviceInfo as a, type DiscoveredDevice as b, type PairChallengeMessage as c, type PairConfirmMessage as d, type PairRejectMessage as e, type PairRequestMessage as f, type PairResponseMessage as g, type PairedDevice as h, type TextMessage as i, type FileAckMessage as j, type FileChunkMessage as k, type FileCompleteMessage as l, type FileRejectMessage as m, type FileRequestMessage as n, type FileTransfer as o, type TextTransfer as p, type AppSettings as q, type ConnectionStatus as r, type DevicePlatform as s, type FilesTransfer as t, MDNS_DOMAIN as u, MDNS_SERVICE_NAME as v, MDNS_SERVICE_TYPE as w, type MessageType as x, type PairingChallenge as y, type PairingResponse as z }; diff --git a/packages/sync/dist/index-D7PLqM1E.d.ts b/packages/sync/dist/index-D7PLqM1E.d.ts new file mode 100644 index 00000000..8f427f44 --- /dev/null +++ b/packages/sync/dist/index-D7PLqM1E.d.ts @@ -0,0 +1,264 @@ +type DevicePlatform = 'macos' | 'windows' | 'linux' | 'android' | 'ios'; +interface DeviceInfo { + id: string; + name: string; + platform: DevicePlatform; + version: string; + host: string; + port: number; +} +interface DiscoveredDevice extends DeviceInfo { + lastSeen: number; +} +interface PairedDevice extends DeviceInfo { + sharedSecret: string; + pairedAt: number; + lastConnected?: number; +} +interface PairingChallenge { + challenge: string; + timestamp: number; +} +interface PairingResponse { + response: string; + deviceInfo: DeviceInfo; +} +type PairingStatus = 'idle' | 'waiting' | 'verifying' | 'success' | 'failed'; +type TransferType = 'text' | 'file' | 'files'; +interface TransferMetadata { + id: string; + type: TransferType; + timestamp: number; + direction: 'send' | 'receive'; + deviceId: string; + deviceName: string; +} +interface TextTransfer extends TransferMetadata { + type: 'text'; + content: string; +} +interface FileTransfer extends TransferMetadata { + type: 'file'; + fileName: string; + fileSize: number; + mimeType: string; + filePath?: string; + durationMs?: number; + speedBytesPerSec?: number; +} +interface FilesTransfer extends TransferMetadata { + type: 'files'; + files: Array<{ + fileName: string; + fileSize: number; + mimeType: string; + filePath?: string; + }>; + totalSize: number; +} +type Transfer = TextTransfer | FileTransfer | FilesTransfer; +interface TransferProgress { + transferId: string; + bytesTransferred: number; + totalBytes: number; + percentage: number; + currentFile?: string; + speedBytesPerSec?: number; + etaSeconds?: number; + elapsedMs?: number; +} +interface TransferQueueItem { + id: string; + fileName: string; + fileSize: number; + status: 'pending' | 'transferring' | 'completed' | 'failed'; + progress: number; + direction: 'send' | 'receive'; +} +type MessageType = 'ping' | 'pong' | 'pair_request' | 'pair_challenge' | 'pair_response' | 'pair_confirm' | 'pair_reject' | 'hello' | 'text' | 'file_request' | 'file_accept' | 'file_reject' | 'file_chunk' | 'file_complete' | 'file_ack' | 'app' | 'error'; +interface Message { + type: MessageType; + id: string; + timestamp: number; + payload?: unknown; +} +/** Generic encrypted application message: a channel name + arbitrary payload. + * Lets features (memory sync, clipboard sync, ...) ride the paired channel + * without each needing its own protocol message type. */ +interface AppMessage extends Message { + type: 'app'; + payload: { + channel: string; + data: unknown; + }; +} +/** Reconnect greeting: identifies the device so an already-paired peer can + * resume with the stored shared secret, skipping the pairing handshake. */ +interface HelloMessage extends Message { + type: 'hello'; + payload: { + deviceInfo: DeviceInfo; + }; +} +interface PingMessage extends Message { + type: 'ping'; +} +interface PongMessage extends Message { + type: 'pong'; +} +interface PairRequestMessage extends Message { + type: 'pair_request'; + payload: { + deviceInfo: DeviceInfo; + }; +} +interface PairChallengeMessage extends Message { + type: 'pair_challenge'; + payload: PairingChallenge; +} +interface PairResponseMessage extends Message { + type: 'pair_response'; + payload: PairingResponse; +} +interface PairConfirmMessage extends Message { + type: 'pair_confirm'; + payload: { + deviceInfo: DeviceInfo; + }; +} +interface PairRejectMessage extends Message { + type: 'pair_reject'; + payload: { + reason: string; + }; +} +interface TextMessage extends Message { + type: 'text'; + payload: { + content: string; + }; +} +interface FileRequestMessage extends Message { + type: 'file_request'; + payload: { + fileName: string; + fileSize: number; + mimeType: string; + checksum: string; + httpUrl?: string; + }; +} +interface FileAcceptMessage extends Message { + type: 'file_accept'; + payload: { + requestId: string; + uploadUrl?: string; + }; +} +interface FileRejectMessage extends Message { + type: 'file_reject'; + payload: { + requestId: string; + reason: string; + }; +} +interface FileChunkMessage extends Message { + type: 'file_chunk'; + payload: { + requestId: string; + chunkIndex: number; + totalChunks: number; + data: string; + }; +} +interface FileCompleteMessage extends Message { + type: 'file_complete'; + payload: { + requestId: string; + checksum: string; + }; +} +interface FileAckMessage extends Message { + type: 'file_ack'; + payload: { + requestId: string; + success: boolean; + }; +} +interface ErrorMessage extends Message { + type: 'error'; + payload: { + code: string; + message: string; + originalMessageId?: string; + }; +} +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; +type PairingStep = 'idle' | 'connecting' | 'sending_request' | 'waiting_for_passphrase' | 'deriving_key' | 'sending_challenge' | 'waiting_for_challenge' | 'responding_to_challenge' | 'verifying_response' | 'confirming' | 'success' | 'failed'; +interface ConnectionState { + status: ConnectionStatus; + device?: DeviceInfo; + error?: string; + /** Verbose status message for UI display */ + statusMessage?: string; + /** Current step in the pairing process */ + pairingStep?: PairingStep; +} +interface AppSettings { + deviceName: string; + deviceId: string; + autoAcceptFromPaired: boolean; + saveDirectory: string; + notificationsEnabled: boolean; +} +interface StoredData { + settings: AppSettings; + pairedDevices: PairedDevice[]; + transferHistory: Transfer[]; +} + +declare const MDNS_SERVICE_TYPE = "_easyshare._tcp"; +declare const MDNS_SERVICE_NAME = "EasyShare"; +declare const MDNS_DOMAIN = "local"; +declare const TXT_DEVICE_ID = "id"; +declare const TXT_DEVICE_NAME = "name"; +declare const TXT_PLATFORM = "platform"; +declare const TXT_VERSION = "version"; +/** + * Create TXT record data for mDNS advertisement + */ +declare function createTxtRecord(device: DeviceInfo): Record; +/** + * Parse TXT record data from mDNS discovery + */ +declare function parseTxtRecord(txt: Record, host: string, port: number): DeviceInfo | null; +/** + * Create a DiscoveredDevice from DeviceInfo + */ +declare function createDiscoveredDevice(device: DeviceInfo): DiscoveredDevice; +/** + * Check if a discovered device is stale (not seen recently) + */ +declare function isDeviceStale(device: DiscoveredDevice, maxAgeMs?: number): boolean; +/** + * Filter out stale devices from a list + */ +declare function filterStaleDevices(devices: DiscoveredDevice[], maxAgeMs?: number): DiscoveredDevice[]; +/** + * Update or add a device to a list of discovered devices + */ +declare function updateDeviceList(devices: DiscoveredDevice[], newDevice: DiscoveredDevice): DiscoveredDevice[]; +/** + * Remove a device from the list by ID + */ +declare function removeDevice(devices: DiscoveredDevice[], deviceId: string): DiscoveredDevice[]; +interface DiscoveryService { + start(): Promise; + stop(): Promise; + advertise(device: DeviceInfo): Promise; + stopAdvertising(): Promise; + onDeviceFound(callback: (device: DiscoveredDevice) => void): void; + onDeviceLost(callback: (deviceId: string) => void): void; +} + +export { updateDeviceList as $, type AppMessage as A, type PairingStep as B, type ConnectionState as C, type DiscoveryService as D, type ErrorMessage as E, type FileAcceptMessage as F, type PingMessage as G, type HelloMessage as H, type PongMessage as I, TXT_DEVICE_ID as J, TXT_DEVICE_NAME as K, TXT_PLATFORM as L, type Message as M, TXT_VERSION as N, type Transfer as O, type PairingStatus as P, type TransferMetadata as Q, type TransferQueueItem as R, type StoredData as S, type TransferProgress as T, type TransferType as U, createDiscoveredDevice as V, createTxtRecord as W, filterStaleDevices as X, isDeviceStale as Y, parseTxtRecord as Z, removeDevice as _, type DeviceInfo as a, type DiscoveredDevice as b, type PairChallengeMessage as c, type PairConfirmMessage as d, type PairRejectMessage as e, type PairRequestMessage as f, type PairResponseMessage as g, type PairedDevice as h, type TextMessage as i, type FileAckMessage as j, type FileChunkMessage as k, type FileCompleteMessage as l, type FileRejectMessage as m, type FileRequestMessage as n, type FileTransfer as o, type TextTransfer as p, type AppSettings as q, type ConnectionStatus as r, type DevicePlatform as s, type FilesTransfer as t, MDNS_DOMAIN as u, MDNS_SERVICE_NAME as v, MDNS_SERVICE_TYPE as w, type MessageType as x, type PairingChallenge as y, type PairingResponse as z }; diff --git a/packages/sync/dist/index.d.mts b/packages/sync/dist/index.d.mts new file mode 100644 index 00000000..ca34b955 --- /dev/null +++ b/packages/sync/dist/index.d.mts @@ -0,0 +1,575 @@ +import { P as PairingStatus, a as DeviceInfo, c as PairChallengeMessage, d as PairConfirmMessage, e as PairRejectMessage, f as PairRequestMessage, g as PairResponseMessage, h as PairedDevice, M as Message, T as TransferProgress, i as TextMessage, F as FileAcceptMessage, j as FileAckMessage, k as FileChunkMessage, l as FileCompleteMessage, m as FileRejectMessage, n as FileRequestMessage, o as FileTransfer, p as TextTransfer, D as DiscoveryService, b as DiscoveredDevice } from './index-D7PLqM1E.mjs'; +export { A as AppMessage, q as AppSettings, C as ConnectionState, r as ConnectionStatus, s as DevicePlatform, E as ErrorMessage, t as FilesTransfer, H as HelloMessage, u as MDNS_DOMAIN, v as MDNS_SERVICE_NAME, w as MDNS_SERVICE_TYPE, x as MessageType, y as PairingChallenge, z as PairingResponse, B as PairingStep, G as PingMessage, I as PongMessage, S as StoredData, J as TXT_DEVICE_ID, K as TXT_DEVICE_NAME, L as TXT_PLATFORM, N as TXT_VERSION, O as Transfer, Q as TransferMetadata, R as TransferQueueItem, U as TransferType, V as createDiscoveredDevice, W as createTxtRecord, X as filterStaleDevices, Y as isDeviceStale, Z as parseTxtRecord, _ as removeDevice, $ as updateDeviceList } from './index-D7PLqM1E.mjs'; +import { T as TransportBridge, S as SyncConnection } from './transport-1cXLtrs5.mjs'; +export { decodeBase64, decodeUTF8, encodeBase64, encodeUTF8 } from 'tweetnacl-util'; + +/** + * Generate a random device ID + */ +declare function generateDeviceId(): string; +/** + * Generate a random message ID + */ +declare function generateMessageId(): string; +/** + * Simple PBKDF2-like key derivation using iterated hashing + * Note: This is a simplified implementation using NaCl primitives + */ +declare function deriveKey(passphrase: string, salt: Uint8Array, iterations?: number): Uint8Array; +/** + * Derive a shared secret from a passphrase and two device IDs + * This ensures both devices derive the same key + */ +declare function deriveSharedSecret(passphrase: string, deviceId1: string, deviceId2: string): string; +/** + * Generate a random challenge for pairing verification + */ +declare function generateChallenge(): string; +/** + * Create an HMAC-like response to a challenge using the shared secret + */ +declare function createChallengeResponse(challenge: string, sharedSecret: string): string; +/** + * Verify a challenge response + */ +declare function verifyChallengeResponse(challenge: string, response: string, sharedSecret: string): boolean; +/** + * Encrypt data using NaCl secretbox (XSalsa20-Poly1305) + */ +declare function encrypt(data: string | Uint8Array, secretKey: string): { + encrypted: string; + nonce: string; +}; +/** + * Decrypt data using NaCl secretbox + */ +declare function decrypt(encrypted: string, nonce: string, secretKey: string): Uint8Array | null; +/** + * Decrypt data and return as string + */ +declare function decryptToString(encrypted: string, nonce: string, secretKey: string): string | null; +/** + * Calculate a checksum for file integrity verification + */ +declare function calculateChecksum(data: Uint8Array): string; +/** + * Verify a checksum + */ +declare function verifyChecksum(data: Uint8Array, checksum: string): boolean; +/** + * Incremental/streaming checksum calculator using SHA-512. + * Produces the same output format as calculateChecksum() (base64 of first 16 bytes of SHA-512) + * but allows feeding data in chunks to avoid loading entire files into memory. + */ +declare class IncrementalChecksum { + private hasher; + constructor(); + /** + * Feed a chunk of data into the hash + */ + update(data: Uint8Array): void; + /** + * Finalize and return checksum in the same format as calculateChecksum() + * (base64 of first 16 bytes of SHA-512 digest) + */ + digest(): string; +} + +/** + * Pairing state machine for managing the pairing handshake + */ +interface PairingState { + status: PairingStatus; + localDevice: DeviceInfo; + remoteDevice?: DeviceInfo; + passphrase?: string; + sharedSecret?: string; + challenge?: string; + error?: string; +} +/** + * Create initial pairing state + */ +declare function createPairingState(localDevice: DeviceInfo): PairingState; +/** + * Create a pair request message + */ +declare function createPairRequest(localDevice: DeviceInfo): PairRequestMessage; +/** + * Create a pair challenge message + */ +declare function createPairChallenge(): PairChallengeMessage; +/** + * Create a pair response message + */ +declare function createPairResponse(challenge: string, sharedSecret: string, localDevice: DeviceInfo): PairResponseMessage; +/** + * Create a pair confirm message + */ +declare function createPairConfirm(localDevice: DeviceInfo): PairConfirmMessage; +/** + * Create a pair reject message + */ +declare function createPairReject(reason: string): PairRejectMessage; +/** + * Handle pairing state transitions + */ +declare function handlePairingMessage(state: PairingState, message: Message, passphrase?: string): { + newState: PairingState; + response?: Message; +}; +/** + * Create a PairedDevice from successful pairing + */ +declare function createPairedDevice(state: PairingState): PairedDevice | null; +/** + * Check if a device is already paired + */ +declare function isPaired(deviceId: string, pairedDevices: PairedDevice[]): boolean; +/** + * Get a paired device by ID + */ +declare function getPairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice | undefined; +/** + * Update last connected time for a paired device + */ +declare function updateLastConnected(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; +/** + * Remove a paired device + */ +declare function removePairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; + +declare const CHUNK_SIZE: number; +declare const MAX_TEXT_LENGTH: number; +/** + * Create a text transfer record + */ +declare function createTextTransfer(content: string, device: DeviceInfo, direction: 'send' | 'receive'): TextTransfer; +/** + * Create a file transfer record + */ +declare function createFileTransfer(fileName: string, fileSize: number, mimeType: string, device: DeviceInfo, direction: 'send' | 'receive', durationMs?: number): FileTransfer; +/** + * Create a text message + */ +declare function createTextMessage(content: string): TextMessage; +/** + * Create an encrypted text message + */ +declare function createEncryptedTextMessage(content: string, secretKey: string): { + message: TextMessage; + nonce: string; +}; +/** + * Decrypt a text message + */ +declare function decryptTextMessage(message: TextMessage, nonce: string, secretKey: string): string | null; +/** + * Create a file request message + */ +declare function createFileRequest(fileName: string, fileSize: number, mimeType: string, fileData: Uint8Array): FileRequestMessage; +/** + * Create a file request message with a pre-computed checksum (for streaming/large files). + * Avoids needing the entire file in memory. + */ +declare function createFileRequestStreaming(fileName: string, fileSize: number, mimeType: string, checksum: string): FileRequestMessage; +/** + * Create a file complete message with a pre-computed checksum (for streaming/large files). + * Avoids needing the entire file in memory. + */ +declare function createFileCompleteStreaming(requestId: string, checksum: string): FileCompleteMessage; +/** + * Create a file request message with an HTTP download URL (for large files sent via HTTP). + */ +declare function createFileRequestHttp(fileName: string, fileSize: number, mimeType: string, checksum: string, httpUrl: string): FileRequestMessage; +/** + * Create a file accept message + */ +declare function createFileAccept(requestId: string): FileAcceptMessage; +/** + * Create a file accept message with an HTTP upload URL (for receiving large files via HTTP). + */ +declare function createFileAcceptHttp(requestId: string, uploadUrl: string): FileAcceptMessage; +/** + * Create a file ack message (sent after HTTP transfer completes). + */ +declare function createFileAck(requestId: string, success: boolean): FileAckMessage; +/** + * Create a file reject message + */ +declare function createFileReject(requestId: string, reason: string): FileRejectMessage; +/** + * Create a file chunk message + */ +declare function createFileChunk(requestId: string, chunkIndex: number, totalChunks: number, data: Uint8Array): FileChunkMessage; +/** + * Create a file chunk message from already-base64-encoded data. + * Avoids the decode → re-encode roundtrip when data is read as base64 from disk. + */ +declare function createFileChunkFromBase64(requestId: string, chunkIndex: number, totalChunks: number, base64Data: string): FileChunkMessage; +/** + * Create a file complete message + */ +declare function createFileComplete(requestId: string, fileData: Uint8Array): FileCompleteMessage; +/** + * Split file data into chunks + */ +declare function chunkFile(data: Uint8Array, chunkSize?: number): Generator<{ + chunk: Uint8Array; + index: number; + total: number; +}>; +/** + * Reassemble chunks into complete file data + */ +declare function reassembleChunks(chunks: Map, totalChunks: number): Uint8Array | null; +/** + * Calculate transfer progress with optional speed/ETA computation + */ +declare function calculateProgress(transferId: string, bytesTransferred: number, totalBytes: number, currentFile?: string, startTime?: number): TransferProgress; +/** + * Verify received file integrity + */ +declare function verifyFileIntegrity(data: Uint8Array, expectedChecksum: string): boolean; +/** + * Format file size for display + */ +declare function formatFileSize(bytes: number): string; +/** + * Format transfer speed for display + */ +declare function formatTransferSpeed(bytesPerSec: number): string; +/** + * Format transfer duration for display + */ +declare function formatDuration(ms: number): string; +/** + * Format ETA for display + */ +declare function formatEta(seconds: number): string; +/** + * Format live transfer progress info string (speed · elapsed · ETA) + */ +declare function formatProgressInfo(progress: TransferProgress): string; +/** + * Get MIME type from file extension + */ +declare function getMimeType(fileName: string): string; + +declare const PROTOCOL_VERSION = "1.0.0"; +declare const HEADER_LENGTH = 5; +declare const MAX_MESSAGE_SIZE: number; +declare const MESSAGE_TYPE_CODES: Record; +/** Build a reconnect hello identifying the local device. */ +declare function createHello(deviceInfo: unknown): Message; +/** Build a generic encrypted application message for a named channel. */ +declare function createAppMessage(channel: string, data: unknown): Message; +declare const MESSAGE_CODE_TYPES: Record; +/** + * Serialize a message to a buffer for transmission + */ +declare function serializeMessage(message: Message): Uint8Array; +/** + * Deserialize a message from a buffer + */ +declare function deserializeMessage(buffer: Uint8Array): Message | null; +/** + * Get the expected message length from a header + */ +declare function getMessageLength(header: Uint8Array): number | null; +/** + * Encrypt a message for transmission over an established connection + */ +declare function encryptMessage(message: Message, secretKey: string): { + encrypted: Uint8Array; + nonce: string; +}; +/** + * Decrypt a received encrypted message + */ +declare function decryptMessage(encrypted: Uint8Array, nonce: string, secretKey: string): Message | null; +/** + * Message frame for encrypted transmission + * Format: [nonce length (1 byte)] [nonce] [encrypted data] + */ +declare function createEncryptedFrame(encrypted: Uint8Array, nonce: string): Uint8Array; +/** + * Parse an encrypted frame + */ +declare function parseEncryptedFrame(frame: Uint8Array): { + encrypted: Uint8Array; + nonce: string; +} | null; +/** + * Buffer for accumulating incoming data and extracting complete messages + */ +declare class MessageBuffer { + private buffer; + /** + * Add data to the buffer + */ + append(data: Uint8Array): void; + /** + * Try to extract a complete message from the buffer + */ + extractMessage(): Message | null; + /** + * Extract all complete messages from the buffer + */ + extractAllMessages(): Message[]; + /** + * Get current buffer size + */ + get size(): number; + /** + * Clear the buffer + */ + clear(): void; +} +/** + * Create a ping message + */ +declare function createPingMessage(): Message; +/** + * Create a pong message in response to a ping + */ +declare function createPongMessage(pingId: string): Message; +/** + * Create an error message + */ +declare function createErrorMessage(code: string, errorMessage: string, originalMessageId?: string): Message; + +declare const FRAME_HEADER_LENGTH = 4; +declare const MAX_FRAME_SIZE: number; +declare const FRAME_KIND_PLAINTEXT = 0; +declare const FRAME_KIND_ENCRYPTED = 1; +/** Encode a plaintext (unencrypted) message frame, used for pairing. */ +declare function encodePlaintextFrame(message: Message): Uint8Array; +/** Encode an encrypted message frame using the per-pair shared secret. */ +declare function encodeEncryptedFrame(message: Message, secretKey: string): Uint8Array; +type DecodedFrame = { + kind: 'plaintext'; + message: Message; +} | { + kind: 'encrypted'; + message: Message; +}; +/** Decode one frame body. `secretKey` is required to read encrypted frames. */ +declare function decodeFrameBody(body: Uint8Array, secretKey?: string): DecodedFrame | null; +/** + * Accumulates incoming bytes and yields complete frame bodies. The shared + * secret can be set once pairing succeeds so later encrypted frames decode. + */ +declare class FrameBuffer { + private buffer; + append(data: Uint8Array): void; + /** Pull the next complete frame body, or null if none is fully buffered. */ + private nextBody; + /** Decode all complete frames currently buffered. */ + drain(secretKey?: string): DecodedFrame[]; +} + +declare const FREE_DEVICE_CAP = 2; +interface DeviceCapPolicy { + /** Max distinct paired devices allowed. Free returns FREE_DEVICE_CAP; a pro + * entitlement returns a higher number or Infinity. */ + limit(): number; +} +/** A fixed free-tier policy. */ +declare const freePolicy: DeviceCapPolicy; +/** Build a policy from a pro flag supplied by the host's entitlement check. */ +declare function policyFor(isPro: boolean, proLimit?: number): DeviceCapPolicy; +interface DeviceCap { + policy: DeviceCapPolicy; + /** How many distinct devices are already paired (from the host's store). */ + pairedCount: () => number; + /** Whether this device id is already paired (re-pairing does not count). */ + isKnown: (deviceId: string) => boolean; +} +/** True if pairing with `deviceId` is allowed under the cap. */ +declare function pairingAllowed(cap: DeviceCap | undefined, deviceId: string): boolean; + +interface SyncEngineOptions { + localDevice: DeviceInfo; + transport: TransportBridge; + /** Supply the passphrase for an incoming pairing (e.g. a UI prompt). Return + * null/undefined to refuse. Not needed on the side that calls connect(). */ + getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; + /** Application message from a paired peer (pairing traffic is handled internally). */ + onMessage?: (deviceId: string, message: Message) => void; + /** Generic app-channel message from a paired peer (type 'app'). Used by + * features like memory/clipboard sync that ride the paired channel. */ + onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; + /** Look up the stored shared secret for an already-paired device, so an + * inbound reconnect (hello) can resume without re-running the handshake. */ + getSharedSecret?: (deviceId: string) => string | undefined; + /** A pairing handshake completed. */ + onPaired?: (device: PairedDevice) => void; + /** A pairing attempt failed. */ + onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; + /** Optional device cap (open-core 2 free / 3+ paid). When set, pairing a new + * device beyond the limit is refused on both the dialing and accepting side. */ + cap?: DeviceCap; +} +/** One peer connection: owns its frame buffer, pairing state, and shared secret. */ +declare class PeerSession { + readonly conn: SyncConnection; + private readonly engine; + private readonly opts; + private buffer; + private pairing; + private sharedSecret?; + private passphrase?; + private resumeSecret?; + private helloSent; + private queue; + remoteDevice?: DeviceInfo; + constructor(conn: SyncConnection, engine: SyncEngine, opts: SyncEngineOptions, initiateWith?: { + remote: DeviceInfo; + passphrase: string; + }, resumeWith?: { + remote: DeviceInfo; + sharedSecret: string; + }); + get pairedSecret(): string | undefined; + /** Send an application message to this peer (must be paired). */ + sendMessage(message: Message): boolean; + private sendPlain; + private onData; + private route; + /** Resume an already-paired device using the stored secret (no handshake). */ + private handleHello; + private handlePairing; +} +declare class SyncEngine { + private readonly opts; + private sessions; + private paired; + constructor(opts: SyncEngineOptions); + /** Start accepting inbound connections on `port`. */ + start(port: number): Promise; + /** Dial a discovered device and begin pairing with `passphrase`. Refuses if + * pairing a new device would exceed the device cap. */ + pair(device: DeviceInfo, passphrase: string): Promise; + /** Reconnect to an already-paired device using its stored shared secret, + * skipping the pairing handshake. Used for auto-reconnect on discovery. */ + reconnect(device: DeviceInfo, sharedSecret: string): Promise; + /** Send an application message to an already-paired device. */ + send(deviceId: string, message: Message): boolean; + /** Send a generic app-channel message (encrypted) to a paired device. */ + sendApp(deviceId: string, channel: string, data: unknown): boolean; + isPaired(deviceId: string): boolean; + stop(): Promise; + /** @internal */ + _registerPaired(deviceId: string, session: PeerSession): void; + /** @internal */ + _removeSession(session: PeerSession): void; +} + +/** The slice of SyncEngine the orchestrator drives. */ +interface ReconnectingEngine { + isPaired(deviceId: string): boolean; + reconnect(device: DeviceInfo, sharedSecret: string): Promise; +} +interface DiscoveryOrchestratorOptions { + engine: ReconnectingEngine; + discovery: DiscoveryService; + localDevice: DeviceInfo; + /** Stored shared secret for a device, or undefined if not yet paired. */ + getSharedSecret: (deviceId: string) => string | undefined; + /** A discovered device we have no secret for - surface it so the UI can pair. */ + onDiscovered?: (device: DiscoveredDevice) => void; + /** A previously discovered device went away. */ + onLost?: (deviceId: string) => void; +} +declare class DiscoveryOrchestrator { + private readonly opts; + private connecting; + constructor(opts: DiscoveryOrchestratorOptions); + start(): Promise; + stop(): Promise; + private handleFound; +} + +type OpKind = 'put' | 'delete'; +interface Op { + /** Globally-unique op id (uuid). The dedup key across devices. */ + opId: string; + /** Logical record type, e.g. 'conversation' | 'message' | 'project'. */ + entity: string; + /** Stable id of the record this op mutates (must be a UUID, not autoincrement). */ + entityId: string; + kind: OpKind; + /** Full record fields for a 'put' (whole-record LWW). Omitted for 'delete'. */ + fields?: Record; + /** Lamport logical clock. */ + lamport: number; + /** Origin device id. */ + deviceId: string; + /** Wall-clock ms (display / human tiebreak only — never used for ordering). */ + ts: number; +} +/** Version vector: per-device highest lamport seen. */ +type VersionVector = Record; +/** How the op-log writes materialized state into the host's real store. */ +interface Materializer { + put(entity: string, entityId: string, fields: Record): void; + remove(entity: string, entityId: string): void; +} +interface OpLogOptions { + deviceId: string; + materializer: Materializer; + /** Persist a single newly-accepted op (e.g. INSERT into sync_ops). */ + persist?: (op: Op) => void; + /** Generate a uuid (host injects: crypto.randomUUID on Node, a polyfill on RN). */ + uuid: () => string; + /** Wall clock ms. Injected so the core stays free of Date.now (testable). */ + now: () => number; + /** Ops already on disk, to rehydrate the log at startup. */ + persisted?: Op[]; +} +declare class OpLog { + private readonly opts; + private ops; + private clock; + constructor(opts: OpLogOptions); + /** Per-device highest lamport — what we tell a peer we already have. */ + versionVector(): VersionVector; + /** Ops the peer (described by their version vector) hasn't seen yet. */ + opsSince(peerVV: VersionVector): Op[]; + /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ + record(entity: string, entityId: string, kind: OpKind, fields?: Record): Op; + /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ + ingest(incoming: Op[]): Op[]; + /** Recompute the winning op for one record and push it to the materializer. */ + private rematerialize; + /** Total ops held (diagnostics). */ + size(): number; +} + +type StateMsg = { + t: 'have'; + vv: VersionVector; +} | { + t: 'ops'; + ops: Op[]; +}; +interface StateSyncOptions { + oplog: OpLog; + /** Send a state message to one peer (host wires → sendApp(id,'state',msg)). */ + send: (deviceId: string, msg: StateMsg) => void; +} +declare class StateSync { + private readonly opts; + constructor(opts: StateSyncOptions); + /** A peer connected: advertise our version vector so it can backfill us; we + * backfill it when its own `have` arrives. */ + onConnect(deviceId: string): void; + /** Inbound message on the 'state' channel from a paired peer. */ + onMessage(deviceId: string, data: unknown): void; +} + +declare const VERSION = "0.0.1"; +declare const APP_NAME = "Off Grid Sync"; + +export { APP_NAME, CHUNK_SIZE, type DecodedFrame, type DeviceCap, type DeviceCapPolicy, DeviceInfo, DiscoveredDevice, DiscoveryOrchestrator, type DiscoveryOrchestratorOptions, DiscoveryService, FRAME_HEADER_LENGTH, FRAME_KIND_ENCRYPTED, FRAME_KIND_PLAINTEXT, FREE_DEVICE_CAP, FileAcceptMessage, FileAckMessage, FileChunkMessage, FileCompleteMessage, FileRejectMessage, FileRequestMessage, FileTransfer, FrameBuffer, HEADER_LENGTH, IncrementalChecksum, MAX_FRAME_SIZE, MAX_MESSAGE_SIZE, MAX_TEXT_LENGTH, MESSAGE_CODE_TYPES, MESSAGE_TYPE_CODES, type Materializer, Message, MessageBuffer, type Op, type OpKind, OpLog, type OpLogOptions, PROTOCOL_VERSION, PairChallengeMessage, PairConfirmMessage, PairRejectMessage, PairRequestMessage, PairResponseMessage, PairedDevice, type PairingState, PairingStatus, type ReconnectingEngine, type StateMsg, StateSync, type StateSyncOptions, SyncConnection, SyncEngine, type SyncEngineOptions, TextMessage, TextTransfer, TransferProgress, TransportBridge, VERSION, type VersionVector, calculateChecksum, calculateProgress, chunkFile, createAppMessage, createChallengeResponse, createEncryptedFrame, createEncryptedTextMessage, createErrorMessage, createFileAccept, createFileAcceptHttp, createFileAck, createFileChunk, createFileChunkFromBase64, createFileComplete, createFileCompleteStreaming, createFileReject, createFileRequest, createFileRequestHttp, createFileRequestStreaming, createFileTransfer, createHello, createPairChallenge, createPairConfirm, createPairReject, createPairRequest, createPairResponse, createPairedDevice, createPairingState, createPingMessage, createPongMessage, createTextMessage, createTextTransfer, decodeFrameBody, decrypt, decryptMessage, decryptTextMessage, decryptToString, deriveKey, deriveSharedSecret, deserializeMessage, encodeEncryptedFrame, encodePlaintextFrame, encrypt, encryptMessage, formatDuration, formatEta, formatFileSize, formatProgressInfo, formatTransferSpeed, freePolicy, generateChallenge, generateDeviceId, generateMessageId, getMessageLength, getMimeType, getPairedDevice, handlePairingMessage, isPaired, pairingAllowed, parseEncryptedFrame, policyFor, reassembleChunks, removePairedDevice, serializeMessage, updateLastConnected, verifyChallengeResponse, verifyChecksum, verifyFileIntegrity }; diff --git a/packages/sync/dist/index.d.ts b/packages/sync/dist/index.d.ts new file mode 100644 index 00000000..2ac32fca --- /dev/null +++ b/packages/sync/dist/index.d.ts @@ -0,0 +1,575 @@ +import { P as PairingStatus, a as DeviceInfo, c as PairChallengeMessage, d as PairConfirmMessage, e as PairRejectMessage, f as PairRequestMessage, g as PairResponseMessage, h as PairedDevice, M as Message, T as TransferProgress, i as TextMessage, F as FileAcceptMessage, j as FileAckMessage, k as FileChunkMessage, l as FileCompleteMessage, m as FileRejectMessage, n as FileRequestMessage, o as FileTransfer, p as TextTransfer, D as DiscoveryService, b as DiscoveredDevice } from './index-D7PLqM1E.js'; +export { A as AppMessage, q as AppSettings, C as ConnectionState, r as ConnectionStatus, s as DevicePlatform, E as ErrorMessage, t as FilesTransfer, H as HelloMessage, u as MDNS_DOMAIN, v as MDNS_SERVICE_NAME, w as MDNS_SERVICE_TYPE, x as MessageType, y as PairingChallenge, z as PairingResponse, B as PairingStep, G as PingMessage, I as PongMessage, S as StoredData, J as TXT_DEVICE_ID, K as TXT_DEVICE_NAME, L as TXT_PLATFORM, N as TXT_VERSION, O as Transfer, Q as TransferMetadata, R as TransferQueueItem, U as TransferType, V as createDiscoveredDevice, W as createTxtRecord, X as filterStaleDevices, Y as isDeviceStale, Z as parseTxtRecord, _ as removeDevice, $ as updateDeviceList } from './index-D7PLqM1E.js'; +import { T as TransportBridge, S as SyncConnection } from './transport-1cXLtrs5.js'; +export { decodeBase64, decodeUTF8, encodeBase64, encodeUTF8 } from 'tweetnacl-util'; + +/** + * Generate a random device ID + */ +declare function generateDeviceId(): string; +/** + * Generate a random message ID + */ +declare function generateMessageId(): string; +/** + * Simple PBKDF2-like key derivation using iterated hashing + * Note: This is a simplified implementation using NaCl primitives + */ +declare function deriveKey(passphrase: string, salt: Uint8Array, iterations?: number): Uint8Array; +/** + * Derive a shared secret from a passphrase and two device IDs + * This ensures both devices derive the same key + */ +declare function deriveSharedSecret(passphrase: string, deviceId1: string, deviceId2: string): string; +/** + * Generate a random challenge for pairing verification + */ +declare function generateChallenge(): string; +/** + * Create an HMAC-like response to a challenge using the shared secret + */ +declare function createChallengeResponse(challenge: string, sharedSecret: string): string; +/** + * Verify a challenge response + */ +declare function verifyChallengeResponse(challenge: string, response: string, sharedSecret: string): boolean; +/** + * Encrypt data using NaCl secretbox (XSalsa20-Poly1305) + */ +declare function encrypt(data: string | Uint8Array, secretKey: string): { + encrypted: string; + nonce: string; +}; +/** + * Decrypt data using NaCl secretbox + */ +declare function decrypt(encrypted: string, nonce: string, secretKey: string): Uint8Array | null; +/** + * Decrypt data and return as string + */ +declare function decryptToString(encrypted: string, nonce: string, secretKey: string): string | null; +/** + * Calculate a checksum for file integrity verification + */ +declare function calculateChecksum(data: Uint8Array): string; +/** + * Verify a checksum + */ +declare function verifyChecksum(data: Uint8Array, checksum: string): boolean; +/** + * Incremental/streaming checksum calculator using SHA-512. + * Produces the same output format as calculateChecksum() (base64 of first 16 bytes of SHA-512) + * but allows feeding data in chunks to avoid loading entire files into memory. + */ +declare class IncrementalChecksum { + private hasher; + constructor(); + /** + * Feed a chunk of data into the hash + */ + update(data: Uint8Array): void; + /** + * Finalize and return checksum in the same format as calculateChecksum() + * (base64 of first 16 bytes of SHA-512 digest) + */ + digest(): string; +} + +/** + * Pairing state machine for managing the pairing handshake + */ +interface PairingState { + status: PairingStatus; + localDevice: DeviceInfo; + remoteDevice?: DeviceInfo; + passphrase?: string; + sharedSecret?: string; + challenge?: string; + error?: string; +} +/** + * Create initial pairing state + */ +declare function createPairingState(localDevice: DeviceInfo): PairingState; +/** + * Create a pair request message + */ +declare function createPairRequest(localDevice: DeviceInfo): PairRequestMessage; +/** + * Create a pair challenge message + */ +declare function createPairChallenge(): PairChallengeMessage; +/** + * Create a pair response message + */ +declare function createPairResponse(challenge: string, sharedSecret: string, localDevice: DeviceInfo): PairResponseMessage; +/** + * Create a pair confirm message + */ +declare function createPairConfirm(localDevice: DeviceInfo): PairConfirmMessage; +/** + * Create a pair reject message + */ +declare function createPairReject(reason: string): PairRejectMessage; +/** + * Handle pairing state transitions + */ +declare function handlePairingMessage(state: PairingState, message: Message, passphrase?: string): { + newState: PairingState; + response?: Message; +}; +/** + * Create a PairedDevice from successful pairing + */ +declare function createPairedDevice(state: PairingState): PairedDevice | null; +/** + * Check if a device is already paired + */ +declare function isPaired(deviceId: string, pairedDevices: PairedDevice[]): boolean; +/** + * Get a paired device by ID + */ +declare function getPairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice | undefined; +/** + * Update last connected time for a paired device + */ +declare function updateLastConnected(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; +/** + * Remove a paired device + */ +declare function removePairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; + +declare const CHUNK_SIZE: number; +declare const MAX_TEXT_LENGTH: number; +/** + * Create a text transfer record + */ +declare function createTextTransfer(content: string, device: DeviceInfo, direction: 'send' | 'receive'): TextTransfer; +/** + * Create a file transfer record + */ +declare function createFileTransfer(fileName: string, fileSize: number, mimeType: string, device: DeviceInfo, direction: 'send' | 'receive', durationMs?: number): FileTransfer; +/** + * Create a text message + */ +declare function createTextMessage(content: string): TextMessage; +/** + * Create an encrypted text message + */ +declare function createEncryptedTextMessage(content: string, secretKey: string): { + message: TextMessage; + nonce: string; +}; +/** + * Decrypt a text message + */ +declare function decryptTextMessage(message: TextMessage, nonce: string, secretKey: string): string | null; +/** + * Create a file request message + */ +declare function createFileRequest(fileName: string, fileSize: number, mimeType: string, fileData: Uint8Array): FileRequestMessage; +/** + * Create a file request message with a pre-computed checksum (for streaming/large files). + * Avoids needing the entire file in memory. + */ +declare function createFileRequestStreaming(fileName: string, fileSize: number, mimeType: string, checksum: string): FileRequestMessage; +/** + * Create a file complete message with a pre-computed checksum (for streaming/large files). + * Avoids needing the entire file in memory. + */ +declare function createFileCompleteStreaming(requestId: string, checksum: string): FileCompleteMessage; +/** + * Create a file request message with an HTTP download URL (for large files sent via HTTP). + */ +declare function createFileRequestHttp(fileName: string, fileSize: number, mimeType: string, checksum: string, httpUrl: string): FileRequestMessage; +/** + * Create a file accept message + */ +declare function createFileAccept(requestId: string): FileAcceptMessage; +/** + * Create a file accept message with an HTTP upload URL (for receiving large files via HTTP). + */ +declare function createFileAcceptHttp(requestId: string, uploadUrl: string): FileAcceptMessage; +/** + * Create a file ack message (sent after HTTP transfer completes). + */ +declare function createFileAck(requestId: string, success: boolean): FileAckMessage; +/** + * Create a file reject message + */ +declare function createFileReject(requestId: string, reason: string): FileRejectMessage; +/** + * Create a file chunk message + */ +declare function createFileChunk(requestId: string, chunkIndex: number, totalChunks: number, data: Uint8Array): FileChunkMessage; +/** + * Create a file chunk message from already-base64-encoded data. + * Avoids the decode → re-encode roundtrip when data is read as base64 from disk. + */ +declare function createFileChunkFromBase64(requestId: string, chunkIndex: number, totalChunks: number, base64Data: string): FileChunkMessage; +/** + * Create a file complete message + */ +declare function createFileComplete(requestId: string, fileData: Uint8Array): FileCompleteMessage; +/** + * Split file data into chunks + */ +declare function chunkFile(data: Uint8Array, chunkSize?: number): Generator<{ + chunk: Uint8Array; + index: number; + total: number; +}>; +/** + * Reassemble chunks into complete file data + */ +declare function reassembleChunks(chunks: Map, totalChunks: number): Uint8Array | null; +/** + * Calculate transfer progress with optional speed/ETA computation + */ +declare function calculateProgress(transferId: string, bytesTransferred: number, totalBytes: number, currentFile?: string, startTime?: number): TransferProgress; +/** + * Verify received file integrity + */ +declare function verifyFileIntegrity(data: Uint8Array, expectedChecksum: string): boolean; +/** + * Format file size for display + */ +declare function formatFileSize(bytes: number): string; +/** + * Format transfer speed for display + */ +declare function formatTransferSpeed(bytesPerSec: number): string; +/** + * Format transfer duration for display + */ +declare function formatDuration(ms: number): string; +/** + * Format ETA for display + */ +declare function formatEta(seconds: number): string; +/** + * Format live transfer progress info string (speed · elapsed · ETA) + */ +declare function formatProgressInfo(progress: TransferProgress): string; +/** + * Get MIME type from file extension + */ +declare function getMimeType(fileName: string): string; + +declare const PROTOCOL_VERSION = "1.0.0"; +declare const HEADER_LENGTH = 5; +declare const MAX_MESSAGE_SIZE: number; +declare const MESSAGE_TYPE_CODES: Record; +/** Build a reconnect hello identifying the local device. */ +declare function createHello(deviceInfo: unknown): Message; +/** Build a generic encrypted application message for a named channel. */ +declare function createAppMessage(channel: string, data: unknown): Message; +declare const MESSAGE_CODE_TYPES: Record; +/** + * Serialize a message to a buffer for transmission + */ +declare function serializeMessage(message: Message): Uint8Array; +/** + * Deserialize a message from a buffer + */ +declare function deserializeMessage(buffer: Uint8Array): Message | null; +/** + * Get the expected message length from a header + */ +declare function getMessageLength(header: Uint8Array): number | null; +/** + * Encrypt a message for transmission over an established connection + */ +declare function encryptMessage(message: Message, secretKey: string): { + encrypted: Uint8Array; + nonce: string; +}; +/** + * Decrypt a received encrypted message + */ +declare function decryptMessage(encrypted: Uint8Array, nonce: string, secretKey: string): Message | null; +/** + * Message frame for encrypted transmission + * Format: [nonce length (1 byte)] [nonce] [encrypted data] + */ +declare function createEncryptedFrame(encrypted: Uint8Array, nonce: string): Uint8Array; +/** + * Parse an encrypted frame + */ +declare function parseEncryptedFrame(frame: Uint8Array): { + encrypted: Uint8Array; + nonce: string; +} | null; +/** + * Buffer for accumulating incoming data and extracting complete messages + */ +declare class MessageBuffer { + private buffer; + /** + * Add data to the buffer + */ + append(data: Uint8Array): void; + /** + * Try to extract a complete message from the buffer + */ + extractMessage(): Message | null; + /** + * Extract all complete messages from the buffer + */ + extractAllMessages(): Message[]; + /** + * Get current buffer size + */ + get size(): number; + /** + * Clear the buffer + */ + clear(): void; +} +/** + * Create a ping message + */ +declare function createPingMessage(): Message; +/** + * Create a pong message in response to a ping + */ +declare function createPongMessage(pingId: string): Message; +/** + * Create an error message + */ +declare function createErrorMessage(code: string, errorMessage: string, originalMessageId?: string): Message; + +declare const FRAME_HEADER_LENGTH = 4; +declare const MAX_FRAME_SIZE: number; +declare const FRAME_KIND_PLAINTEXT = 0; +declare const FRAME_KIND_ENCRYPTED = 1; +/** Encode a plaintext (unencrypted) message frame, used for pairing. */ +declare function encodePlaintextFrame(message: Message): Uint8Array; +/** Encode an encrypted message frame using the per-pair shared secret. */ +declare function encodeEncryptedFrame(message: Message, secretKey: string): Uint8Array; +type DecodedFrame = { + kind: 'plaintext'; + message: Message; +} | { + kind: 'encrypted'; + message: Message; +}; +/** Decode one frame body. `secretKey` is required to read encrypted frames. */ +declare function decodeFrameBody(body: Uint8Array, secretKey?: string): DecodedFrame | null; +/** + * Accumulates incoming bytes and yields complete frame bodies. The shared + * secret can be set once pairing succeeds so later encrypted frames decode. + */ +declare class FrameBuffer { + private buffer; + append(data: Uint8Array): void; + /** Pull the next complete frame body, or null if none is fully buffered. */ + private nextBody; + /** Decode all complete frames currently buffered. */ + drain(secretKey?: string): DecodedFrame[]; +} + +declare const FREE_DEVICE_CAP = 2; +interface DeviceCapPolicy { + /** Max distinct paired devices allowed. Free returns FREE_DEVICE_CAP; a pro + * entitlement returns a higher number or Infinity. */ + limit(): number; +} +/** A fixed free-tier policy. */ +declare const freePolicy: DeviceCapPolicy; +/** Build a policy from a pro flag supplied by the host's entitlement check. */ +declare function policyFor(isPro: boolean, proLimit?: number): DeviceCapPolicy; +interface DeviceCap { + policy: DeviceCapPolicy; + /** How many distinct devices are already paired (from the host's store). */ + pairedCount: () => number; + /** Whether this device id is already paired (re-pairing does not count). */ + isKnown: (deviceId: string) => boolean; +} +/** True if pairing with `deviceId` is allowed under the cap. */ +declare function pairingAllowed(cap: DeviceCap | undefined, deviceId: string): boolean; + +interface SyncEngineOptions { + localDevice: DeviceInfo; + transport: TransportBridge; + /** Supply the passphrase for an incoming pairing (e.g. a UI prompt). Return + * null/undefined to refuse. Not needed on the side that calls connect(). */ + getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; + /** Application message from a paired peer (pairing traffic is handled internally). */ + onMessage?: (deviceId: string, message: Message) => void; + /** Generic app-channel message from a paired peer (type 'app'). Used by + * features like memory/clipboard sync that ride the paired channel. */ + onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; + /** Look up the stored shared secret for an already-paired device, so an + * inbound reconnect (hello) can resume without re-running the handshake. */ + getSharedSecret?: (deviceId: string) => string | undefined; + /** A pairing handshake completed. */ + onPaired?: (device: PairedDevice) => void; + /** A pairing attempt failed. */ + onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; + /** Optional device cap (open-core 2 free / 3+ paid). When set, pairing a new + * device beyond the limit is refused on both the dialing and accepting side. */ + cap?: DeviceCap; +} +/** One peer connection: owns its frame buffer, pairing state, and shared secret. */ +declare class PeerSession { + readonly conn: SyncConnection; + private readonly engine; + private readonly opts; + private buffer; + private pairing; + private sharedSecret?; + private passphrase?; + private resumeSecret?; + private helloSent; + private queue; + remoteDevice?: DeviceInfo; + constructor(conn: SyncConnection, engine: SyncEngine, opts: SyncEngineOptions, initiateWith?: { + remote: DeviceInfo; + passphrase: string; + }, resumeWith?: { + remote: DeviceInfo; + sharedSecret: string; + }); + get pairedSecret(): string | undefined; + /** Send an application message to this peer (must be paired). */ + sendMessage(message: Message): boolean; + private sendPlain; + private onData; + private route; + /** Resume an already-paired device using the stored secret (no handshake). */ + private handleHello; + private handlePairing; +} +declare class SyncEngine { + private readonly opts; + private sessions; + private paired; + constructor(opts: SyncEngineOptions); + /** Start accepting inbound connections on `port`. */ + start(port: number): Promise; + /** Dial a discovered device and begin pairing with `passphrase`. Refuses if + * pairing a new device would exceed the device cap. */ + pair(device: DeviceInfo, passphrase: string): Promise; + /** Reconnect to an already-paired device using its stored shared secret, + * skipping the pairing handshake. Used for auto-reconnect on discovery. */ + reconnect(device: DeviceInfo, sharedSecret: string): Promise; + /** Send an application message to an already-paired device. */ + send(deviceId: string, message: Message): boolean; + /** Send a generic app-channel message (encrypted) to a paired device. */ + sendApp(deviceId: string, channel: string, data: unknown): boolean; + isPaired(deviceId: string): boolean; + stop(): Promise; + /** @internal */ + _registerPaired(deviceId: string, session: PeerSession): void; + /** @internal */ + _removeSession(session: PeerSession): void; +} + +/** The slice of SyncEngine the orchestrator drives. */ +interface ReconnectingEngine { + isPaired(deviceId: string): boolean; + reconnect(device: DeviceInfo, sharedSecret: string): Promise; +} +interface DiscoveryOrchestratorOptions { + engine: ReconnectingEngine; + discovery: DiscoveryService; + localDevice: DeviceInfo; + /** Stored shared secret for a device, or undefined if not yet paired. */ + getSharedSecret: (deviceId: string) => string | undefined; + /** A discovered device we have no secret for - surface it so the UI can pair. */ + onDiscovered?: (device: DiscoveredDevice) => void; + /** A previously discovered device went away. */ + onLost?: (deviceId: string) => void; +} +declare class DiscoveryOrchestrator { + private readonly opts; + private connecting; + constructor(opts: DiscoveryOrchestratorOptions); + start(): Promise; + stop(): Promise; + private handleFound; +} + +type OpKind = 'put' | 'delete'; +interface Op { + /** Globally-unique op id (uuid). The dedup key across devices. */ + opId: string; + /** Logical record type, e.g. 'conversation' | 'message' | 'project'. */ + entity: string; + /** Stable id of the record this op mutates (must be a UUID, not autoincrement). */ + entityId: string; + kind: OpKind; + /** Full record fields for a 'put' (whole-record LWW). Omitted for 'delete'. */ + fields?: Record; + /** Lamport logical clock. */ + lamport: number; + /** Origin device id. */ + deviceId: string; + /** Wall-clock ms (display / human tiebreak only — never used for ordering). */ + ts: number; +} +/** Version vector: per-device highest lamport seen. */ +type VersionVector = Record; +/** How the op-log writes materialized state into the host's real store. */ +interface Materializer { + put(entity: string, entityId: string, fields: Record): void; + remove(entity: string, entityId: string): void; +} +interface OpLogOptions { + deviceId: string; + materializer: Materializer; + /** Persist a single newly-accepted op (e.g. INSERT into sync_ops). */ + persist?: (op: Op) => void; + /** Generate a uuid (host injects: crypto.randomUUID on Node, a polyfill on RN). */ + uuid: () => string; + /** Wall clock ms. Injected so the core stays free of Date.now (testable). */ + now: () => number; + /** Ops already on disk, to rehydrate the log at startup. */ + persisted?: Op[]; +} +declare class OpLog { + private readonly opts; + private ops; + private clock; + constructor(opts: OpLogOptions); + /** Per-device highest lamport — what we tell a peer we already have. */ + versionVector(): VersionVector; + /** Ops the peer (described by their version vector) hasn't seen yet. */ + opsSince(peerVV: VersionVector): Op[]; + /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ + record(entity: string, entityId: string, kind: OpKind, fields?: Record): Op; + /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ + ingest(incoming: Op[]): Op[]; + /** Recompute the winning op for one record and push it to the materializer. */ + private rematerialize; + /** Total ops held (diagnostics). */ + size(): number; +} + +type StateMsg = { + t: 'have'; + vv: VersionVector; +} | { + t: 'ops'; + ops: Op[]; +}; +interface StateSyncOptions { + oplog: OpLog; + /** Send a state message to one peer (host wires → sendApp(id,'state',msg)). */ + send: (deviceId: string, msg: StateMsg) => void; +} +declare class StateSync { + private readonly opts; + constructor(opts: StateSyncOptions); + /** A peer connected: advertise our version vector so it can backfill us; we + * backfill it when its own `have` arrives. */ + onConnect(deviceId: string): void; + /** Inbound message on the 'state' channel from a paired peer. */ + onMessage(deviceId: string, data: unknown): void; +} + +declare const VERSION = "0.0.1"; +declare const APP_NAME = "Off Grid Sync"; + +export { APP_NAME, CHUNK_SIZE, type DecodedFrame, type DeviceCap, type DeviceCapPolicy, DeviceInfo, DiscoveredDevice, DiscoveryOrchestrator, type DiscoveryOrchestratorOptions, DiscoveryService, FRAME_HEADER_LENGTH, FRAME_KIND_ENCRYPTED, FRAME_KIND_PLAINTEXT, FREE_DEVICE_CAP, FileAcceptMessage, FileAckMessage, FileChunkMessage, FileCompleteMessage, FileRejectMessage, FileRequestMessage, FileTransfer, FrameBuffer, HEADER_LENGTH, IncrementalChecksum, MAX_FRAME_SIZE, MAX_MESSAGE_SIZE, MAX_TEXT_LENGTH, MESSAGE_CODE_TYPES, MESSAGE_TYPE_CODES, type Materializer, Message, MessageBuffer, type Op, type OpKind, OpLog, type OpLogOptions, PROTOCOL_VERSION, PairChallengeMessage, PairConfirmMessage, PairRejectMessage, PairRequestMessage, PairResponseMessage, PairedDevice, type PairingState, PairingStatus, type ReconnectingEngine, type StateMsg, StateSync, type StateSyncOptions, SyncConnection, SyncEngine, type SyncEngineOptions, TextMessage, TextTransfer, TransferProgress, TransportBridge, VERSION, type VersionVector, calculateChecksum, calculateProgress, chunkFile, createAppMessage, createChallengeResponse, createEncryptedFrame, createEncryptedTextMessage, createErrorMessage, createFileAccept, createFileAcceptHttp, createFileAck, createFileChunk, createFileChunkFromBase64, createFileComplete, createFileCompleteStreaming, createFileReject, createFileRequest, createFileRequestHttp, createFileRequestStreaming, createFileTransfer, createHello, createPairChallenge, createPairConfirm, createPairReject, createPairRequest, createPairResponse, createPairedDevice, createPairingState, createPingMessage, createPongMessage, createTextMessage, createTextTransfer, decodeFrameBody, decrypt, decryptMessage, decryptTextMessage, decryptToString, deriveKey, deriveSharedSecret, deserializeMessage, encodeEncryptedFrame, encodePlaintextFrame, encrypt, encryptMessage, formatDuration, formatEta, formatFileSize, formatProgressInfo, formatTransferSpeed, freePolicy, generateChallenge, generateDeviceId, generateMessageId, getMessageLength, getMimeType, getPairedDevice, handlePairingMessage, isPaired, pairingAllowed, parseEncryptedFrame, policyFor, reassembleChunks, removePairedDevice, serializeMessage, updateLastConnected, verifyChallengeResponse, verifyChecksum, verifyFileIntegrity }; diff --git a/packages/sync/dist/index.js b/packages/sync/dist/index.js new file mode 100644 index 00000000..35388963 --- /dev/null +++ b/packages/sync/dist/index.js @@ -0,0 +1,1565 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + APP_NAME: () => APP_NAME, + CHUNK_SIZE: () => CHUNK_SIZE, + DiscoveryOrchestrator: () => DiscoveryOrchestrator, + FRAME_HEADER_LENGTH: () => FRAME_HEADER_LENGTH, + FRAME_KIND_ENCRYPTED: () => FRAME_KIND_ENCRYPTED, + FRAME_KIND_PLAINTEXT: () => FRAME_KIND_PLAINTEXT, + FREE_DEVICE_CAP: () => FREE_DEVICE_CAP, + FrameBuffer: () => FrameBuffer, + HEADER_LENGTH: () => HEADER_LENGTH, + IncrementalChecksum: () => IncrementalChecksum, + MAX_FRAME_SIZE: () => MAX_FRAME_SIZE, + MAX_MESSAGE_SIZE: () => MAX_MESSAGE_SIZE, + MAX_TEXT_LENGTH: () => MAX_TEXT_LENGTH, + MDNS_DOMAIN: () => MDNS_DOMAIN, + MDNS_SERVICE_NAME: () => MDNS_SERVICE_NAME, + MDNS_SERVICE_TYPE: () => MDNS_SERVICE_TYPE, + MESSAGE_CODE_TYPES: () => MESSAGE_CODE_TYPES, + MESSAGE_TYPE_CODES: () => MESSAGE_TYPE_CODES, + MessageBuffer: () => MessageBuffer, + OpLog: () => OpLog, + PROTOCOL_VERSION: () => PROTOCOL_VERSION, + StateSync: () => StateSync, + SyncEngine: () => SyncEngine, + TXT_DEVICE_ID: () => TXT_DEVICE_ID, + TXT_DEVICE_NAME: () => TXT_DEVICE_NAME, + TXT_PLATFORM: () => TXT_PLATFORM, + TXT_VERSION: () => TXT_VERSION, + VERSION: () => VERSION, + calculateChecksum: () => calculateChecksum, + calculateProgress: () => calculateProgress, + chunkFile: () => chunkFile, + createAppMessage: () => createAppMessage, + createChallengeResponse: () => createChallengeResponse, + createDiscoveredDevice: () => createDiscoveredDevice, + createEncryptedFrame: () => createEncryptedFrame, + createEncryptedTextMessage: () => createEncryptedTextMessage, + createErrorMessage: () => createErrorMessage, + createFileAccept: () => createFileAccept, + createFileAcceptHttp: () => createFileAcceptHttp, + createFileAck: () => createFileAck, + createFileChunk: () => createFileChunk, + createFileChunkFromBase64: () => createFileChunkFromBase64, + createFileComplete: () => createFileComplete, + createFileCompleteStreaming: () => createFileCompleteStreaming, + createFileReject: () => createFileReject, + createFileRequest: () => createFileRequest, + createFileRequestHttp: () => createFileRequestHttp, + createFileRequestStreaming: () => createFileRequestStreaming, + createFileTransfer: () => createFileTransfer, + createHello: () => createHello, + createPairChallenge: () => createPairChallenge, + createPairConfirm: () => createPairConfirm, + createPairReject: () => createPairReject, + createPairRequest: () => createPairRequest, + createPairResponse: () => createPairResponse, + createPairedDevice: () => createPairedDevice, + createPairingState: () => createPairingState, + createPingMessage: () => createPingMessage, + createPongMessage: () => createPongMessage, + createTextMessage: () => createTextMessage, + createTextTransfer: () => createTextTransfer, + createTxtRecord: () => createTxtRecord, + decodeBase64: () => import_tweetnacl_util.decodeBase64, + decodeFrameBody: () => decodeFrameBody, + decodeUTF8: () => import_tweetnacl_util.decodeUTF8, + decrypt: () => decrypt, + decryptMessage: () => decryptMessage, + decryptTextMessage: () => decryptTextMessage, + decryptToString: () => decryptToString, + deriveKey: () => deriveKey, + deriveSharedSecret: () => deriveSharedSecret, + deserializeMessage: () => deserializeMessage, + encodeBase64: () => import_tweetnacl_util.encodeBase64, + encodeEncryptedFrame: () => encodeEncryptedFrame, + encodePlaintextFrame: () => encodePlaintextFrame, + encodeUTF8: () => import_tweetnacl_util.encodeUTF8, + encrypt: () => encrypt, + encryptMessage: () => encryptMessage, + filterStaleDevices: () => filterStaleDevices, + formatDuration: () => formatDuration, + formatEta: () => formatEta, + formatFileSize: () => formatFileSize, + formatProgressInfo: () => formatProgressInfo, + formatTransferSpeed: () => formatTransferSpeed, + freePolicy: () => freePolicy, + generateChallenge: () => generateChallenge, + generateDeviceId: () => generateDeviceId, + generateMessageId: () => generateMessageId, + getMessageLength: () => getMessageLength, + getMimeType: () => getMimeType, + getPairedDevice: () => getPairedDevice, + handlePairingMessage: () => handlePairingMessage, + isDeviceStale: () => isDeviceStale, + isPaired: () => isPaired, + pairingAllowed: () => pairingAllowed, + parseEncryptedFrame: () => parseEncryptedFrame, + parseTxtRecord: () => parseTxtRecord, + policyFor: () => policyFor, + reassembleChunks: () => reassembleChunks, + removeDevice: () => removeDevice, + removePairedDevice: () => removePairedDevice, + serializeMessage: () => serializeMessage, + updateDeviceList: () => updateDeviceList, + updateLastConnected: () => updateLastConnected, + verifyChallengeResponse: () => verifyChallengeResponse, + verifyChecksum: () => verifyChecksum, + verifyFileIntegrity: () => verifyFileIntegrity +}); +module.exports = __toCommonJS(index_exports); + +// src/crypto/index.ts +var import_tweetnacl = __toESM(require("tweetnacl")); +var import_tweetnacl_util = require("tweetnacl-util"); +var import_js_sha512 = require("js-sha512"); +var PBKDF2_ITERATIONS = 1e4; +var SALT_LENGTH = 16; +var KEY_LENGTH = 32; +function generateDeviceId() { + const bytes = import_tweetnacl.default.randomBytes(16); + return (0, import_tweetnacl_util.encodeBase64)(bytes).replace( + /[+/=]/g, + (c) => c === "+" ? "-" : c === "/" ? "_" : "" + ); +} +function generateMessageId() { + const bytes = import_tweetnacl.default.randomBytes(8); + return (0, import_tweetnacl_util.encodeBase64)(bytes).replace( + /[+/=]/g, + (c) => c === "+" ? "-" : c === "/" ? "_" : "" + ); +} +function deriveKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) { + const passphraseBytes = (0, import_tweetnacl_util.decodeUTF8)(passphrase); + const combined = new Uint8Array(passphraseBytes.length + salt.length); + combined.set(passphraseBytes); + combined.set(salt, passphraseBytes.length); + let result = import_tweetnacl.default.hash(combined); + for (let i = 1; i < iterations; i++) { + result = import_tweetnacl.default.hash(result); + } + return result.slice(0, KEY_LENGTH); +} +function deriveSharedSecret(passphrase, deviceId1, deviceId2) { + const sortedIds = [deviceId1, deviceId2].sort(); + const saltString = `${sortedIds[0]}:${sortedIds[1]}`; + const salt = import_tweetnacl.default.hash((0, import_tweetnacl_util.decodeUTF8)(saltString)).slice(0, SALT_LENGTH); + const key = deriveKey(passphrase, salt); + return (0, import_tweetnacl_util.encodeBase64)(key); +} +function generateChallenge() { + const bytes = import_tweetnacl.default.randomBytes(32); + return (0, import_tweetnacl_util.encodeBase64)(bytes); +} +function createChallengeResponse(challenge, sharedSecret) { + const challengeBytes = (0, import_tweetnacl_util.decodeBase64)(challenge); + const secretBytes = (0, import_tweetnacl_util.decodeBase64)(sharedSecret); + const combined = new Uint8Array(challengeBytes.length + secretBytes.length); + combined.set(challengeBytes); + combined.set(secretBytes, challengeBytes.length); + const hash = import_tweetnacl.default.hash(combined); + return (0, import_tweetnacl_util.encodeBase64)(hash.slice(0, 32)); +} +function verifyChallengeResponse(challenge, response, sharedSecret) { + const expectedResponse = createChallengeResponse(challenge, sharedSecret); + return response === expectedResponse; +} +function encrypt(data, secretKey) { + const keyBytes = (0, import_tweetnacl_util.decodeBase64)(secretKey); + const dataBytes = typeof data === "string" ? (0, import_tweetnacl_util.decodeUTF8)(data) : data; + const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength); + const encrypted = import_tweetnacl.default.secretbox(dataBytes, nonce, keyBytes); + return { + encrypted: (0, import_tweetnacl_util.encodeBase64)(encrypted), + nonce: (0, import_tweetnacl_util.encodeBase64)(nonce) + }; +} +function decrypt(encrypted, nonce, secretKey) { + const keyBytes = (0, import_tweetnacl_util.decodeBase64)(secretKey); + const encryptedBytes = (0, import_tweetnacl_util.decodeBase64)(encrypted); + const nonceBytes = (0, import_tweetnacl_util.decodeBase64)(nonce); + const decrypted = import_tweetnacl.default.secretbox.open(encryptedBytes, nonceBytes, keyBytes); + return decrypted; +} +function decryptToString(encrypted, nonce, secretKey) { + const decrypted = decrypt(encrypted, nonce, secretKey); + if (!decrypted) return null; + return (0, import_tweetnacl_util.encodeUTF8)(decrypted); +} +function calculateChecksum(data) { + const hash = import_tweetnacl.default.hash(data); + return (0, import_tweetnacl_util.encodeBase64)(hash.slice(0, 16)); +} +function verifyChecksum(data, checksum) { + const calculated = calculateChecksum(data); + return calculated === checksum; +} +var IncrementalChecksum = class { + hasher; + constructor() { + this.hasher = import_js_sha512.sha512.create(); + } + /** + * Feed a chunk of data into the hash + */ + update(data) { + this.hasher.update(data); + } + /** + * Finalize and return checksum in the same format as calculateChecksum() + * (base64 of first 16 bytes of SHA-512 digest) + */ + digest() { + const hashArray = this.hasher.array(); + const first16 = new Uint8Array(hashArray.slice(0, 16)); + return (0, import_tweetnacl_util.encodeBase64)(first16); + } +}; + +// src/discovery/index.ts +var MDNS_SERVICE_TYPE = "_easyshare._tcp"; +var MDNS_SERVICE_NAME = "EasyShare"; +var MDNS_DOMAIN = "local"; +var TXT_DEVICE_ID = "id"; +var TXT_DEVICE_NAME = "name"; +var TXT_PLATFORM = "platform"; +var TXT_VERSION = "version"; +function createTxtRecord(device) { + return { + [TXT_DEVICE_ID]: device.id, + [TXT_DEVICE_NAME]: device.name, + [TXT_PLATFORM]: device.platform, + [TXT_VERSION]: device.version + }; +} +function parseTxtRecord(txt, host, port) { + const id = txt[TXT_DEVICE_ID]; + const name = txt[TXT_DEVICE_NAME]; + const platform = txt[TXT_PLATFORM]; + const version = txt[TXT_VERSION]; + if (!id || !name || !platform || !version) { + return null; + } + return { + id, + name, + platform, + version, + host, + port + }; +} +function createDiscoveredDevice(device) { + return { + ...device, + lastSeen: Date.now() + }; +} +function isDeviceStale(device, maxAgeMs = 3e4) { + return Date.now() - device.lastSeen > maxAgeMs; +} +function filterStaleDevices(devices, maxAgeMs = 3e4) { + return devices.filter((device) => !isDeviceStale(device, maxAgeMs)); +} +function updateDeviceList(devices, newDevice) { + const existingIndex = devices.findIndex((d) => d.id === newDevice.id); + if (existingIndex >= 0) { + const updated = [...devices]; + updated[existingIndex] = { ...newDevice, lastSeen: Date.now() }; + return updated; + } + return [...devices, newDevice]; +} +function removeDevice(devices, deviceId) { + return devices.filter((d) => d.id !== deviceId); +} + +// src/pairing/index.ts +function createPairingState(localDevice) { + return { + status: "idle", + localDevice + }; +} +function createPairRequest(localDevice) { + return { + type: "pair_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + deviceInfo: localDevice + } + }; +} +function createPairChallenge() { + const challenge = generateChallenge(); + return { + type: "pair_challenge", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + challenge, + timestamp: Date.now() + } + }; +} +function createPairResponse(challenge, sharedSecret, localDevice) { + const response = createChallengeResponse(challenge, sharedSecret); + return { + type: "pair_response", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + response, + deviceInfo: localDevice + } + }; +} +function createPairConfirm(localDevice) { + return { + type: "pair_confirm", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + deviceInfo: localDevice + } + }; +} +function createPairReject(reason) { + return { + type: "pair_reject", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + reason + } + }; +} +function handlePairingMessage(state, message, passphrase) { + switch (message.type) { + case "pair_request": { + const msg = message; + const remoteDevice = msg.payload.deviceInfo; + if (!passphrase) { + return { + newState: { + ...state, + status: "waiting", + remoteDevice + } + }; + } + const sharedSecret = deriveSharedSecret( + passphrase, + state.localDevice.id, + remoteDevice.id + ); + const challengeMsg = createPairChallenge(); + return { + newState: { + ...state, + status: "verifying", + remoteDevice, + passphrase, + sharedSecret, + challenge: challengeMsg.payload.challenge + }, + response: challengeMsg + }; + } + case "pair_challenge": { + const msg = message; + if (!passphrase || !state.remoteDevice) { + return { + newState: { + ...state, + status: "failed", + error: "Missing passphrase or remote device" + } + }; + } + const sharedSecret = deriveSharedSecret( + passphrase, + state.localDevice.id, + state.remoteDevice.id + ); + const responseMsg = createPairResponse( + msg.payload.challenge, + sharedSecret, + state.localDevice + ); + return { + newState: { + ...state, + status: "verifying", + sharedSecret + }, + response: responseMsg + }; + } + case "pair_response": { + const msg = message; + if (!state.sharedSecret || !state.challenge) { + return { + newState: { + ...state, + status: "failed", + error: "Invalid pairing state" + } + }; + } + const isValid = verifyChallengeResponse( + state.challenge, + msg.payload.response, + state.sharedSecret + ); + if (isValid) { + const confirmMsg = createPairConfirm(state.localDevice); + return { + newState: { + ...state, + status: "success", + remoteDevice: msg.payload.deviceInfo + }, + response: confirmMsg + }; + } else { + const rejectMsg = createPairReject("Passphrase mismatch"); + return { + newState: { + ...state, + status: "failed", + error: "Passphrase mismatch" + }, + response: rejectMsg + }; + } + } + case "pair_confirm": { + return { + newState: { + ...state, + status: "success" + } + }; + } + case "pair_reject": { + const msg = message; + return { + newState: { + ...state, + status: "failed", + error: msg.payload.reason + } + }; + } + default: + return { newState: state }; + } +} +function createPairedDevice(state) { + if (state.status !== "success" || !state.remoteDevice || !state.sharedSecret) { + return null; + } + return { + ...state.remoteDevice, + sharedSecret: state.sharedSecret, + pairedAt: Date.now() + }; +} +function isPaired(deviceId, pairedDevices) { + return pairedDevices.some((d) => d.id === deviceId); +} +function getPairedDevice(deviceId, pairedDevices) { + return pairedDevices.find((d) => d.id === deviceId); +} +function updateLastConnected(deviceId, pairedDevices) { + return pairedDevices.map( + (d) => d.id === deviceId ? { ...d, lastConnected: Date.now() } : d + ); +} +function removePairedDevice(deviceId, pairedDevices) { + return pairedDevices.filter((d) => d.id !== deviceId); +} + +// src/transfer/index.ts +var CHUNK_SIZE = 64 * 1024; +var MAX_TEXT_LENGTH = 1024 * 1024; +function createTextTransfer(content, device, direction) { + return { + id: generateMessageId(), + type: "text", + timestamp: Date.now(), + direction, + deviceId: device.id, + deviceName: device.name, + content + }; +} +function createFileTransfer(fileName, fileSize, mimeType, device, direction, durationMs) { + const transfer = { + id: generateMessageId(), + type: "file", + timestamp: Date.now(), + direction, + deviceId: device.id, + deviceName: device.name, + fileName, + fileSize, + mimeType + }; + if (durationMs != null && durationMs > 0) { + transfer.durationMs = durationMs; + transfer.speedBytesPerSec = Math.round(fileSize / durationMs * 1e3); + } + return transfer; +} +function createTextMessage(content) { + return { + type: "text", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + content + } + }; +} +function createEncryptedTextMessage(content, secretKey) { + const { encrypted, nonce } = encrypt(content, secretKey); + return { + message: createTextMessage(encrypted), + nonce + }; +} +function decryptTextMessage(message, nonce, secretKey) { + const decrypted = decrypt(message.payload.content, nonce, secretKey); + if (!decrypted) return null; + return new TextDecoder().decode(decrypted); +} +function createFileRequest(fileName, fileSize, mimeType, fileData) { + return { + type: "file_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum: calculateChecksum(fileData) + } + }; +} +function createFileRequestStreaming(fileName, fileSize, mimeType, checksum) { + return { + type: "file_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum + } + }; +} +function createFileCompleteStreaming(requestId, checksum) { + return { + type: "file_complete", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + checksum + } + }; +} +function createFileRequestHttp(fileName, fileSize, mimeType, checksum, httpUrl) { + return { + type: "file_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum, + httpUrl + } + }; +} +function createFileAccept(requestId) { + return { + type: "file_accept", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId + } + }; +} +function createFileAcceptHttp(requestId, uploadUrl) { + return { + type: "file_accept", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + uploadUrl + } + }; +} +function createFileAck(requestId, success) { + return { + type: "file_ack", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + success + } + }; +} +function createFileReject(requestId, reason) { + return { + type: "file_reject", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + reason + } + }; +} +function createFileChunk(requestId, chunkIndex, totalChunks, data) { + return { + type: "file_chunk", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + chunkIndex, + totalChunks, + data: (0, import_tweetnacl_util.encodeBase64)(data) + } + }; +} +function createFileChunkFromBase64(requestId, chunkIndex, totalChunks, base64Data) { + return { + type: "file_chunk", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + chunkIndex, + totalChunks, + data: base64Data + } + }; +} +function createFileComplete(requestId, fileData) { + return { + type: "file_complete", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + checksum: calculateChecksum(fileData) + } + }; +} +function* chunkFile(data, chunkSize = CHUNK_SIZE) { + const totalChunks = Math.ceil(data.length / chunkSize); + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, data.length); + yield { + chunk: data.slice(start, end), + index: i, + total: totalChunks + }; + } +} +function reassembleChunks(chunks, totalChunks) { + if (chunks.size !== totalChunks) { + return null; + } + let totalSize = 0; + for (let i = 0; i < totalChunks; i++) { + const chunk = chunks.get(i); + if (!chunk) return null; + totalSize += chunk.length; + } + const result = new Uint8Array(totalSize); + let offset = 0; + for (let i = 0; i < totalChunks; i++) { + const chunk = chunks.get(i); + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} +function calculateProgress(transferId, bytesTransferred, totalBytes, currentFile, startTime) { + const clampedBytes = Math.min(bytesTransferred, totalBytes); + const result = { + transferId, + bytesTransferred: clampedBytes, + totalBytes, + percentage: totalBytes > 0 ? Math.min(100, Math.round(clampedBytes / totalBytes * 100)) : 0, + currentFile + }; + if (startTime && startTime > 0) { + const elapsedMs = Date.now() - startTime; + result.elapsedMs = elapsedMs; + if (elapsedMs > 500 && clampedBytes > 0) { + result.speedBytesPerSec = Math.round(clampedBytes / elapsedMs * 1e3); + if (result.speedBytesPerSec > 0 && clampedBytes < totalBytes) { + const remainingBytes = totalBytes - clampedBytes; + result.etaSeconds = Math.round(remainingBytes / result.speedBytesPerSec); + } + } + } + return result; +} +function verifyFileIntegrity(data, expectedChecksum) { + return verifyChecksum(data, expectedChecksum); +} +function formatFileSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} +function formatTransferSpeed(bytesPerSec) { + if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; + if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; + if (bytesPerSec < 1024 * 1024 * 1024) return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; + return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)} GB/s`; +} +function formatDuration(ms) { + if (ms < 1e3) return `${ms}ms`; + const seconds = ms / 1e3; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds.toFixed(0)}s`; +} +function formatEta(seconds) { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} +function formatProgressInfo(progress) { + const parts = []; + if (progress.speedBytesPerSec != null && progress.speedBytesPerSec > 0) { + parts.push(formatTransferSpeed(progress.speedBytesPerSec)); + } + if (progress.elapsedMs != null && progress.elapsedMs >= 1e3) { + parts.push(formatDuration(progress.elapsedMs) + " elapsed"); + } + if (progress.etaSeconds != null && progress.etaSeconds > 0) { + parts.push("~" + formatEta(progress.etaSeconds) + " left"); + } + return parts.join(" \xB7 "); +} +function getMimeType(fileName) { + const ext = fileName.split(".").pop()?.toLowerCase() || ""; + const mimeTypes = { + txt: "text/plain", + html: "text/html", + css: "text/css", + js: "application/javascript", + json: "application/json", + xml: "application/xml", + pdf: "application/pdf", + zip: "application/zip", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + mp3: "audio/mpeg", + wav: "audio/wav", + mp4: "video/mp4", + webm: "video/webm", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" + }; + return mimeTypes[ext] || "application/octet-stream"; +} + +// src/protocol/index.ts +var PROTOCOL_VERSION = "1.0.0"; +var HEADER_LENGTH = 5; +var MAX_MESSAGE_SIZE = 10 * 1024 * 1024; +var MESSAGE_TYPE_CODES = { + ping: 1, + pong: 2, + pair_request: 16, + pair_challenge: 17, + pair_response: 18, + pair_confirm: 19, + pair_reject: 20, + hello: 21, + text: 32, + file_request: 48, + file_accept: 49, + file_reject: 50, + file_chunk: 51, + file_complete: 52, + file_ack: 53, + app: 64, + error: 255 +}; +function createHello(deviceInfo) { + return { + type: "hello", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { deviceInfo } + }; +} +function createAppMessage(channel, data) { + return { + type: "app", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { channel, data } + }; +} +var MESSAGE_CODE_TYPES = Object.fromEntries( + Object.entries(MESSAGE_TYPE_CODES).map(([k, v]) => [v, k]) +); +function serializeMessage(message) { + const jsonPayload = JSON.stringify(message); + const payloadBytes = new TextEncoder().encode(jsonPayload); + const typeCode = MESSAGE_TYPE_CODES[message.type] || 255; + const buffer = new Uint8Array(HEADER_LENGTH + payloadBytes.length); + const view = new DataView(buffer.buffer); + view.setUint32(0, payloadBytes.length, false); + buffer[4] = typeCode; + buffer.set(payloadBytes, HEADER_LENGTH); + return buffer; +} +function deserializeMessage(buffer) { + if (buffer.length < HEADER_LENGTH) { + return null; + } + const view = new DataView(buffer.buffer, buffer.byteOffset); + const payloadLength = view.getUint32(0, false); + if (buffer.length < HEADER_LENGTH + payloadLength) { + return null; + } + const payloadBytes = buffer.slice(HEADER_LENGTH, HEADER_LENGTH + payloadLength); + const jsonPayload = new TextDecoder().decode(payloadBytes); + try { + return JSON.parse(jsonPayload); + } catch { + return null; + } +} +function getMessageLength(header) { + if (header.length < 4) { + return null; + } + const view = new DataView(header.buffer, header.byteOffset); + const length = view.getUint32(0, false); + if (length > MAX_MESSAGE_SIZE) { + return null; + } + return HEADER_LENGTH + length; +} +function encryptMessage(message, secretKey) { + const serialized = serializeMessage(message); + const { encrypted, nonce } = encrypt(serialized, secretKey); + return { + encrypted: (0, import_tweetnacl_util.decodeBase64)(encrypted), + nonce + }; +} +function decryptMessage(encrypted, nonce, secretKey) { + const decrypted = decrypt((0, import_tweetnacl_util.encodeBase64)(encrypted), nonce, secretKey); + if (!decrypted) return null; + return deserializeMessage(decrypted); +} +function createEncryptedFrame(encrypted, nonce) { + const nonceBytes = (0, import_tweetnacl_util.decodeBase64)(nonce); + const frame2 = new Uint8Array(1 + nonceBytes.length + encrypted.length); + frame2[0] = nonceBytes.length; + frame2.set(nonceBytes, 1); + frame2.set(encrypted, 1 + nonceBytes.length); + return frame2; +} +function parseEncryptedFrame(frame2) { + if (frame2.length < 2) return null; + const nonceLength = frame2[0]; + if (frame2.length < 1 + nonceLength) return null; + const nonceBytes = frame2.slice(1, 1 + nonceLength); + const encrypted = frame2.slice(1 + nonceLength); + return { + encrypted, + nonce: (0, import_tweetnacl_util.encodeBase64)(nonceBytes) + }; +} +var MessageBuffer = class { + buffer = new Uint8Array(0); + /** + * Add data to the buffer + */ + append(data) { + const newBuffer = new Uint8Array(this.buffer.length + data.length); + newBuffer.set(this.buffer); + newBuffer.set(data, this.buffer.length); + this.buffer = newBuffer; + } + /** + * Try to extract a complete message from the buffer + */ + extractMessage() { + const length = getMessageLength(this.buffer); + if (length === null || this.buffer.length < length) { + return null; + } + const messageBytes = this.buffer.slice(0, length); + this.buffer = this.buffer.slice(length); + return deserializeMessage(messageBytes); + } + /** + * Extract all complete messages from the buffer + */ + extractAllMessages() { + const messages = []; + let message; + while ((message = this.extractMessage()) !== null) { + messages.push(message); + } + return messages; + } + /** + * Get current buffer size + */ + get size() { + return this.buffer.length; + } + /** + * Clear the buffer + */ + clear() { + this.buffer = new Uint8Array(0); + } +}; +function createPingMessage() { + return { + type: "ping", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now() + }; +} +function createPongMessage(pingId) { + return { + type: "pong", + id: pingId, + timestamp: Date.now() + }; +} +function createErrorMessage(code, errorMessage, originalMessageId) { + return { + type: "error", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { + code, + message: errorMessage, + originalMessageId + } + }; +} + +// src/wire.ts +var FRAME_HEADER_LENGTH = 4; +var MAX_FRAME_SIZE = 16 * 1024 * 1024; +var FRAME_KIND_PLAINTEXT = 0; +var FRAME_KIND_ENCRYPTED = 1; +function frame(body) { + const out = new Uint8Array(FRAME_HEADER_LENGTH + body.length); + new DataView(out.buffer).setUint32(0, body.length, false); + out.set(body, FRAME_HEADER_LENGTH); + return out; +} +function encodePlaintextFrame(message) { + const json = new TextEncoder().encode(JSON.stringify(message)); + const body = new Uint8Array(1 + json.length); + body[0] = FRAME_KIND_PLAINTEXT; + body.set(json, 1); + return frame(body); +} +function encodeEncryptedFrame(message, secretKey) { + const { encrypted, nonce } = encryptMessage(message, secretKey); + const nonceBytes = (0, import_tweetnacl_util.decodeBase64)(nonce); + const body = new Uint8Array(1 + 1 + nonceBytes.length + encrypted.length); + body[0] = FRAME_KIND_ENCRYPTED; + body[1] = nonceBytes.length; + body.set(nonceBytes, 2); + body.set(encrypted, 2 + nonceBytes.length); + return frame(body); +} +function decodeFrameBody(body, secretKey) { + if (body.length < 1) return null; + const kind = body[0]; + const payload = body.subarray(1); + if (kind === FRAME_KIND_PLAINTEXT) { + try { + const message = JSON.parse(new TextDecoder().decode(payload)); + return { kind: "plaintext", message }; + } catch { + return null; + } + } + if (kind === FRAME_KIND_ENCRYPTED) { + if (!secretKey || payload.length < 1) return null; + const nonceLen = payload[0]; + if (payload.length < 1 + nonceLen) return null; + const nonce = (0, import_tweetnacl_util.encodeBase64)(payload.subarray(1, 1 + nonceLen)); + const encrypted = payload.subarray(1 + nonceLen); + const message = decryptMessage(encrypted, nonce, secretKey); + return message ? { kind: "encrypted", message } : null; + } + return null; +} +var FrameBuffer = class { + buffer = new Uint8Array(0); + append(data) { + const next = new Uint8Array(this.buffer.length + data.length); + next.set(this.buffer); + next.set(data, this.buffer.length); + this.buffer = next; + } + /** Pull the next complete frame body, or null if none is fully buffered. */ + nextBody() { + if (this.buffer.length < FRAME_HEADER_LENGTH) return null; + const len = new DataView( + this.buffer.buffer, + this.buffer.byteOffset + ).getUint32(0, false); + if (len > MAX_FRAME_SIZE) { + this.buffer = new Uint8Array(0); + return null; + } + if (this.buffer.length < FRAME_HEADER_LENGTH + len) return null; + const body = this.buffer.slice(FRAME_HEADER_LENGTH, FRAME_HEADER_LENGTH + len); + this.buffer = this.buffer.slice(FRAME_HEADER_LENGTH + len); + return body; + } + /** Decode all complete frames currently buffered. */ + drain(secretKey) { + const out = []; + let body; + while ((body = this.nextBody()) !== null) { + const decoded = decodeFrameBody(body, secretKey); + if (decoded) out.push(decoded); + } + return out; + } +}; + +// src/cap.ts +var FREE_DEVICE_CAP = 2; +var freePolicy = { limit: () => FREE_DEVICE_CAP }; +function policyFor(isPro, proLimit = Infinity) { + return { limit: () => isPro ? proLimit : FREE_DEVICE_CAP }; +} +function pairingAllowed(cap, deviceId) { + if (!cap) return true; + if (cap.isKnown(deviceId)) return true; + return cap.pairedCount() < cap.policy.limit(); +} + +// src/engine.ts +var PAIRING_TYPES = /* @__PURE__ */ new Set([ + "pair_request", + "pair_challenge", + "pair_response", + "pair_confirm", + "pair_reject" +]); +var PeerSession = class { + constructor(conn, engine, opts, initiateWith, resumeWith) { + this.conn = conn; + this.engine = engine; + this.opts = opts; + this.pairing = createPairingState(opts.localDevice); + if (initiateWith) { + this.passphrase = initiateWith.passphrase; + this.remoteDevice = initiateWith.remote; + this.pairing = { ...this.pairing, remoteDevice: initiateWith.remote, passphrase: initiateWith.passphrase }; + } else if (resumeWith) { + this.remoteDevice = resumeWith.remote; + this.resumeSecret = resumeWith.sharedSecret; + } + conn.onData((data) => this.onData(data)); + conn.onClose(() => this.engine._removeSession(this)); + if (initiateWith) { + this.sendPlain(createPairRequest(opts.localDevice)); + } else if (resumeWith) { + this.sendPlain(createHello(opts.localDevice)); + this.helloSent = true; + } + } + conn; + engine; + opts; + buffer = new FrameBuffer(); + pairing; + sharedSecret; + passphrase; + resumeSecret; + helloSent = false; + queue = Promise.resolve(); + remoteDevice; + get pairedSecret() { + return this.sharedSecret; + } + /** Send an application message to this peer (must be paired). */ + sendMessage(message) { + if (!this.sharedSecret) return false; + this.conn.send(encodeEncryptedFrame(message, this.sharedSecret)); + return true; + } + sendPlain(message) { + this.conn.send(encodePlaintextFrame(message)); + } + onData(data) { + this.buffer.append(data); + const frames = this.buffer.drain(this.sharedSecret); + for (const f of frames) { + this.queue = this.queue.then(() => this.route(f.message)); + } + } + async route(message) { + if (message.type === "hello") { + this.handleHello(message); + return; + } + if (PAIRING_TYPES.has(message.type)) { + await this.handlePairing(message); + return; + } + if (this.sharedSecret && this.remoteDevice) { + if (message.type === "app") { + const p = message.payload; + this.opts.onAppMessage?.(this.remoteDevice.id, p.channel, p.data); + } else { + this.opts.onMessage?.(this.remoteDevice.id, message); + } + } + } + /** Resume an already-paired device using the stored secret (no handshake). */ + handleHello(message) { + const remote = message.payload.deviceInfo; + this.remoteDevice = remote; + const secret = this.resumeSecret ?? this.opts.getSharedSecret?.(remote.id); + if (!secret) { + this.opts.onPairingFailed?.(remote, "unknown_device"); + this.conn.close(); + return; + } + this.sharedSecret = secret; + if (!this.helloSent) { + this.sendPlain(createHello(this.opts.localDevice)); + this.helloSent = true; + } + const paired = { ...remote, sharedSecret: secret, pairedAt: Date.now() }; + this.engine._registerPaired(remote.id, this); + this.opts.onPaired?.(paired); + } + async handlePairing(message) { + if (message.type === "pair_request" && this.passphrase == null) { + const remote = message.payload.deviceInfo; + this.remoteDevice = remote; + if (!pairingAllowed(this.opts.cap, remote.id)) { + this.sendPlain(createPairReject("device limit reached")); + this.opts.onPairingFailed?.(remote, "device_cap_reached"); + this.conn.close(); + return; + } + const pass = await this.opts.getPassphrase?.(remote); + if (pass == null) { + this.opts.onPairingFailed?.(remote, "pairing refused"); + this.conn.close(); + return; + } + this.passphrase = pass; + } + const { newState, response } = handlePairingMessage(this.pairing, message, this.passphrase); + this.pairing = newState; + if (newState.sharedSecret) this.sharedSecret = newState.sharedSecret; + if (newState.remoteDevice) this.remoteDevice = newState.remoteDevice; + if (response) this.sendPlain(response); + if (newState.status === "success") { + const paired = createPairedDevice(newState); + if (paired) { + this.engine._registerPaired(paired.id, this); + this.opts.onPaired?.(paired); + } + } else if (newState.status === "failed") { + this.opts.onPairingFailed?.(this.remoteDevice, newState.error ?? "pairing failed"); + } + } +}; +var SyncEngine = class { + constructor(opts) { + this.opts = opts; + } + opts; + sessions = /* @__PURE__ */ new Set(); + paired = /* @__PURE__ */ new Map(); + /** Start accepting inbound connections on `port`. */ + async start(port) { + await this.opts.transport.listen(port, (conn) => { + this.sessions.add(new PeerSession(conn, this, this.opts)); + }); + } + /** Dial a discovered device and begin pairing with `passphrase`. Refuses if + * pairing a new device would exceed the device cap. */ + async pair(device, passphrase) { + if (!pairingAllowed(this.opts.cap, device.id)) { + this.opts.onPairingFailed?.(device, "device_cap_reached"); + return; + } + const conn = await this.opts.transport.connect(device.host, device.port); + const session = new PeerSession(conn, this, this.opts, { remote: device, passphrase }); + this.sessions.add(session); + } + /** Reconnect to an already-paired device using its stored shared secret, + * skipping the pairing handshake. Used for auto-reconnect on discovery. */ + async reconnect(device, sharedSecret) { + const conn = await this.opts.transport.connect(device.host, device.port); + const session = new PeerSession(conn, this, this.opts, void 0, { remote: device, sharedSecret }); + this.sessions.add(session); + } + /** Send an application message to an already-paired device. */ + send(deviceId, message) { + return this.paired.get(deviceId)?.sendMessage(message) ?? false; + } + /** Send a generic app-channel message (encrypted) to a paired device. */ + sendApp(deviceId, channel, data) { + return this.send(deviceId, createAppMessage(channel, data)); + } + isPaired(deviceId) { + return this.paired.has(deviceId); + } + async stop() { + for (const s of this.sessions) s.conn.close(); + this.sessions.clear(); + this.paired.clear(); + await this.opts.transport.stop(); + } + /** @internal */ + _registerPaired(deviceId, session) { + this.paired.set(deviceId, session); + } + /** @internal */ + _removeSession(session) { + this.sessions.delete(session); + for (const [id, s] of this.paired) { + if (s === session) this.paired.delete(id); + } + } +}; + +// src/orchestrator.ts +var DiscoveryOrchestrator = class { + constructor(opts) { + this.opts = opts; + } + opts; + connecting = /* @__PURE__ */ new Set(); + async start() { + this.opts.discovery.onDeviceFound((d) => this.handleFound(d)); + this.opts.discovery.onDeviceLost((id) => { + this.connecting.delete(id); + this.opts.onLost?.(id); + }); + await this.opts.discovery.start(); + await this.opts.discovery.advertise(this.opts.localDevice); + } + async stop() { + await this.opts.discovery.stop(); + } + handleFound(device) { + if (device.id === this.opts.localDevice.id) return; + if (this.opts.engine.isPaired(device.id)) return; + if (this.connecting.has(device.id)) return; + const secret = this.opts.getSharedSecret(device.id); + if (secret) { + this.connecting.add(device.id); + this.opts.engine.reconnect(device, secret).catch(() => void 0).finally(() => this.connecting.delete(device.id)); + } else { + this.opts.onDiscovered?.(device); + } + } +}; + +// src/oplog.ts +function wins(a, b) { + if (a.lamport !== b.lamport) return a.lamport > b.lamport; + if (a.deviceId !== b.deviceId) return a.deviceId > b.deviceId; + return a.opId > b.opId; +} +var OpLog = class { + constructor(opts) { + this.opts = opts; + for (const op of opts.persisted ?? []) { + this.ops.set(op.opId, op); + if (op.lamport > this.clock) this.clock = op.lamport; + } + } + opts; + ops = /* @__PURE__ */ new Map(); + // opId -> op + clock = 0; + /** Per-device highest lamport — what we tell a peer we already have. */ + versionVector() { + const vv = {}; + for (const op of this.ops.values()) { + if (!(op.deviceId in vv) || op.lamport > vv[op.deviceId]) vv[op.deviceId] = op.lamport; + } + return vv; + } + /** Ops the peer (described by their version vector) hasn't seen yet. */ + opsSince(peerVV) { + const out = []; + for (const op of this.ops.values()) { + if (op.lamport > (peerVV[op.deviceId] ?? 0)) out.push(op); + } + return out.sort((a, b) => a.lamport - b.lamport); + } + /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ + record(entity, entityId, kind, fields) { + const op = { + opId: this.opts.uuid(), + entity, + entityId, + kind, + fields: kind === "put" ? fields : void 0, + lamport: ++this.clock, + deviceId: this.opts.deviceId, + ts: this.opts.now() + }; + this.ops.set(op.opId, op); + this.opts.persist?.(op); + this.rematerialize(entity, entityId); + return op; + } + /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ + ingest(incoming) { + const accepted = []; + const touched = /* @__PURE__ */ new Set(); + for (const op of incoming) { + if (this.ops.has(op.opId)) continue; + this.ops.set(op.opId, op); + if (op.lamport > this.clock) this.clock = op.lamport; + this.opts.persist?.(op); + accepted.push(op); + touched.add(`${op.entity}\0${op.entityId}`); + } + for (const key of touched) { + const [entity, entityId] = key.split("\0"); + this.rematerialize(entity, entityId); + } + return accepted; + } + /** Recompute the winning op for one record and push it to the materializer. */ + rematerialize(entity, entityId) { + let winner; + for (const op of this.ops.values()) { + if (op.entity !== entity || op.entityId !== entityId) continue; + if (!winner || wins(op, winner)) winner = op; + } + if (!winner) return; + if (winner.kind === "delete") this.opts.materializer.remove(entity, entityId); + else this.opts.materializer.put(entity, entityId, winner.fields ?? {}); + } + /** Total ops held (diagnostics). */ + size() { + return this.ops.size; + } +}; + +// src/state-sync.ts +var StateSync = class { + constructor(opts) { + this.opts = opts; + } + opts; + /** A peer connected: advertise our version vector so it can backfill us; we + * backfill it when its own `have` arrives. */ + onConnect(deviceId) { + this.opts.send(deviceId, { t: "have", vv: this.opts.oplog.versionVector() }); + } + /** Inbound message on the 'state' channel from a paired peer. */ + onMessage(deviceId, data) { + const msg = data; + if (!msg || typeof msg !== "object" || !("t" in msg)) return; + if (msg.t === "have") { + const missing = this.opts.oplog.opsSince(msg.vv); + if (missing.length) this.opts.send(deviceId, { t: "ops", ops: missing }); + } else if (msg.t === "ops" && Array.isArray(msg.ops)) { + this.opts.oplog.ingest(msg.ops); + } + } +}; + +// src/index.ts +var VERSION = "0.0.1"; +var APP_NAME = "Off Grid Sync"; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + APP_NAME, + CHUNK_SIZE, + DiscoveryOrchestrator, + FRAME_HEADER_LENGTH, + FRAME_KIND_ENCRYPTED, + FRAME_KIND_PLAINTEXT, + FREE_DEVICE_CAP, + FrameBuffer, + HEADER_LENGTH, + IncrementalChecksum, + MAX_FRAME_SIZE, + MAX_MESSAGE_SIZE, + MAX_TEXT_LENGTH, + MDNS_DOMAIN, + MDNS_SERVICE_NAME, + MDNS_SERVICE_TYPE, + MESSAGE_CODE_TYPES, + MESSAGE_TYPE_CODES, + MessageBuffer, + OpLog, + PROTOCOL_VERSION, + StateSync, + SyncEngine, + TXT_DEVICE_ID, + TXT_DEVICE_NAME, + TXT_PLATFORM, + TXT_VERSION, + VERSION, + calculateChecksum, + calculateProgress, + chunkFile, + createAppMessage, + createChallengeResponse, + createDiscoveredDevice, + createEncryptedFrame, + createEncryptedTextMessage, + createErrorMessage, + createFileAccept, + createFileAcceptHttp, + createFileAck, + createFileChunk, + createFileChunkFromBase64, + createFileComplete, + createFileCompleteStreaming, + createFileReject, + createFileRequest, + createFileRequestHttp, + createFileRequestStreaming, + createFileTransfer, + createHello, + createPairChallenge, + createPairConfirm, + createPairReject, + createPairRequest, + createPairResponse, + createPairedDevice, + createPairingState, + createPingMessage, + createPongMessage, + createTextMessage, + createTextTransfer, + createTxtRecord, + decodeBase64, + decodeFrameBody, + decodeUTF8, + decrypt, + decryptMessage, + decryptTextMessage, + decryptToString, + deriveKey, + deriveSharedSecret, + deserializeMessage, + encodeBase64, + encodeEncryptedFrame, + encodePlaintextFrame, + encodeUTF8, + encrypt, + encryptMessage, + filterStaleDevices, + formatDuration, + formatEta, + formatFileSize, + formatProgressInfo, + formatTransferSpeed, + freePolicy, + generateChallenge, + generateDeviceId, + generateMessageId, + getMessageLength, + getMimeType, + getPairedDevice, + handlePairingMessage, + isDeviceStale, + isPaired, + pairingAllowed, + parseEncryptedFrame, + parseTxtRecord, + policyFor, + reassembleChunks, + removeDevice, + removePairedDevice, + serializeMessage, + updateDeviceList, + updateLastConnected, + verifyChallengeResponse, + verifyChecksum, + verifyFileIntegrity +}); diff --git a/packages/sync/dist/index.mjs b/packages/sync/dist/index.mjs new file mode 100644 index 00000000..1086c5ad --- /dev/null +++ b/packages/sync/dist/index.mjs @@ -0,0 +1,1381 @@ +import { + MDNS_DOMAIN, + MDNS_SERVICE_NAME, + MDNS_SERVICE_TYPE, + TXT_DEVICE_ID, + TXT_DEVICE_NAME, + TXT_PLATFORM, + TXT_VERSION, + createDiscoveredDevice, + createTxtRecord, + filterStaleDevices, + isDeviceStale, + parseTxtRecord, + removeDevice, + updateDeviceList +} from "./chunk-UMHRNOI2.mjs"; + +// src/crypto/index.ts +import nacl from "tweetnacl"; +import { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } from "tweetnacl-util"; +import { sha512 } from "js-sha512"; +var PBKDF2_ITERATIONS = 1e4; +var SALT_LENGTH = 16; +var KEY_LENGTH = 32; +function generateDeviceId() { + const bytes = nacl.randomBytes(16); + return encodeBase64(bytes).replace( + /[+/=]/g, + (c) => c === "+" ? "-" : c === "/" ? "_" : "" + ); +} +function generateMessageId() { + const bytes = nacl.randomBytes(8); + return encodeBase64(bytes).replace( + /[+/=]/g, + (c) => c === "+" ? "-" : c === "/" ? "_" : "" + ); +} +function deriveKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) { + const passphraseBytes = decodeUTF8(passphrase); + const combined = new Uint8Array(passphraseBytes.length + salt.length); + combined.set(passphraseBytes); + combined.set(salt, passphraseBytes.length); + let result = nacl.hash(combined); + for (let i = 1; i < iterations; i++) { + result = nacl.hash(result); + } + return result.slice(0, KEY_LENGTH); +} +function deriveSharedSecret(passphrase, deviceId1, deviceId2) { + const sortedIds = [deviceId1, deviceId2].sort(); + const saltString = `${sortedIds[0]}:${sortedIds[1]}`; + const salt = nacl.hash(decodeUTF8(saltString)).slice(0, SALT_LENGTH); + const key = deriveKey(passphrase, salt); + return encodeBase64(key); +} +function generateChallenge() { + const bytes = nacl.randomBytes(32); + return encodeBase64(bytes); +} +function createChallengeResponse(challenge, sharedSecret) { + const challengeBytes = decodeBase64(challenge); + const secretBytes = decodeBase64(sharedSecret); + const combined = new Uint8Array(challengeBytes.length + secretBytes.length); + combined.set(challengeBytes); + combined.set(secretBytes, challengeBytes.length); + const hash = nacl.hash(combined); + return encodeBase64(hash.slice(0, 32)); +} +function verifyChallengeResponse(challenge, response, sharedSecret) { + const expectedResponse = createChallengeResponse(challenge, sharedSecret); + return response === expectedResponse; +} +function encrypt(data, secretKey) { + const keyBytes = decodeBase64(secretKey); + const dataBytes = typeof data === "string" ? decodeUTF8(data) : data; + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); + const encrypted = nacl.secretbox(dataBytes, nonce, keyBytes); + return { + encrypted: encodeBase64(encrypted), + nonce: encodeBase64(nonce) + }; +} +function decrypt(encrypted, nonce, secretKey) { + const keyBytes = decodeBase64(secretKey); + const encryptedBytes = decodeBase64(encrypted); + const nonceBytes = decodeBase64(nonce); + const decrypted = nacl.secretbox.open(encryptedBytes, nonceBytes, keyBytes); + return decrypted; +} +function decryptToString(encrypted, nonce, secretKey) { + const decrypted = decrypt(encrypted, nonce, secretKey); + if (!decrypted) return null; + return encodeUTF8(decrypted); +} +function calculateChecksum(data) { + const hash = nacl.hash(data); + return encodeBase64(hash.slice(0, 16)); +} +function verifyChecksum(data, checksum) { + const calculated = calculateChecksum(data); + return calculated === checksum; +} +var IncrementalChecksum = class { + hasher; + constructor() { + this.hasher = sha512.create(); + } + /** + * Feed a chunk of data into the hash + */ + update(data) { + this.hasher.update(data); + } + /** + * Finalize and return checksum in the same format as calculateChecksum() + * (base64 of first 16 bytes of SHA-512 digest) + */ + digest() { + const hashArray = this.hasher.array(); + const first16 = new Uint8Array(hashArray.slice(0, 16)); + return encodeBase64(first16); + } +}; + +// src/pairing/index.ts +function createPairingState(localDevice) { + return { + status: "idle", + localDevice + }; +} +function createPairRequest(localDevice) { + return { + type: "pair_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + deviceInfo: localDevice + } + }; +} +function createPairChallenge() { + const challenge = generateChallenge(); + return { + type: "pair_challenge", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + challenge, + timestamp: Date.now() + } + }; +} +function createPairResponse(challenge, sharedSecret, localDevice) { + const response = createChallengeResponse(challenge, sharedSecret); + return { + type: "pair_response", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + response, + deviceInfo: localDevice + } + }; +} +function createPairConfirm(localDevice) { + return { + type: "pair_confirm", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + deviceInfo: localDevice + } + }; +} +function createPairReject(reason) { + return { + type: "pair_reject", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + reason + } + }; +} +function handlePairingMessage(state, message, passphrase) { + switch (message.type) { + case "pair_request": { + const msg = message; + const remoteDevice = msg.payload.deviceInfo; + if (!passphrase) { + return { + newState: { + ...state, + status: "waiting", + remoteDevice + } + }; + } + const sharedSecret = deriveSharedSecret( + passphrase, + state.localDevice.id, + remoteDevice.id + ); + const challengeMsg = createPairChallenge(); + return { + newState: { + ...state, + status: "verifying", + remoteDevice, + passphrase, + sharedSecret, + challenge: challengeMsg.payload.challenge + }, + response: challengeMsg + }; + } + case "pair_challenge": { + const msg = message; + if (!passphrase || !state.remoteDevice) { + return { + newState: { + ...state, + status: "failed", + error: "Missing passphrase or remote device" + } + }; + } + const sharedSecret = deriveSharedSecret( + passphrase, + state.localDevice.id, + state.remoteDevice.id + ); + const responseMsg = createPairResponse( + msg.payload.challenge, + sharedSecret, + state.localDevice + ); + return { + newState: { + ...state, + status: "verifying", + sharedSecret + }, + response: responseMsg + }; + } + case "pair_response": { + const msg = message; + if (!state.sharedSecret || !state.challenge) { + return { + newState: { + ...state, + status: "failed", + error: "Invalid pairing state" + } + }; + } + const isValid = verifyChallengeResponse( + state.challenge, + msg.payload.response, + state.sharedSecret + ); + if (isValid) { + const confirmMsg = createPairConfirm(state.localDevice); + return { + newState: { + ...state, + status: "success", + remoteDevice: msg.payload.deviceInfo + }, + response: confirmMsg + }; + } else { + const rejectMsg = createPairReject("Passphrase mismatch"); + return { + newState: { + ...state, + status: "failed", + error: "Passphrase mismatch" + }, + response: rejectMsg + }; + } + } + case "pair_confirm": { + return { + newState: { + ...state, + status: "success" + } + }; + } + case "pair_reject": { + const msg = message; + return { + newState: { + ...state, + status: "failed", + error: msg.payload.reason + } + }; + } + default: + return { newState: state }; + } +} +function createPairedDevice(state) { + if (state.status !== "success" || !state.remoteDevice || !state.sharedSecret) { + return null; + } + return { + ...state.remoteDevice, + sharedSecret: state.sharedSecret, + pairedAt: Date.now() + }; +} +function isPaired(deviceId, pairedDevices) { + return pairedDevices.some((d) => d.id === deviceId); +} +function getPairedDevice(deviceId, pairedDevices) { + return pairedDevices.find((d) => d.id === deviceId); +} +function updateLastConnected(deviceId, pairedDevices) { + return pairedDevices.map( + (d) => d.id === deviceId ? { ...d, lastConnected: Date.now() } : d + ); +} +function removePairedDevice(deviceId, pairedDevices) { + return pairedDevices.filter((d) => d.id !== deviceId); +} + +// src/transfer/index.ts +var CHUNK_SIZE = 64 * 1024; +var MAX_TEXT_LENGTH = 1024 * 1024; +function createTextTransfer(content, device, direction) { + return { + id: generateMessageId(), + type: "text", + timestamp: Date.now(), + direction, + deviceId: device.id, + deviceName: device.name, + content + }; +} +function createFileTransfer(fileName, fileSize, mimeType, device, direction, durationMs) { + const transfer = { + id: generateMessageId(), + type: "file", + timestamp: Date.now(), + direction, + deviceId: device.id, + deviceName: device.name, + fileName, + fileSize, + mimeType + }; + if (durationMs != null && durationMs > 0) { + transfer.durationMs = durationMs; + transfer.speedBytesPerSec = Math.round(fileSize / durationMs * 1e3); + } + return transfer; +} +function createTextMessage(content) { + return { + type: "text", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + content + } + }; +} +function createEncryptedTextMessage(content, secretKey) { + const { encrypted, nonce } = encrypt(content, secretKey); + return { + message: createTextMessage(encrypted), + nonce + }; +} +function decryptTextMessage(message, nonce, secretKey) { + const decrypted = decrypt(message.payload.content, nonce, secretKey); + if (!decrypted) return null; + return new TextDecoder().decode(decrypted); +} +function createFileRequest(fileName, fileSize, mimeType, fileData) { + return { + type: "file_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum: calculateChecksum(fileData) + } + }; +} +function createFileRequestStreaming(fileName, fileSize, mimeType, checksum) { + return { + type: "file_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum + } + }; +} +function createFileCompleteStreaming(requestId, checksum) { + return { + type: "file_complete", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + checksum + } + }; +} +function createFileRequestHttp(fileName, fileSize, mimeType, checksum, httpUrl) { + return { + type: "file_request", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum, + httpUrl + } + }; +} +function createFileAccept(requestId) { + return { + type: "file_accept", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId + } + }; +} +function createFileAcceptHttp(requestId, uploadUrl) { + return { + type: "file_accept", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + uploadUrl + } + }; +} +function createFileAck(requestId, success) { + return { + type: "file_ack", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + success + } + }; +} +function createFileReject(requestId, reason) { + return { + type: "file_reject", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + reason + } + }; +} +function createFileChunk(requestId, chunkIndex, totalChunks, data) { + return { + type: "file_chunk", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + chunkIndex, + totalChunks, + data: encodeBase64(data) + } + }; +} +function createFileChunkFromBase64(requestId, chunkIndex, totalChunks, base64Data) { + return { + type: "file_chunk", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + chunkIndex, + totalChunks, + data: base64Data + } + }; +} +function createFileComplete(requestId, fileData) { + return { + type: "file_complete", + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + checksum: calculateChecksum(fileData) + } + }; +} +function* chunkFile(data, chunkSize = CHUNK_SIZE) { + const totalChunks = Math.ceil(data.length / chunkSize); + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, data.length); + yield { + chunk: data.slice(start, end), + index: i, + total: totalChunks + }; + } +} +function reassembleChunks(chunks, totalChunks) { + if (chunks.size !== totalChunks) { + return null; + } + let totalSize = 0; + for (let i = 0; i < totalChunks; i++) { + const chunk = chunks.get(i); + if (!chunk) return null; + totalSize += chunk.length; + } + const result = new Uint8Array(totalSize); + let offset = 0; + for (let i = 0; i < totalChunks; i++) { + const chunk = chunks.get(i); + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} +function calculateProgress(transferId, bytesTransferred, totalBytes, currentFile, startTime) { + const clampedBytes = Math.min(bytesTransferred, totalBytes); + const result = { + transferId, + bytesTransferred: clampedBytes, + totalBytes, + percentage: totalBytes > 0 ? Math.min(100, Math.round(clampedBytes / totalBytes * 100)) : 0, + currentFile + }; + if (startTime && startTime > 0) { + const elapsedMs = Date.now() - startTime; + result.elapsedMs = elapsedMs; + if (elapsedMs > 500 && clampedBytes > 0) { + result.speedBytesPerSec = Math.round(clampedBytes / elapsedMs * 1e3); + if (result.speedBytesPerSec > 0 && clampedBytes < totalBytes) { + const remainingBytes = totalBytes - clampedBytes; + result.etaSeconds = Math.round(remainingBytes / result.speedBytesPerSec); + } + } + } + return result; +} +function verifyFileIntegrity(data, expectedChecksum) { + return verifyChecksum(data, expectedChecksum); +} +function formatFileSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} +function formatTransferSpeed(bytesPerSec) { + if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; + if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; + if (bytesPerSec < 1024 * 1024 * 1024) return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; + return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)} GB/s`; +} +function formatDuration(ms) { + if (ms < 1e3) return `${ms}ms`; + const seconds = ms / 1e3; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds.toFixed(0)}s`; +} +function formatEta(seconds) { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} +function formatProgressInfo(progress) { + const parts = []; + if (progress.speedBytesPerSec != null && progress.speedBytesPerSec > 0) { + parts.push(formatTransferSpeed(progress.speedBytesPerSec)); + } + if (progress.elapsedMs != null && progress.elapsedMs >= 1e3) { + parts.push(formatDuration(progress.elapsedMs) + " elapsed"); + } + if (progress.etaSeconds != null && progress.etaSeconds > 0) { + parts.push("~" + formatEta(progress.etaSeconds) + " left"); + } + return parts.join(" \xB7 "); +} +function getMimeType(fileName) { + const ext = fileName.split(".").pop()?.toLowerCase() || ""; + const mimeTypes = { + txt: "text/plain", + html: "text/html", + css: "text/css", + js: "application/javascript", + json: "application/json", + xml: "application/xml", + pdf: "application/pdf", + zip: "application/zip", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + mp3: "audio/mpeg", + wav: "audio/wav", + mp4: "video/mp4", + webm: "video/webm", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" + }; + return mimeTypes[ext] || "application/octet-stream"; +} + +// src/protocol/index.ts +var PROTOCOL_VERSION = "1.0.0"; +var HEADER_LENGTH = 5; +var MAX_MESSAGE_SIZE = 10 * 1024 * 1024; +var MESSAGE_TYPE_CODES = { + ping: 1, + pong: 2, + pair_request: 16, + pair_challenge: 17, + pair_response: 18, + pair_confirm: 19, + pair_reject: 20, + hello: 21, + text: 32, + file_request: 48, + file_accept: 49, + file_reject: 50, + file_chunk: 51, + file_complete: 52, + file_ack: 53, + app: 64, + error: 255 +}; +function createHello(deviceInfo) { + return { + type: "hello", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { deviceInfo } + }; +} +function createAppMessage(channel, data) { + return { + type: "app", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { channel, data } + }; +} +var MESSAGE_CODE_TYPES = Object.fromEntries( + Object.entries(MESSAGE_TYPE_CODES).map(([k, v]) => [v, k]) +); +function serializeMessage(message) { + const jsonPayload = JSON.stringify(message); + const payloadBytes = new TextEncoder().encode(jsonPayload); + const typeCode = MESSAGE_TYPE_CODES[message.type] || 255; + const buffer = new Uint8Array(HEADER_LENGTH + payloadBytes.length); + const view = new DataView(buffer.buffer); + view.setUint32(0, payloadBytes.length, false); + buffer[4] = typeCode; + buffer.set(payloadBytes, HEADER_LENGTH); + return buffer; +} +function deserializeMessage(buffer) { + if (buffer.length < HEADER_LENGTH) { + return null; + } + const view = new DataView(buffer.buffer, buffer.byteOffset); + const payloadLength = view.getUint32(0, false); + if (buffer.length < HEADER_LENGTH + payloadLength) { + return null; + } + const payloadBytes = buffer.slice(HEADER_LENGTH, HEADER_LENGTH + payloadLength); + const jsonPayload = new TextDecoder().decode(payloadBytes); + try { + return JSON.parse(jsonPayload); + } catch { + return null; + } +} +function getMessageLength(header) { + if (header.length < 4) { + return null; + } + const view = new DataView(header.buffer, header.byteOffset); + const length = view.getUint32(0, false); + if (length > MAX_MESSAGE_SIZE) { + return null; + } + return HEADER_LENGTH + length; +} +function encryptMessage(message, secretKey) { + const serialized = serializeMessage(message); + const { encrypted, nonce } = encrypt(serialized, secretKey); + return { + encrypted: decodeBase64(encrypted), + nonce + }; +} +function decryptMessage(encrypted, nonce, secretKey) { + const decrypted = decrypt(encodeBase64(encrypted), nonce, secretKey); + if (!decrypted) return null; + return deserializeMessage(decrypted); +} +function createEncryptedFrame(encrypted, nonce) { + const nonceBytes = decodeBase64(nonce); + const frame2 = new Uint8Array(1 + nonceBytes.length + encrypted.length); + frame2[0] = nonceBytes.length; + frame2.set(nonceBytes, 1); + frame2.set(encrypted, 1 + nonceBytes.length); + return frame2; +} +function parseEncryptedFrame(frame2) { + if (frame2.length < 2) return null; + const nonceLength = frame2[0]; + if (frame2.length < 1 + nonceLength) return null; + const nonceBytes = frame2.slice(1, 1 + nonceLength); + const encrypted = frame2.slice(1 + nonceLength); + return { + encrypted, + nonce: encodeBase64(nonceBytes) + }; +} +var MessageBuffer = class { + buffer = new Uint8Array(0); + /** + * Add data to the buffer + */ + append(data) { + const newBuffer = new Uint8Array(this.buffer.length + data.length); + newBuffer.set(this.buffer); + newBuffer.set(data, this.buffer.length); + this.buffer = newBuffer; + } + /** + * Try to extract a complete message from the buffer + */ + extractMessage() { + const length = getMessageLength(this.buffer); + if (length === null || this.buffer.length < length) { + return null; + } + const messageBytes = this.buffer.slice(0, length); + this.buffer = this.buffer.slice(length); + return deserializeMessage(messageBytes); + } + /** + * Extract all complete messages from the buffer + */ + extractAllMessages() { + const messages = []; + let message; + while ((message = this.extractMessage()) !== null) { + messages.push(message); + } + return messages; + } + /** + * Get current buffer size + */ + get size() { + return this.buffer.length; + } + /** + * Clear the buffer + */ + clear() { + this.buffer = new Uint8Array(0); + } +}; +function createPingMessage() { + return { + type: "ping", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now() + }; +} +function createPongMessage(pingId) { + return { + type: "pong", + id: pingId, + timestamp: Date.now() + }; +} +function createErrorMessage(code, errorMessage, originalMessageId) { + return { + type: "error", + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { + code, + message: errorMessage, + originalMessageId + } + }; +} + +// src/wire.ts +var FRAME_HEADER_LENGTH = 4; +var MAX_FRAME_SIZE = 16 * 1024 * 1024; +var FRAME_KIND_PLAINTEXT = 0; +var FRAME_KIND_ENCRYPTED = 1; +function frame(body) { + const out = new Uint8Array(FRAME_HEADER_LENGTH + body.length); + new DataView(out.buffer).setUint32(0, body.length, false); + out.set(body, FRAME_HEADER_LENGTH); + return out; +} +function encodePlaintextFrame(message) { + const json = new TextEncoder().encode(JSON.stringify(message)); + const body = new Uint8Array(1 + json.length); + body[0] = FRAME_KIND_PLAINTEXT; + body.set(json, 1); + return frame(body); +} +function encodeEncryptedFrame(message, secretKey) { + const { encrypted, nonce } = encryptMessage(message, secretKey); + const nonceBytes = decodeBase64(nonce); + const body = new Uint8Array(1 + 1 + nonceBytes.length + encrypted.length); + body[0] = FRAME_KIND_ENCRYPTED; + body[1] = nonceBytes.length; + body.set(nonceBytes, 2); + body.set(encrypted, 2 + nonceBytes.length); + return frame(body); +} +function decodeFrameBody(body, secretKey) { + if (body.length < 1) return null; + const kind = body[0]; + const payload = body.subarray(1); + if (kind === FRAME_KIND_PLAINTEXT) { + try { + const message = JSON.parse(new TextDecoder().decode(payload)); + return { kind: "plaintext", message }; + } catch { + return null; + } + } + if (kind === FRAME_KIND_ENCRYPTED) { + if (!secretKey || payload.length < 1) return null; + const nonceLen = payload[0]; + if (payload.length < 1 + nonceLen) return null; + const nonce = encodeBase64(payload.subarray(1, 1 + nonceLen)); + const encrypted = payload.subarray(1 + nonceLen); + const message = decryptMessage(encrypted, nonce, secretKey); + return message ? { kind: "encrypted", message } : null; + } + return null; +} +var FrameBuffer = class { + buffer = new Uint8Array(0); + append(data) { + const next = new Uint8Array(this.buffer.length + data.length); + next.set(this.buffer); + next.set(data, this.buffer.length); + this.buffer = next; + } + /** Pull the next complete frame body, or null if none is fully buffered. */ + nextBody() { + if (this.buffer.length < FRAME_HEADER_LENGTH) return null; + const len = new DataView( + this.buffer.buffer, + this.buffer.byteOffset + ).getUint32(0, false); + if (len > MAX_FRAME_SIZE) { + this.buffer = new Uint8Array(0); + return null; + } + if (this.buffer.length < FRAME_HEADER_LENGTH + len) return null; + const body = this.buffer.slice(FRAME_HEADER_LENGTH, FRAME_HEADER_LENGTH + len); + this.buffer = this.buffer.slice(FRAME_HEADER_LENGTH + len); + return body; + } + /** Decode all complete frames currently buffered. */ + drain(secretKey) { + const out = []; + let body; + while ((body = this.nextBody()) !== null) { + const decoded = decodeFrameBody(body, secretKey); + if (decoded) out.push(decoded); + } + return out; + } +}; + +// src/cap.ts +var FREE_DEVICE_CAP = 2; +var freePolicy = { limit: () => FREE_DEVICE_CAP }; +function policyFor(isPro, proLimit = Infinity) { + return { limit: () => isPro ? proLimit : FREE_DEVICE_CAP }; +} +function pairingAllowed(cap, deviceId) { + if (!cap) return true; + if (cap.isKnown(deviceId)) return true; + return cap.pairedCount() < cap.policy.limit(); +} + +// src/engine.ts +var PAIRING_TYPES = /* @__PURE__ */ new Set([ + "pair_request", + "pair_challenge", + "pair_response", + "pair_confirm", + "pair_reject" +]); +var PeerSession = class { + constructor(conn, engine, opts, initiateWith, resumeWith) { + this.conn = conn; + this.engine = engine; + this.opts = opts; + this.pairing = createPairingState(opts.localDevice); + if (initiateWith) { + this.passphrase = initiateWith.passphrase; + this.remoteDevice = initiateWith.remote; + this.pairing = { ...this.pairing, remoteDevice: initiateWith.remote, passphrase: initiateWith.passphrase }; + } else if (resumeWith) { + this.remoteDevice = resumeWith.remote; + this.resumeSecret = resumeWith.sharedSecret; + } + conn.onData((data) => this.onData(data)); + conn.onClose(() => this.engine._removeSession(this)); + if (initiateWith) { + this.sendPlain(createPairRequest(opts.localDevice)); + } else if (resumeWith) { + this.sendPlain(createHello(opts.localDevice)); + this.helloSent = true; + } + } + conn; + engine; + opts; + buffer = new FrameBuffer(); + pairing; + sharedSecret; + passphrase; + resumeSecret; + helloSent = false; + queue = Promise.resolve(); + remoteDevice; + get pairedSecret() { + return this.sharedSecret; + } + /** Send an application message to this peer (must be paired). */ + sendMessage(message) { + if (!this.sharedSecret) return false; + this.conn.send(encodeEncryptedFrame(message, this.sharedSecret)); + return true; + } + sendPlain(message) { + this.conn.send(encodePlaintextFrame(message)); + } + onData(data) { + this.buffer.append(data); + const frames = this.buffer.drain(this.sharedSecret); + for (const f of frames) { + this.queue = this.queue.then(() => this.route(f.message)); + } + } + async route(message) { + if (message.type === "hello") { + this.handleHello(message); + return; + } + if (PAIRING_TYPES.has(message.type)) { + await this.handlePairing(message); + return; + } + if (this.sharedSecret && this.remoteDevice) { + if (message.type === "app") { + const p = message.payload; + this.opts.onAppMessage?.(this.remoteDevice.id, p.channel, p.data); + } else { + this.opts.onMessage?.(this.remoteDevice.id, message); + } + } + } + /** Resume an already-paired device using the stored secret (no handshake). */ + handleHello(message) { + const remote = message.payload.deviceInfo; + this.remoteDevice = remote; + const secret = this.resumeSecret ?? this.opts.getSharedSecret?.(remote.id); + if (!secret) { + this.opts.onPairingFailed?.(remote, "unknown_device"); + this.conn.close(); + return; + } + this.sharedSecret = secret; + if (!this.helloSent) { + this.sendPlain(createHello(this.opts.localDevice)); + this.helloSent = true; + } + const paired = { ...remote, sharedSecret: secret, pairedAt: Date.now() }; + this.engine._registerPaired(remote.id, this); + this.opts.onPaired?.(paired); + } + async handlePairing(message) { + if (message.type === "pair_request" && this.passphrase == null) { + const remote = message.payload.deviceInfo; + this.remoteDevice = remote; + if (!pairingAllowed(this.opts.cap, remote.id)) { + this.sendPlain(createPairReject("device limit reached")); + this.opts.onPairingFailed?.(remote, "device_cap_reached"); + this.conn.close(); + return; + } + const pass = await this.opts.getPassphrase?.(remote); + if (pass == null) { + this.opts.onPairingFailed?.(remote, "pairing refused"); + this.conn.close(); + return; + } + this.passphrase = pass; + } + const { newState, response } = handlePairingMessage(this.pairing, message, this.passphrase); + this.pairing = newState; + if (newState.sharedSecret) this.sharedSecret = newState.sharedSecret; + if (newState.remoteDevice) this.remoteDevice = newState.remoteDevice; + if (response) this.sendPlain(response); + if (newState.status === "success") { + const paired = createPairedDevice(newState); + if (paired) { + this.engine._registerPaired(paired.id, this); + this.opts.onPaired?.(paired); + } + } else if (newState.status === "failed") { + this.opts.onPairingFailed?.(this.remoteDevice, newState.error ?? "pairing failed"); + } + } +}; +var SyncEngine = class { + constructor(opts) { + this.opts = opts; + } + opts; + sessions = /* @__PURE__ */ new Set(); + paired = /* @__PURE__ */ new Map(); + /** Start accepting inbound connections on `port`. */ + async start(port) { + await this.opts.transport.listen(port, (conn) => { + this.sessions.add(new PeerSession(conn, this, this.opts)); + }); + } + /** Dial a discovered device and begin pairing with `passphrase`. Refuses if + * pairing a new device would exceed the device cap. */ + async pair(device, passphrase) { + if (!pairingAllowed(this.opts.cap, device.id)) { + this.opts.onPairingFailed?.(device, "device_cap_reached"); + return; + } + const conn = await this.opts.transport.connect(device.host, device.port); + const session = new PeerSession(conn, this, this.opts, { remote: device, passphrase }); + this.sessions.add(session); + } + /** Reconnect to an already-paired device using its stored shared secret, + * skipping the pairing handshake. Used for auto-reconnect on discovery. */ + async reconnect(device, sharedSecret) { + const conn = await this.opts.transport.connect(device.host, device.port); + const session = new PeerSession(conn, this, this.opts, void 0, { remote: device, sharedSecret }); + this.sessions.add(session); + } + /** Send an application message to an already-paired device. */ + send(deviceId, message) { + return this.paired.get(deviceId)?.sendMessage(message) ?? false; + } + /** Send a generic app-channel message (encrypted) to a paired device. */ + sendApp(deviceId, channel, data) { + return this.send(deviceId, createAppMessage(channel, data)); + } + isPaired(deviceId) { + return this.paired.has(deviceId); + } + async stop() { + for (const s of this.sessions) s.conn.close(); + this.sessions.clear(); + this.paired.clear(); + await this.opts.transport.stop(); + } + /** @internal */ + _registerPaired(deviceId, session) { + this.paired.set(deviceId, session); + } + /** @internal */ + _removeSession(session) { + this.sessions.delete(session); + for (const [id, s] of this.paired) { + if (s === session) this.paired.delete(id); + } + } +}; + +// src/orchestrator.ts +var DiscoveryOrchestrator = class { + constructor(opts) { + this.opts = opts; + } + opts; + connecting = /* @__PURE__ */ new Set(); + async start() { + this.opts.discovery.onDeviceFound((d) => this.handleFound(d)); + this.opts.discovery.onDeviceLost((id) => { + this.connecting.delete(id); + this.opts.onLost?.(id); + }); + await this.opts.discovery.start(); + await this.opts.discovery.advertise(this.opts.localDevice); + } + async stop() { + await this.opts.discovery.stop(); + } + handleFound(device) { + if (device.id === this.opts.localDevice.id) return; + if (this.opts.engine.isPaired(device.id)) return; + if (this.connecting.has(device.id)) return; + const secret = this.opts.getSharedSecret(device.id); + if (secret) { + this.connecting.add(device.id); + this.opts.engine.reconnect(device, secret).catch(() => void 0).finally(() => this.connecting.delete(device.id)); + } else { + this.opts.onDiscovered?.(device); + } + } +}; + +// src/oplog.ts +function wins(a, b) { + if (a.lamport !== b.lamport) return a.lamport > b.lamport; + if (a.deviceId !== b.deviceId) return a.deviceId > b.deviceId; + return a.opId > b.opId; +} +var OpLog = class { + constructor(opts) { + this.opts = opts; + for (const op of opts.persisted ?? []) { + this.ops.set(op.opId, op); + if (op.lamport > this.clock) this.clock = op.lamport; + } + } + opts; + ops = /* @__PURE__ */ new Map(); + // opId -> op + clock = 0; + /** Per-device highest lamport — what we tell a peer we already have. */ + versionVector() { + const vv = {}; + for (const op of this.ops.values()) { + if (!(op.deviceId in vv) || op.lamport > vv[op.deviceId]) vv[op.deviceId] = op.lamport; + } + return vv; + } + /** Ops the peer (described by their version vector) hasn't seen yet. */ + opsSince(peerVV) { + const out = []; + for (const op of this.ops.values()) { + if (op.lamport > (peerVV[op.deviceId] ?? 0)) out.push(op); + } + return out.sort((a, b) => a.lamport - b.lamport); + } + /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ + record(entity, entityId, kind, fields) { + const op = { + opId: this.opts.uuid(), + entity, + entityId, + kind, + fields: kind === "put" ? fields : void 0, + lamport: ++this.clock, + deviceId: this.opts.deviceId, + ts: this.opts.now() + }; + this.ops.set(op.opId, op); + this.opts.persist?.(op); + this.rematerialize(entity, entityId); + return op; + } + /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ + ingest(incoming) { + const accepted = []; + const touched = /* @__PURE__ */ new Set(); + for (const op of incoming) { + if (this.ops.has(op.opId)) continue; + this.ops.set(op.opId, op); + if (op.lamport > this.clock) this.clock = op.lamport; + this.opts.persist?.(op); + accepted.push(op); + touched.add(`${op.entity}\0${op.entityId}`); + } + for (const key of touched) { + const [entity, entityId] = key.split("\0"); + this.rematerialize(entity, entityId); + } + return accepted; + } + /** Recompute the winning op for one record and push it to the materializer. */ + rematerialize(entity, entityId) { + let winner; + for (const op of this.ops.values()) { + if (op.entity !== entity || op.entityId !== entityId) continue; + if (!winner || wins(op, winner)) winner = op; + } + if (!winner) return; + if (winner.kind === "delete") this.opts.materializer.remove(entity, entityId); + else this.opts.materializer.put(entity, entityId, winner.fields ?? {}); + } + /** Total ops held (diagnostics). */ + size() { + return this.ops.size; + } +}; + +// src/state-sync.ts +var StateSync = class { + constructor(opts) { + this.opts = opts; + } + opts; + /** A peer connected: advertise our version vector so it can backfill us; we + * backfill it when its own `have` arrives. */ + onConnect(deviceId) { + this.opts.send(deviceId, { t: "have", vv: this.opts.oplog.versionVector() }); + } + /** Inbound message on the 'state' channel from a paired peer. */ + onMessage(deviceId, data) { + const msg = data; + if (!msg || typeof msg !== "object" || !("t" in msg)) return; + if (msg.t === "have") { + const missing = this.opts.oplog.opsSince(msg.vv); + if (missing.length) this.opts.send(deviceId, { t: "ops", ops: missing }); + } else if (msg.t === "ops" && Array.isArray(msg.ops)) { + this.opts.oplog.ingest(msg.ops); + } + } +}; + +// src/index.ts +var VERSION = "0.0.1"; +var APP_NAME = "Off Grid Sync"; +export { + APP_NAME, + CHUNK_SIZE, + DiscoveryOrchestrator, + FRAME_HEADER_LENGTH, + FRAME_KIND_ENCRYPTED, + FRAME_KIND_PLAINTEXT, + FREE_DEVICE_CAP, + FrameBuffer, + HEADER_LENGTH, + IncrementalChecksum, + MAX_FRAME_SIZE, + MAX_MESSAGE_SIZE, + MAX_TEXT_LENGTH, + MDNS_DOMAIN, + MDNS_SERVICE_NAME, + MDNS_SERVICE_TYPE, + MESSAGE_CODE_TYPES, + MESSAGE_TYPE_CODES, + MessageBuffer, + OpLog, + PROTOCOL_VERSION, + StateSync, + SyncEngine, + TXT_DEVICE_ID, + TXT_DEVICE_NAME, + TXT_PLATFORM, + TXT_VERSION, + VERSION, + calculateChecksum, + calculateProgress, + chunkFile, + createAppMessage, + createChallengeResponse, + createDiscoveredDevice, + createEncryptedFrame, + createEncryptedTextMessage, + createErrorMessage, + createFileAccept, + createFileAcceptHttp, + createFileAck, + createFileChunk, + createFileChunkFromBase64, + createFileComplete, + createFileCompleteStreaming, + createFileReject, + createFileRequest, + createFileRequestHttp, + createFileRequestStreaming, + createFileTransfer, + createHello, + createPairChallenge, + createPairConfirm, + createPairReject, + createPairRequest, + createPairResponse, + createPairedDevice, + createPairingState, + createPingMessage, + createPongMessage, + createTextMessage, + createTextTransfer, + createTxtRecord, + decodeBase64, + decodeFrameBody, + decodeUTF8, + decrypt, + decryptMessage, + decryptTextMessage, + decryptToString, + deriveKey, + deriveSharedSecret, + deserializeMessage, + encodeBase64, + encodeEncryptedFrame, + encodePlaintextFrame, + encodeUTF8, + encrypt, + encryptMessage, + filterStaleDevices, + formatDuration, + formatEta, + formatFileSize, + formatProgressInfo, + formatTransferSpeed, + freePolicy, + generateChallenge, + generateDeviceId, + generateMessageId, + getMessageLength, + getMimeType, + getPairedDevice, + handlePairingMessage, + isDeviceStale, + isPaired, + pairingAllowed, + parseEncryptedFrame, + parseTxtRecord, + policyFor, + reassembleChunks, + removeDevice, + removePairedDevice, + serializeMessage, + updateDeviceList, + updateLastConnected, + verifyChallengeResponse, + verifyChecksum, + verifyFileIntegrity +}; diff --git a/packages/sync/dist/portable/index.d.mts b/packages/sync/dist/portable/index.d.mts new file mode 100644 index 00000000..d7dba766 --- /dev/null +++ b/packages/sync/dist/portable/index.d.mts @@ -0,0 +1,165 @@ +/** Stable format discriminator. A file whose `format` differs is rejected. */ +declare const BUNDLE_FORMAT: "offgrid-backup"; +/** Envelope version. Bump when the envelope shape changes incompatibly. */ +declare const BUNDLE_VERSION: 1; +/** Anything mergeable by stable id (projects, conversations, images, ...). */ +interface HasId { + id: string; +} +interface MergeResult { + /** existing followed by the newly-added items, in incoming order. */ + merged: T[]; + /** ids that were actually added (not already present). */ + addedIds: string[]; +} +/** + * A portable bundle: a stable header plus an app-defined `data` payload. + * `T` is the app's payload shape. The header is identical across apps so a + * bundle produced by one surface is recognizable by another. + */ +interface PortableBundle { + format: typeof BUNDLE_FORMAT; + version: number; + /** ISO timestamp of export. */ + exportedAt: string; + data: T; +} +/** Thrown when a file is not a valid/compatible bundle. Message is user-facing. */ +declare class BundleError extends Error { + constructor(message: string); +} + +/** + * Additive, non-destructive merge by id — the single import rule for every + * record type. Incoming items whose id is not already present (and not + * duplicated within the incoming batch itself) are appended; existing ids are + * left untouched. It NEVER deletes or overwrites, so importing a backup can + * only ever add what is missing. Defined once here and reused by every store's + * import path so the semantics can never drift between record types. + */ +declare function mergeById(existing: T[], incoming: T[]): MergeResult; + +interface CreateBundleInput { + data: T; + /** ISO timestamp — the caller supplies the clock; this code takes none. */ + exportedAt: string; + /** Defaults to the current BUNDLE_VERSION. */ + version?: number; +} +/** Assemble a versioned bundle around an app payload. Pure. */ +declare function createBundle(input: CreateBundleInput): PortableBundle; +/** Serialize a bundle to the JSON text written to a file / sent over the wire. */ +declare function serializeBundle(bundle: PortableBundle): string; +interface ParseBundleOptions { + /** Expected envelope version; a mismatch throws a user-facing BundleError. */ + expectedVersion?: number; + /** + * App payload validator. Receives the raw `data` and returns the typed + * payload (or throws a BundleError with a user-facing message). The shared + * core validates only the envelope; payload shape is the app's business. + */ + validateData: (data: unknown) => T; +} +/** + * Parse + validate bundle JSON. Checks the format discriminator and version — + * the shared envelope contract — then hands the payload to the app-supplied + * validator. Throws BundleError with a user-facing message on any problem. + */ +declare function parseBundle(raw: string, opts: ParseBundleOptions): PortableBundle; + +/** A file the payload points at, paired with the bundle-relative key it travels under. */ +interface FileRef { + /** Path INSIDE the bundle, e.g. "files/img-0.png". */ + key: string; + /** On-device absolute path/uri to read from on export (or read back on import). */ + sourcePath: string; +} +/** + * Pure mapping between a payload's on-device file paths and bundle-relative keys. + * The app implements this because only it knows which fields carry file paths; + * it stays pure (no I/O) so it is unit-testable. `extract` (export) lists the + * files and returns a copy of the payload with paths replaced by keys; `listKeys` + * reads the keys back out of a keyed payload (import); `restore` swaps keys for + * the real restored paths. + */ +interface FileMapper { + extract(data: T): { + files: FileRef[]; + keyed: T; + }; + listKeys(keyed: T): string[]; + restore(keyed: T, keyToPath: Record): T; +} +/** + * Host filesystem + archive I/O. All the platform-specific, on-device work of + * assembling a zip and reading it back. Absolute paths throughout. + */ +interface ArchivePort { + /** A fresh empty directory to assemble a bundle in. */ + stageDir(): Promise; + writeText(absPath: string, text: string): Promise; + readText(absPath: string): Promise; + /** Copy a source file to an absolute dest path, creating parent dirs. */ + copyInto(srcPath: string, destAbsPath: string): Promise; + /** Zip the CONTENTS of stageDir into an archive; return its path. */ + pack(stageDir: string, suggestedName: string): Promise; + /** Unzip an archive into a fresh dir; return that dir. */ + unpack(archivePath: string): Promise; + /** The permanent on-device path a restored file with this key should live at. */ + restorePathFor(key: string): string; + join(...parts: string[]): string; +} +/** + * Host access to the app's data. Every store / SQLite read and every additive + * write lives behind this port. `T` = the app's payload shape; `S` = its restore + * summary. + */ +interface BackupDataPort { + collectAll(): Promise; + collectProject(projectId: string): Promise; + collectConversation(conversationId: string): Promise; + validate(data: unknown): T; + apply(data: T): Promise; +} +/** Host sink: how the finished bundle FILE leaves the device and how one is picked back. */ +interface BackupSink { + /** Hand a finished bundle file (already written at absPath) to the user. */ + deliverFile(absPath: string, suggestedName: string): Promise; + /** Pick a bundle file; return a readable local path to it, or null if cancelled. */ + pickFile(): Promise; +} +/** Turn an ISO timestamp into a filename-safe stamp. Pure. */ +declare const fileStamp: (iso: string) => string; +/** The name of the envelope entry inside every bundle zip. */ +declare const ENVELOPE_ENTRY = "backup.json"; +/** + * The engine. Constructed with the four ports + an injected clock (`now`) so the + * core stays free of `Date`. Export assembles a zip (envelope + files) and + * delivers it; import unpacks a zip, restores files, and applies additively. + */ +declare class BackupEngine { + private readonly data; + private readonly files; + private readonly archive; + private readonly sink; + private readonly now; + constructor(data: BackupDataPort, files: FileMapper, archive: ArchivePort, sink: BackupSink, now: () => string); + private exportBundle; + /** Export everything. */ + exportAll: () => Promise; + /** Export one project (its chats + knowledge base). Null if the project is gone. */ + exportProject: (projectId: string) => Promise; + /** Export one conversation, self-contained. Null if the conversation is gone. */ + exportConversation: (conversationId: string) => Promise; + /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ + import(): Promise; + /** + * Restore + apply a bundle at a known local path, WITHOUT the picker. This is + * the receiver side of device-to-device sharing: a peer pushes a bundle file, + * the transport saves it locally, and this applies it — the same unpack → + * restore-files → rewrite → apply flow `import()` uses after picking. + */ + importPath(archivePath: string): Promise; +} + +export { type ArchivePort, BUNDLE_FORMAT, BUNDLE_VERSION, type BackupDataPort, BackupEngine, type BackupSink, BundleError, type CreateBundleInput, ENVELOPE_ENTRY, type FileMapper, type FileRef, type HasId, type MergeResult, type ParseBundleOptions, type PortableBundle, createBundle, fileStamp, mergeById, parseBundle, serializeBundle }; diff --git a/packages/sync/dist/portable/index.d.ts b/packages/sync/dist/portable/index.d.ts new file mode 100644 index 00000000..d7dba766 --- /dev/null +++ b/packages/sync/dist/portable/index.d.ts @@ -0,0 +1,165 @@ +/** Stable format discriminator. A file whose `format` differs is rejected. */ +declare const BUNDLE_FORMAT: "offgrid-backup"; +/** Envelope version. Bump when the envelope shape changes incompatibly. */ +declare const BUNDLE_VERSION: 1; +/** Anything mergeable by stable id (projects, conversations, images, ...). */ +interface HasId { + id: string; +} +interface MergeResult { + /** existing followed by the newly-added items, in incoming order. */ + merged: T[]; + /** ids that were actually added (not already present). */ + addedIds: string[]; +} +/** + * A portable bundle: a stable header plus an app-defined `data` payload. + * `T` is the app's payload shape. The header is identical across apps so a + * bundle produced by one surface is recognizable by another. + */ +interface PortableBundle { + format: typeof BUNDLE_FORMAT; + version: number; + /** ISO timestamp of export. */ + exportedAt: string; + data: T; +} +/** Thrown when a file is not a valid/compatible bundle. Message is user-facing. */ +declare class BundleError extends Error { + constructor(message: string); +} + +/** + * Additive, non-destructive merge by id — the single import rule for every + * record type. Incoming items whose id is not already present (and not + * duplicated within the incoming batch itself) are appended; existing ids are + * left untouched. It NEVER deletes or overwrites, so importing a backup can + * only ever add what is missing. Defined once here and reused by every store's + * import path so the semantics can never drift between record types. + */ +declare function mergeById(existing: T[], incoming: T[]): MergeResult; + +interface CreateBundleInput { + data: T; + /** ISO timestamp — the caller supplies the clock; this code takes none. */ + exportedAt: string; + /** Defaults to the current BUNDLE_VERSION. */ + version?: number; +} +/** Assemble a versioned bundle around an app payload. Pure. */ +declare function createBundle(input: CreateBundleInput): PortableBundle; +/** Serialize a bundle to the JSON text written to a file / sent over the wire. */ +declare function serializeBundle(bundle: PortableBundle): string; +interface ParseBundleOptions { + /** Expected envelope version; a mismatch throws a user-facing BundleError. */ + expectedVersion?: number; + /** + * App payload validator. Receives the raw `data` and returns the typed + * payload (or throws a BundleError with a user-facing message). The shared + * core validates only the envelope; payload shape is the app's business. + */ + validateData: (data: unknown) => T; +} +/** + * Parse + validate bundle JSON. Checks the format discriminator and version — + * the shared envelope contract — then hands the payload to the app-supplied + * validator. Throws BundleError with a user-facing message on any problem. + */ +declare function parseBundle(raw: string, opts: ParseBundleOptions): PortableBundle; + +/** A file the payload points at, paired with the bundle-relative key it travels under. */ +interface FileRef { + /** Path INSIDE the bundle, e.g. "files/img-0.png". */ + key: string; + /** On-device absolute path/uri to read from on export (or read back on import). */ + sourcePath: string; +} +/** + * Pure mapping between a payload's on-device file paths and bundle-relative keys. + * The app implements this because only it knows which fields carry file paths; + * it stays pure (no I/O) so it is unit-testable. `extract` (export) lists the + * files and returns a copy of the payload with paths replaced by keys; `listKeys` + * reads the keys back out of a keyed payload (import); `restore` swaps keys for + * the real restored paths. + */ +interface FileMapper { + extract(data: T): { + files: FileRef[]; + keyed: T; + }; + listKeys(keyed: T): string[]; + restore(keyed: T, keyToPath: Record): T; +} +/** + * Host filesystem + archive I/O. All the platform-specific, on-device work of + * assembling a zip and reading it back. Absolute paths throughout. + */ +interface ArchivePort { + /** A fresh empty directory to assemble a bundle in. */ + stageDir(): Promise; + writeText(absPath: string, text: string): Promise; + readText(absPath: string): Promise; + /** Copy a source file to an absolute dest path, creating parent dirs. */ + copyInto(srcPath: string, destAbsPath: string): Promise; + /** Zip the CONTENTS of stageDir into an archive; return its path. */ + pack(stageDir: string, suggestedName: string): Promise; + /** Unzip an archive into a fresh dir; return that dir. */ + unpack(archivePath: string): Promise; + /** The permanent on-device path a restored file with this key should live at. */ + restorePathFor(key: string): string; + join(...parts: string[]): string; +} +/** + * Host access to the app's data. Every store / SQLite read and every additive + * write lives behind this port. `T` = the app's payload shape; `S` = its restore + * summary. + */ +interface BackupDataPort { + collectAll(): Promise; + collectProject(projectId: string): Promise; + collectConversation(conversationId: string): Promise; + validate(data: unknown): T; + apply(data: T): Promise; +} +/** Host sink: how the finished bundle FILE leaves the device and how one is picked back. */ +interface BackupSink { + /** Hand a finished bundle file (already written at absPath) to the user. */ + deliverFile(absPath: string, suggestedName: string): Promise; + /** Pick a bundle file; return a readable local path to it, or null if cancelled. */ + pickFile(): Promise; +} +/** Turn an ISO timestamp into a filename-safe stamp. Pure. */ +declare const fileStamp: (iso: string) => string; +/** The name of the envelope entry inside every bundle zip. */ +declare const ENVELOPE_ENTRY = "backup.json"; +/** + * The engine. Constructed with the four ports + an injected clock (`now`) so the + * core stays free of `Date`. Export assembles a zip (envelope + files) and + * delivers it; import unpacks a zip, restores files, and applies additively. + */ +declare class BackupEngine { + private readonly data; + private readonly files; + private readonly archive; + private readonly sink; + private readonly now; + constructor(data: BackupDataPort, files: FileMapper, archive: ArchivePort, sink: BackupSink, now: () => string); + private exportBundle; + /** Export everything. */ + exportAll: () => Promise; + /** Export one project (its chats + knowledge base). Null if the project is gone. */ + exportProject: (projectId: string) => Promise; + /** Export one conversation, self-contained. Null if the conversation is gone. */ + exportConversation: (conversationId: string) => Promise; + /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ + import(): Promise; + /** + * Restore + apply a bundle at a known local path, WITHOUT the picker. This is + * the receiver side of device-to-device sharing: a peer pushes a bundle file, + * the transport saves it locally, and this applies it — the same unpack → + * restore-files → rewrite → apply flow `import()` uses after picking. + */ + importPath(archivePath: string): Promise; +} + +export { type ArchivePort, BUNDLE_FORMAT, BUNDLE_VERSION, type BackupDataPort, BackupEngine, type BackupSink, BundleError, type CreateBundleInput, ENVELOPE_ENTRY, type FileMapper, type FileRef, type HasId, type MergeResult, type ParseBundleOptions, type PortableBundle, createBundle, fileStamp, mergeById, parseBundle, serializeBundle }; diff --git a/packages/sync/dist/portable/index.js b/packages/sync/dist/portable/index.js new file mode 100644 index 00000000..d511b083 --- /dev/null +++ b/packages/sync/dist/portable/index.js @@ -0,0 +1,181 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/portable/index.ts +var portable_exports = {}; +__export(portable_exports, { + BUNDLE_FORMAT: () => BUNDLE_FORMAT, + BUNDLE_VERSION: () => BUNDLE_VERSION, + BackupEngine: () => BackupEngine, + BundleError: () => BundleError, + ENVELOPE_ENTRY: () => ENVELOPE_ENTRY, + createBundle: () => createBundle, + fileStamp: () => fileStamp, + mergeById: () => mergeById, + parseBundle: () => parseBundle, + serializeBundle: () => serializeBundle +}); +module.exports = __toCommonJS(portable_exports); + +// src/portable/types.ts +var BUNDLE_FORMAT = "offgrid-backup"; +var BUNDLE_VERSION = 1; +var BundleError = class extends Error { + constructor(message) { + super(message); + this.name = "BundleError"; + } +}; + +// src/portable/merge.ts +function mergeById(existing, incoming) { + const existingIds = new Set(existing.map((item) => item.id)); + const additions = []; + const addedIds = []; + const seenIncoming = /* @__PURE__ */ new Set(); + for (const item of incoming) { + if (existingIds.has(item.id) || seenIncoming.has(item.id)) continue; + seenIncoming.add(item.id); + additions.push(item); + addedIds.push(item.id); + } + return { merged: [...existing, ...additions], addedIds }; +} + +// src/portable/bundle.ts +function createBundle(input) { + return { + format: BUNDLE_FORMAT, + version: input.version ?? BUNDLE_VERSION, + exportedAt: input.exportedAt, + data: input.data + }; +} +function serializeBundle(bundle) { + return JSON.stringify(bundle, null, 2); +} +function isObject(value) { + return typeof value === "object" && value !== null; +} +function parseBundle(raw, opts) { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new BundleError("This file is not a valid backup (could not read it as JSON)."); + } + if (!isObject(parsed)) { + throw new BundleError("This file is not a valid Off Grid backup."); + } + if (parsed.format !== BUNDLE_FORMAT) { + throw new BundleError("This file is not an Off Grid backup."); + } + const expected = opts.expectedVersion ?? BUNDLE_VERSION; + if (parsed.version !== expected) { + throw new BundleError( + `This backup was made by a different app version (backup v${String(parsed.version)}, expected v${expected}).` + ); + } + const data = opts.validateData(parsed.data); + return { + format: BUNDLE_FORMAT, + version: expected, + exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "", + data + }; +} + +// src/portable/engine.ts +var fileStamp = (iso) => iso.replaceAll(/[:.]/g, "-"); +var ENVELOPE_ENTRY = "backup.json"; +var BackupEngine = class { + constructor(data, files, archive, sink, now) { + this.data = data; + this.files = files; + this.archive = archive; + this.sink = sink; + this.now = now; + } + data; + files; + archive; + sink; + now; + async exportBundle(prefix, payload) { + if (payload == null) return null; + const { files: refs, keyed } = this.files.extract(payload); + const exportedAt = this.now(); + const stage = await this.archive.stageDir(); + await this.archive.writeText( + this.archive.join(stage, ENVELOPE_ENTRY), + serializeBundle(createBundle({ data: keyed, exportedAt })) + ); + for (const ref of refs) { + await this.archive.copyInto(ref.sourcePath, this.archive.join(stage, ref.key)); + } + const name = `${prefix}-${fileStamp(exportedAt)}.zip`; + const zipPath = await this.archive.pack(stage, name); + return this.sink.deliverFile(zipPath, name); + } + /** Export everything. */ + exportAll = () => this.data.collectAll().then((d) => this.exportBundle("offgrid-backup", d)); + /** Export one project (its chats + knowledge base). Null if the project is gone. */ + exportProject = (projectId) => this.data.collectProject(projectId).then((d) => this.exportBundle("offgrid-project", d)); + /** Export one conversation, self-contained. Null if the conversation is gone. */ + exportConversation = (conversationId) => this.data.collectConversation(conversationId).then((d) => this.exportBundle("offgrid-chat", d)); + /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ + async import() { + const picked = await this.sink.pickFile(); + if (picked == null) return null; + return this.importPath(picked); + } + /** + * Restore + apply a bundle at a known local path, WITHOUT the picker. This is + * the receiver side of device-to-device sharing: a peer pushes a bundle file, + * the transport saves it locally, and this applies it — the same unpack → + * restore-files → rewrite → apply flow `import()` uses after picking. + */ + async importPath(archivePath) { + const dir = await this.archive.unpack(archivePath); + const raw = await this.archive.readText(this.archive.join(dir, ENVELOPE_ENTRY)); + const bundle = parseBundle(raw, { validateData: (d) => this.data.validate(d) }); + const keyed = bundle.data; + const keyToPath = {}; + for (const key of this.files.listKeys(keyed)) { + const dest = this.archive.restorePathFor(key); + await this.archive.copyInto(this.archive.join(dir, key), dest); + keyToPath[key] = dest; + } + const restored = this.files.restore(keyed, keyToPath); + return this.data.apply(restored); + } +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + BUNDLE_FORMAT, + BUNDLE_VERSION, + BackupEngine, + BundleError, + ENVELOPE_ENTRY, + createBundle, + fileStamp, + mergeById, + parseBundle, + serializeBundle +}); diff --git a/packages/sync/dist/portable/index.mjs b/packages/sync/dist/portable/index.mjs new file mode 100644 index 00000000..77206fc1 --- /dev/null +++ b/packages/sync/dist/portable/index.mjs @@ -0,0 +1,145 @@ +// src/portable/types.ts +var BUNDLE_FORMAT = "offgrid-backup"; +var BUNDLE_VERSION = 1; +var BundleError = class extends Error { + constructor(message) { + super(message); + this.name = "BundleError"; + } +}; + +// src/portable/merge.ts +function mergeById(existing, incoming) { + const existingIds = new Set(existing.map((item) => item.id)); + const additions = []; + const addedIds = []; + const seenIncoming = /* @__PURE__ */ new Set(); + for (const item of incoming) { + if (existingIds.has(item.id) || seenIncoming.has(item.id)) continue; + seenIncoming.add(item.id); + additions.push(item); + addedIds.push(item.id); + } + return { merged: [...existing, ...additions], addedIds }; +} + +// src/portable/bundle.ts +function createBundle(input) { + return { + format: BUNDLE_FORMAT, + version: input.version ?? BUNDLE_VERSION, + exportedAt: input.exportedAt, + data: input.data + }; +} +function serializeBundle(bundle) { + return JSON.stringify(bundle, null, 2); +} +function isObject(value) { + return typeof value === "object" && value !== null; +} +function parseBundle(raw, opts) { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new BundleError("This file is not a valid backup (could not read it as JSON)."); + } + if (!isObject(parsed)) { + throw new BundleError("This file is not a valid Off Grid backup."); + } + if (parsed.format !== BUNDLE_FORMAT) { + throw new BundleError("This file is not an Off Grid backup."); + } + const expected = opts.expectedVersion ?? BUNDLE_VERSION; + if (parsed.version !== expected) { + throw new BundleError( + `This backup was made by a different app version (backup v${String(parsed.version)}, expected v${expected}).` + ); + } + const data = opts.validateData(parsed.data); + return { + format: BUNDLE_FORMAT, + version: expected, + exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "", + data + }; +} + +// src/portable/engine.ts +var fileStamp = (iso) => iso.replaceAll(/[:.]/g, "-"); +var ENVELOPE_ENTRY = "backup.json"; +var BackupEngine = class { + constructor(data, files, archive, sink, now) { + this.data = data; + this.files = files; + this.archive = archive; + this.sink = sink; + this.now = now; + } + data; + files; + archive; + sink; + now; + async exportBundle(prefix, payload) { + if (payload == null) return null; + const { files: refs, keyed } = this.files.extract(payload); + const exportedAt = this.now(); + const stage = await this.archive.stageDir(); + await this.archive.writeText( + this.archive.join(stage, ENVELOPE_ENTRY), + serializeBundle(createBundle({ data: keyed, exportedAt })) + ); + for (const ref of refs) { + await this.archive.copyInto(ref.sourcePath, this.archive.join(stage, ref.key)); + } + const name = `${prefix}-${fileStamp(exportedAt)}.zip`; + const zipPath = await this.archive.pack(stage, name); + return this.sink.deliverFile(zipPath, name); + } + /** Export everything. */ + exportAll = () => this.data.collectAll().then((d) => this.exportBundle("offgrid-backup", d)); + /** Export one project (its chats + knowledge base). Null if the project is gone. */ + exportProject = (projectId) => this.data.collectProject(projectId).then((d) => this.exportBundle("offgrid-project", d)); + /** Export one conversation, self-contained. Null if the conversation is gone. */ + exportConversation = (conversationId) => this.data.collectConversation(conversationId).then((d) => this.exportBundle("offgrid-chat", d)); + /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ + async import() { + const picked = await this.sink.pickFile(); + if (picked == null) return null; + return this.importPath(picked); + } + /** + * Restore + apply a bundle at a known local path, WITHOUT the picker. This is + * the receiver side of device-to-device sharing: a peer pushes a bundle file, + * the transport saves it locally, and this applies it — the same unpack → + * restore-files → rewrite → apply flow `import()` uses after picking. + */ + async importPath(archivePath) { + const dir = await this.archive.unpack(archivePath); + const raw = await this.archive.readText(this.archive.join(dir, ENVELOPE_ENTRY)); + const bundle = parseBundle(raw, { validateData: (d) => this.data.validate(d) }); + const keyed = bundle.data; + const keyToPath = {}; + for (const key of this.files.listKeys(keyed)) { + const dest = this.archive.restorePathFor(key); + await this.archive.copyInto(this.archive.join(dir, key), dest); + keyToPath[key] = dest; + } + const restored = this.files.restore(keyed, keyToPath); + return this.data.apply(restored); + } +}; +export { + BUNDLE_FORMAT, + BUNDLE_VERSION, + BackupEngine, + BundleError, + ENVELOPE_ENTRY, + createBundle, + fileStamp, + mergeById, + parseBundle, + serializeBundle +}; diff --git a/packages/sync/dist/transport-1cXLtrs5.d.mts b/packages/sync/dist/transport-1cXLtrs5.d.mts new file mode 100644 index 00000000..010b357c --- /dev/null +++ b/packages/sync/dist/transport-1cXLtrs5.d.mts @@ -0,0 +1,22 @@ +/** A duplex, ordered, reliable byte stream to one remote peer. */ +interface SyncConnection { + /** Stable id for this connection (host:port or a socket id). */ + readonly id: string; + /** Remote host/address, when the transport knows it. */ + readonly remoteHost?: string; + send(data: Uint8Array): void; + onData(cb: (data: Uint8Array) => void): void; + onClose(cb: () => void): void; + close(): void; +} +/** Listens for inbound connections and dials outbound ones. */ +interface TransportBridge { + /** Start accepting inbound connections on `port`. */ + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; + /** Dial a remote peer and resolve once the byte stream is open. */ + connect(host: string, port: number): Promise; + /** Stop listening and release resources. */ + stop(): Promise; +} + +export type { SyncConnection as S, TransportBridge as T }; diff --git a/packages/sync/dist/transport-1cXLtrs5.d.ts b/packages/sync/dist/transport-1cXLtrs5.d.ts new file mode 100644 index 00000000..010b357c --- /dev/null +++ b/packages/sync/dist/transport-1cXLtrs5.d.ts @@ -0,0 +1,22 @@ +/** A duplex, ordered, reliable byte stream to one remote peer. */ +interface SyncConnection { + /** Stable id for this connection (host:port or a socket id). */ + readonly id: string; + /** Remote host/address, when the transport knows it. */ + readonly remoteHost?: string; + send(data: Uint8Array): void; + onData(cb: (data: Uint8Array) => void): void; + onClose(cb: () => void): void; + close(): void; +} +/** Listens for inbound connections and dials outbound ones. */ +interface TransportBridge { + /** Start accepting inbound connections on `port`. */ + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; + /** Dial a remote peer and resolve once the byte stream is open. */ + connect(host: string, port: number): Promise; + /** Stop listening and release resources. */ + stop(): Promise; +} + +export type { SyncConnection as S, TransportBridge as T }; diff --git a/packages/sync/package.json b/packages/sync/package.json new file mode 100644 index 00000000..67a2d3a5 --- /dev/null +++ b/packages/sync/package.json @@ -0,0 +1,61 @@ +{ + "name": "@offgrid/sync", + "version": "0.0.1", + "private": true, + "description": "Off Grid sync engine: device pairing, discovery, encrypted framed messaging, and transfer. Platform-agnostic; embeddable in desktop and mobile via a TransportBridge. Extracted from EasyShare.", + "license": "AGPL-3.0-only", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./node": { + "types": "./dist/adapters/node-tcp.d.ts", + "import": "./dist/adapters/node-tcp.mjs", + "require": "./dist/adapters/node-tcp.js" + }, + "./node-discovery": { + "types": "./dist/adapters/node-discovery.d.ts", + "import": "./dist/adapters/node-discovery.mjs", + "require": "./dist/adapters/node-discovery.js" + }, + "./rn": { + "types": "./dist/adapters/rn-tcp.d.ts", + "import": "./dist/adapters/rn-tcp.mjs", + "require": "./dist/adapters/rn-tcp.js" + }, + "./rn-discovery": { + "types": "./dist/adapters/rn-discovery.d.ts", + "import": "./dist/adapters/rn-discovery.mjs", + "require": "./dist/adapters/rn-discovery.js" + }, + "./portable": { + "types": "./dist/portable/index.d.ts", + "import": "./dist/portable/index.mjs", + "require": "./dist/portable/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts src/adapters/node-tcp.ts src/adapters/node-discovery.ts src/adapters/rn-tcp.ts src/adapters/rn-discovery.ts src/portable/index.ts --format esm,cjs --dts", + "dev": "tsup src/index.ts src/adapters/node-tcp.ts src/adapters/node-discovery.ts src/adapters/rn-tcp.ts src/adapters/rn-discovery.ts src/portable/index.ts --format esm,cjs --dts --watch", + "typecheck": "tsc --noEmit", + "prepare": "npm run build", + "test": "npm run build && node --test 'test/**/*.test.mjs'" + }, + "dependencies": { + "bonjour-service": "^1.2.1", + "js-sha512": "^0.9.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + }, + "offgridVendoredFrom": { + "repo": "off-grid-ai/shared", + "path": "packages/sync", + "commit": "9b671b5", + "vendoredAt": "2026-07-26" + } +} diff --git a/packages/sync/src/adapters/node-discovery.ts b/packages/sync/src/adapters/node-discovery.ts new file mode 100644 index 00000000..2a039898 --- /dev/null +++ b/packages/sync/src/adapters/node-discovery.ts @@ -0,0 +1,68 @@ +// Node mDNS discovery for @offgrid/sync (desktop). Implements DiscoveryService +// over bonjour-service (pure-JS multicast DNS, no native build) so devices find +// each other on the LAN without manual host/port. React Native uses a native +// NSD/Bonjour module instead; this Node adapter is at @offgrid/sync/node-discovery. + +import { Bonjour, type Browser, type Service } from 'bonjour-service'; +import type { DeviceInfo, DiscoveredDevice } from '../types'; +import type { DiscoveryService } from '../discovery'; +import { createTxtRecord, parseTxtRecord, createDiscoveredDevice } from '../discovery'; + +// bonjour-service takes the bare service type and forms _._tcp.local. +const SERVICE_TYPE = 'offgrid'; + +export class NodeDiscovery implements DiscoveryService { + private bonjour = new Bonjour(); + private browser?: Browser; + private published?: Service; + private foundCb?: (device: DiscoveredDevice) => void; + private lostCb?: (deviceId: string) => void; + + async start(): Promise { + this.browser = this.bonjour.find({ type: SERVICE_TYPE }); + this.browser.on('up', (service: Service) => { + const txt = (service.txt ?? {}) as Record; + const host = + service.addresses?.find((a) => a.includes('.')) ?? service.host ?? ''; + const info = parseTxtRecord(txt, host, service.port); + if (info) this.foundCb?.(createDiscoveredDevice(info)); + }); + this.browser.on('down', (service: Service) => { + const txt = (service.txt ?? {}) as Record; + this.lostCb?.(txt.id || service.name); + }); + } + + async advertise(device: DeviceInfo): Promise { + this.published = this.bonjour.publish({ + name: `OffGrid-${device.id}`, + type: SERVICE_TYPE, + port: device.port, + txt: createTxtRecord(device), + }); + } + + async stopAdvertising(): Promise { + await new Promise((resolve) => { + if (!this.published) return resolve(); + this.published.stop?.(() => resolve()); + this.published = undefined; + // stop() may not invoke the callback on all versions; resolve soon anyway. + setTimeout(resolve, 50); + }); + } + + onDeviceFound(callback: (device: DiscoveredDevice) => void): void { + this.foundCb = callback; + } + + onDeviceLost(callback: (deviceId: string) => void): void { + this.lostCb = callback; + } + + async stop(): Promise { + this.browser?.stop(); + await this.stopAdvertising(); + this.bonjour.destroy(); + } +} diff --git a/packages/sync/src/adapters/node-tcp.ts b/packages/sync/src/adapters/node-tcp.ts new file mode 100644 index 00000000..5e3c45a9 --- /dev/null +++ b/packages/sync/src/adapters/node-tcp.ts @@ -0,0 +1,56 @@ +// Node TCP transport for @offgrid/sync (desktop / Electron main process). +// Implements TransportBridge over node:net. The engine handles framing and +// encryption; this adapter just moves bytes. React Native supplies its own +// transport, so this Node-only adapter lives at the @offgrid/sync/node subpath +// and is never imported by the platform-agnostic core. + +import net from 'net'; +import type { SyncConnection, TransportBridge } from '../transport'; + +function wrap(socket: net.Socket): SyncConnection { + const id = `${socket.remoteAddress ?? '?'}:${socket.remotePort ?? '?'}`; + // Avoid uncaught 'error' events tearing down the process; surface as close. + socket.on('error', () => socket.destroy()); + return { + id, + remoteHost: socket.remoteAddress ?? undefined, + send: (data) => socket.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), + onData: (cb) => socket.on('data', (d: Buffer) => cb(new Uint8Array(d.buffer, d.byteOffset, d.byteLength))), + onClose: (cb) => socket.on('close', () => cb()), + close: () => socket.destroy(), + }; +} + +export class NodeTcpTransport implements TransportBridge { + private server?: net.Server; + /** The port actually bound after listen() (useful when listening on 0). */ + boundPort?: number; + + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer((socket) => onConnection(wrap(socket))); + server.once('error', reject); + server.listen(port, () => { + const addr = server.address(); + if (addr && typeof addr === 'object') this.boundPort = addr.port; + this.server = server; + resolve(); + }); + }); + } + + connect(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }, () => resolve(wrap(socket))); + socket.once('error', reject); + }); + } + + stop(): Promise { + return new Promise((resolve) => { + if (!this.server) return resolve(); + this.server.close(() => resolve()); + this.server = undefined; + }); + } +} diff --git a/packages/sync/src/adapters/rn-discovery.ts b/packages/sync/src/adapters/rn-discovery.ts new file mode 100644 index 00000000..78062c6d --- /dev/null +++ b/packages/sync/src/adapters/rn-discovery.ts @@ -0,0 +1,103 @@ +// React Native mDNS discovery for @offgrid/sync (mobile). Implements +// DiscoveryService over react-native-zeroconf (Android NSD / iOS Bonjour). +// Mirrors node-discovery.ts. The Zeroconf instance is INJECTED by the host so +// this package never imports react-native-zeroconf directly. +// +// Service type 'offgrid' resolves to _offgrid._tcp.local — identical to the +// desktop Node adapter, so phone and laptop find each other. + +import type { DeviceInfo, DiscoveredDevice } from '../types'; +import type { DiscoveryService } from '../discovery'; +import { createTxtRecord, parseTxtRecord, createDiscoveredDevice } from '../discovery'; + +const SERVICE_TYPE = 'offgrid'; +const PROTOCOL = 'tcp'; +const DOMAIN = 'local.'; + +/** Minimal shape of a react-native-zeroconf resolved service. */ +export interface RnZeroconfService { + txt?: Record; + addresses?: string[]; + host?: string; + port: number; + name: string; +} + +/** Minimal shape of the react-native-zeroconf instance we use. Publish methods + * are optional — not every RN zeroconf build can advertise; discovery still + * works one-way (we browse; a peer that can advertise gets found and dialed). */ +export interface RnZeroconf { + on(event: 'resolved', cb: (service: RnZeroconfService) => void): void; + on(event: 'remove', cb: (name: string) => void): void; + on(event: 'error', cb: (err: unknown) => void): void; + scan(type: string, protocol: string, domain: string): void; + stop(): void; + removeDeviceListeners?(): void; + publishService?( + type: string, + protocol: string, + domain: string, + name: string, + port: number, + txt?: Record + ): void; + unpublishService?(name: string): void; +} + +export class RnDiscovery implements DiscoveryService { + private foundCb?: (device: DiscoveredDevice) => void; + private lostCb?: (deviceId: string) => void; + private publishedName?: string; + + constructor(private readonly zeroconf: RnZeroconf) {} + + async start(): Promise { + this.zeroconf.on('resolved', (svc) => { + const txt = svc.txt ?? {}; + const ipv4 = svc.addresses?.find((a) => a.includes('.')); + const host = ipv4 ?? svc.host ?? svc.addresses?.[0] ?? ''; + const info = parseTxtRecord(txt, host, svc.port); + if (info) this.foundCb?.(createDiscoveredDevice(info)); + }); + this.zeroconf.on('remove', (name) => { + // name like "OffGrid-._offgrid._tcp.local." — recover our device id. + const m = /OffGrid-([^.]+)/.exec(name); + this.lostCb?.(m ? m[1] : name); + }); + this.zeroconf.on('error', () => { + /* swallowed; rescans recover */ + }); + this.zeroconf.scan(SERVICE_TYPE, PROTOCOL, DOMAIN); + } + + async advertise(device: DeviceInfo): Promise { + const name = `OffGrid-${device.id}`; + this.publishedName = name; + if (typeof this.zeroconf.publishService === 'function') { + this.zeroconf.publishService(SERVICE_TYPE, PROTOCOL, DOMAIN, name, device.port, createTxtRecord(device)); + } else { + console.warn('[sync] zeroconf.publishService unavailable — browse-only on this device'); + } + } + + async stopAdvertising(): Promise { + if (this.publishedName && typeof this.zeroconf.unpublishService === 'function') { + this.zeroconf.unpublishService(this.publishedName); + } + this.publishedName = undefined; + } + + onDeviceFound(callback: (device: DiscoveredDevice) => void): void { + this.foundCb = callback; + } + + onDeviceLost(callback: (deviceId: string) => void): void { + this.lostCb = callback; + } + + async stop(): Promise { + await this.stopAdvertising(); + this.zeroconf.stop(); + this.zeroconf.removeDeviceListeners?.(); + } +} diff --git a/packages/sync/src/adapters/rn-tcp.ts b/packages/sync/src/adapters/rn-tcp.ts new file mode 100644 index 00000000..a28a24fd --- /dev/null +++ b/packages/sync/src/adapters/rn-tcp.ts @@ -0,0 +1,93 @@ +// React Native TCP transport for @offgrid/sync (mobile). Implements +// TransportBridge over react-native-tcp-socket. Mirrors node-tcp.ts; the engine +// handles framing + encryption, this just moves bytes. +// +// The RN socket module and a byte codec are INJECTED by the host (the mobile app +// passes `TcpSocket` and a Buffer-backed codec), so this package never imports +// react-native-tcp-socket directly and stays installable/buildable without RN. + +import type { SyncConnection, TransportBridge } from '../transport'; + +/** Minimal shape of a react-native-tcp-socket socket we use. */ +export interface RnSocket { + remoteAddress?: string; + on(event: 'data', cb: (data: unknown) => void): void; + on(event: 'close', cb: () => void): void; + on(event: 'error', cb: (err: unknown) => void): void; + write(data: unknown): void; + destroy(): void; +} + +/** Minimal shape of a react-native-tcp-socket server we use. */ +export interface RnTcpServer { + listen(opts: { port: number; host?: string }, cb?: () => void): void; + address(): { port: number } | string | null; + on(event: 'error', cb: (err: unknown) => void): void; + close(): void; +} + +/** Minimal shape of the react-native-tcp-socket module we use. */ +export interface RnTcpModule { + createServer(onConnection: (socket: RnSocket) => void): RnTcpServer; + createConnection(opts: { host: string; port: number }, cb?: () => void): RnSocket; +} + +/** Bytes <-> wire conversion. Injected because RN needs its Buffer polyfill and + * react-native-tcp-socket may deliver 'data' as a (base64) string on Android. */ +export interface ByteCodec { + /** Normalize an inbound 'data' payload (Buffer or string) to raw bytes. */ + toBytes(data: unknown): Uint8Array; + /** Convert raw bytes into what socket.write() expects (a Buffer). */ + fromBytes(bytes: Uint8Array): unknown; +} + +function wrap(socket: RnSocket, codec: ByteCodec): SyncConnection { + socket.on('error', () => socket.destroy()); // surface errors as close, not crash + return { + id: socket.remoteAddress ?? 'rn-peer', + remoteHost: socket.remoteAddress, + send: (data) => socket.write(codec.fromBytes(data)), + onData: (cb) => socket.on('data', (d) => cb(codec.toBytes(d))), + onClose: (cb) => socket.on('close', cb), + close: () => socket.destroy(), + }; +} + +export class RnTcpTransport implements TransportBridge { + private server?: RnTcpServer; + /** Port actually bound after listen() (we listen on 0 and advertise this). */ + boundPort?: number; + + constructor( + private readonly tcp: RnTcpModule, + private readonly codec: ByteCodec + ) {} + + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise { + return new Promise((resolve, reject) => { + const server = this.tcp.createServer((socket) => onConnection(wrap(socket, this.codec))); + server.on('error', reject); + server.listen({ port, host: '0.0.0.0' }, () => { + const addr = server.address(); + if (addr && typeof addr === 'object') this.boundPort = addr.port; + this.server = server; + resolve(); + }); + }); + } + + connect(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = this.tcp.createConnection({ host, port }, () => resolve(wrap(socket, this.codec))); + socket.on('error', reject); + }); + } + + stop(): Promise { + return new Promise((resolve) => { + this.server?.close(); + this.server = undefined; + resolve(); + }); + } +} diff --git a/packages/sync/src/cap.ts b/packages/sync/src/cap.ts new file mode 100644 index 00000000..e29a6186 --- /dev/null +++ b/packages/sync/src/cap.ts @@ -0,0 +1,37 @@ +// Device cap (open-core monetization lever). +// +// The COUNT CHECK lives here in the open core: free tier pairs up to +// FREE_DEVICE_CAP devices; beyond that requires a paid entitlement. The +// ENTITLEMENT itself (what the limit is) is injected by the host's private pro +// layer via DeviceCapPolicy.limit() - this package never verifies billing. + +export const FREE_DEVICE_CAP = 2; + +export interface DeviceCapPolicy { + /** Max distinct paired devices allowed. Free returns FREE_DEVICE_CAP; a pro + * entitlement returns a higher number or Infinity. */ + limit(): number; +} + +/** A fixed free-tier policy. */ +export const freePolicy: DeviceCapPolicy = { limit: () => FREE_DEVICE_CAP }; + +/** Build a policy from a pro flag supplied by the host's entitlement check. */ +export function policyFor(isPro: boolean, proLimit: number = Infinity): DeviceCapPolicy { + return { limit: () => (isPro ? proLimit : FREE_DEVICE_CAP) }; +} + +export interface DeviceCap { + policy: DeviceCapPolicy; + /** How many distinct devices are already paired (from the host's store). */ + pairedCount: () => number; + /** Whether this device id is already paired (re-pairing does not count). */ + isKnown: (deviceId: string) => boolean; +} + +/** True if pairing with `deviceId` is allowed under the cap. */ +export function pairingAllowed(cap: DeviceCap | undefined, deviceId: string): boolean { + if (!cap) return true; + if (cap.isKnown(deviceId)) return true; // re-pair an existing device + return cap.pairedCount() < cap.policy.limit(); +} diff --git a/packages/sync/src/crypto/index.ts b/packages/sync/src/crypto/index.ts new file mode 100644 index 00000000..b962a587 --- /dev/null +++ b/packages/sync/src/crypto/index.ts @@ -0,0 +1,210 @@ +import nacl from 'tweetnacl'; +import { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } from 'tweetnacl-util'; +import { sha512 } from 'js-sha512'; + +// Constants +// Note: 10,000 iterations is still secure for a passphrase-based key while being fast enough for mobile +// 100,000 was causing 5-10 second delays on mobile devices +const PBKDF2_ITERATIONS = 10000; +const SALT_LENGTH = 16; +const KEY_LENGTH = 32; // 256 bits for NaCl secretbox + +/** + * Generate a random device ID + */ +export function generateDeviceId(): string { + const bytes = nacl.randomBytes(16); + return encodeBase64(bytes).replace(/[+/=]/g, (c) => + c === '+' ? '-' : c === '/' ? '_' : '' + ); +} + +/** + * Generate a random message ID + */ +export function generateMessageId(): string { + const bytes = nacl.randomBytes(8); + return encodeBase64(bytes).replace(/[+/=]/g, (c) => + c === '+' ? '-' : c === '/' ? '_' : '' + ); +} + +/** + * Simple PBKDF2-like key derivation using iterated hashing + * Note: This is a simplified implementation using NaCl primitives + */ +export function deriveKey( + passphrase: string, + salt: Uint8Array, + iterations: number = PBKDF2_ITERATIONS +): Uint8Array { + const passphraseBytes = decodeUTF8(passphrase); + + // Combine passphrase and salt + const combined = new Uint8Array(passphraseBytes.length + salt.length); + combined.set(passphraseBytes); + combined.set(salt, passphraseBytes.length); + + // Iteratively hash + let result = nacl.hash(combined); + for (let i = 1; i < iterations; i++) { + result = nacl.hash(result); + } + + // Take first KEY_LENGTH bytes + return result.slice(0, KEY_LENGTH); +} + +/** + * Derive a shared secret from a passphrase and two device IDs + * This ensures both devices derive the same key + */ +export function deriveSharedSecret( + passphrase: string, + deviceId1: string, + deviceId2: string +): string { + // Sort device IDs to ensure consistent ordering + const sortedIds = [deviceId1, deviceId2].sort(); + const saltString = `${sortedIds[0]}:${sortedIds[1]}`; + const salt = nacl.hash(decodeUTF8(saltString)).slice(0, SALT_LENGTH); + + const key = deriveKey(passphrase, salt); + return encodeBase64(key); +} + +/** + * Generate a random challenge for pairing verification + */ +export function generateChallenge(): string { + const bytes = nacl.randomBytes(32); + return encodeBase64(bytes); +} + +/** + * Create an HMAC-like response to a challenge using the shared secret + */ +export function createChallengeResponse( + challenge: string, + sharedSecret: string +): string { + const challengeBytes = decodeBase64(challenge); + const secretBytes = decodeBase64(sharedSecret); + + // Combine challenge and secret, then hash + const combined = new Uint8Array(challengeBytes.length + secretBytes.length); + combined.set(challengeBytes); + combined.set(secretBytes, challengeBytes.length); + + const hash = nacl.hash(combined); + return encodeBase64(hash.slice(0, 32)); +} + +/** + * Verify a challenge response + */ +export function verifyChallengeResponse( + challenge: string, + response: string, + sharedSecret: string +): boolean { + const expectedResponse = createChallengeResponse(challenge, sharedSecret); + return response === expectedResponse; +} + +/** + * Encrypt data using NaCl secretbox (XSalsa20-Poly1305) + */ +export function encrypt( + data: string | Uint8Array, + secretKey: string +): { encrypted: string; nonce: string } { + const keyBytes = decodeBase64(secretKey); + const dataBytes = typeof data === 'string' ? decodeUTF8(data) : data; + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); + + const encrypted = nacl.secretbox(dataBytes, nonce, keyBytes); + + return { + encrypted: encodeBase64(encrypted), + nonce: encodeBase64(nonce), + }; +} + +/** + * Decrypt data using NaCl secretbox + */ +export function decrypt( + encrypted: string, + nonce: string, + secretKey: string +): Uint8Array | null { + const keyBytes = decodeBase64(secretKey); + const encryptedBytes = decodeBase64(encrypted); + const nonceBytes = decodeBase64(nonce); + + const decrypted = nacl.secretbox.open(encryptedBytes, nonceBytes, keyBytes); + return decrypted; +} + +/** + * Decrypt data and return as string + */ +export function decryptToString( + encrypted: string, + nonce: string, + secretKey: string +): string | null { + const decrypted = decrypt(encrypted, nonce, secretKey); + if (!decrypted) return null; + return encodeUTF8(decrypted); +} + +/** + * Calculate a checksum for file integrity verification + */ +export function calculateChecksum(data: Uint8Array): string { + const hash = nacl.hash(data); + return encodeBase64(hash.slice(0, 16)); +} + +/** + * Verify a checksum + */ +export function verifyChecksum(data: Uint8Array, checksum: string): boolean { + const calculated = calculateChecksum(data); + return calculated === checksum; +} + +/** + * Incremental/streaming checksum calculator using SHA-512. + * Produces the same output format as calculateChecksum() (base64 of first 16 bytes of SHA-512) + * but allows feeding data in chunks to avoid loading entire files into memory. + */ +export class IncrementalChecksum { + private hasher: ReturnType; + + constructor() { + this.hasher = sha512.create(); + } + + /** + * Feed a chunk of data into the hash + */ + update(data: Uint8Array): void { + this.hasher.update(data); + } + + /** + * Finalize and return checksum in the same format as calculateChecksum() + * (base64 of first 16 bytes of SHA-512 digest) + */ + digest(): string { + const hashArray = this.hasher.array(); + const first16 = new Uint8Array(hashArray.slice(0, 16)); + return encodeBase64(first16); + } +} + +// Re-export utilities +export { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 }; diff --git a/packages/sync/src/discovery/index.ts b/packages/sync/src/discovery/index.ts new file mode 100644 index 00000000..25839360 --- /dev/null +++ b/packages/sync/src/discovery/index.ts @@ -0,0 +1,115 @@ +import type { DeviceInfo, DiscoveredDevice } from '../types'; + +// mDNS Service Configuration +export const MDNS_SERVICE_TYPE = '_easyshare._tcp'; +export const MDNS_SERVICE_NAME = 'EasyShare'; +export const MDNS_DOMAIN = 'local'; + +// TXT Record Keys +export const TXT_DEVICE_ID = 'id'; +export const TXT_DEVICE_NAME = 'name'; +export const TXT_PLATFORM = 'platform'; +export const TXT_VERSION = 'version'; + +/** + * Create TXT record data for mDNS advertisement + */ +export function createTxtRecord(device: DeviceInfo): Record { + return { + [TXT_DEVICE_ID]: device.id, + [TXT_DEVICE_NAME]: device.name, + [TXT_PLATFORM]: device.platform, + [TXT_VERSION]: device.version, + }; +} + +/** + * Parse TXT record data from mDNS discovery + */ +export function parseTxtRecord( + txt: Record, + host: string, + port: number +): DeviceInfo | null { + const id = txt[TXT_DEVICE_ID]; + const name = txt[TXT_DEVICE_NAME]; + const platform = txt[TXT_PLATFORM] as DeviceInfo['platform']; + const version = txt[TXT_VERSION]; + + if (!id || !name || !platform || !version) { + return null; + } + + return { + id, + name, + platform, + version, + host, + port, + }; +} + +/** + * Create a DiscoveredDevice from DeviceInfo + */ +export function createDiscoveredDevice(device: DeviceInfo): DiscoveredDevice { + return { + ...device, + lastSeen: Date.now(), + }; +} + +/** + * Check if a discovered device is stale (not seen recently) + */ +export function isDeviceStale(device: DiscoveredDevice, maxAgeMs: number = 30000): boolean { + return Date.now() - device.lastSeen > maxAgeMs; +} + +/** + * Filter out stale devices from a list + */ +export function filterStaleDevices( + devices: DiscoveredDevice[], + maxAgeMs: number = 30000 +): DiscoveredDevice[] { + return devices.filter((device) => !isDeviceStale(device, maxAgeMs)); +} + +/** + * Update or add a device to a list of discovered devices + */ +export function updateDeviceList( + devices: DiscoveredDevice[], + newDevice: DiscoveredDevice +): DiscoveredDevice[] { + const existingIndex = devices.findIndex((d) => d.id === newDevice.id); + + if (existingIndex >= 0) { + // Update existing device + const updated = [...devices]; + updated[existingIndex] = { ...newDevice, lastSeen: Date.now() }; + return updated; + } + + // Add new device + return [...devices, newDevice]; +} + +/** + * Remove a device from the list by ID + */ +export function removeDevice(devices: DiscoveredDevice[], deviceId: string): DiscoveredDevice[] { + return devices.filter((d) => d.id !== deviceId); +} + +// Platform-specific discovery interfaces (implemented in desktop/mobile packages) +export interface DiscoveryService { + start(): Promise; + stop(): Promise; + advertise(device: DeviceInfo): Promise; + stopAdvertising(): Promise; + onDeviceFound(callback: (device: DiscoveredDevice) => void): void; + onDeviceLost(callback: (deviceId: string) => void): void; +} diff --git a/packages/sync/src/engine.ts b/packages/sync/src/engine.ts new file mode 100644 index 00000000..21395c74 --- /dev/null +++ b/packages/sync/src/engine.ts @@ -0,0 +1,256 @@ +// SyncEngine: ties pairing and encrypted messaging together over a +// TransportBridge. Host-agnostic - give it a transport and a local device and +// it manages the handshake and routes application messages to paired peers. + +import type { DeviceInfo, PairedDevice, Message, MessageType } from './types'; +import { + createPairingState, + handlePairingMessage, + createPairRequest, + createPairedDevice, + type PairingState, +} from './pairing'; +import { FrameBuffer, encodePlaintextFrame, encodeEncryptedFrame } from './wire'; +import { createAppMessage, createHello } from './protocol'; +import type { SyncConnection, TransportBridge } from './transport'; +import { pairingAllowed, type DeviceCap } from './cap'; +import { createPairReject } from './pairing'; + +const PAIRING_TYPES: ReadonlySet = new Set([ + 'pair_request', + 'pair_challenge', + 'pair_response', + 'pair_confirm', + 'pair_reject', +]); + +export interface SyncEngineOptions { + localDevice: DeviceInfo; + transport: TransportBridge; + /** Supply the passphrase for an incoming pairing (e.g. a UI prompt). Return + * null/undefined to refuse. Not needed on the side that calls connect(). */ + getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; + /** Application message from a paired peer (pairing traffic is handled internally). */ + onMessage?: (deviceId: string, message: Message) => void; + /** Generic app-channel message from a paired peer (type 'app'). Used by + * features like memory/clipboard sync that ride the paired channel. */ + onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; + /** Look up the stored shared secret for an already-paired device, so an + * inbound reconnect (hello) can resume without re-running the handshake. */ + getSharedSecret?: (deviceId: string) => string | undefined; + /** A pairing handshake completed. */ + onPaired?: (device: PairedDevice) => void; + /** A pairing attempt failed. */ + onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; + /** Optional device cap (open-core 2 free / 3+ paid). When set, pairing a new + * device beyond the limit is refused on both the dialing and accepting side. */ + cap?: DeviceCap; +} + +/** One peer connection: owns its frame buffer, pairing state, and shared secret. */ +class PeerSession { + private buffer = new FrameBuffer(); + private pairing: PairingState; + private sharedSecret?: string; + private passphrase?: string; + private resumeSecret?: string; + private helloSent = false; + private queue: Promise = Promise.resolve(); + remoteDevice?: DeviceInfo; + + constructor( + readonly conn: SyncConnection, + private readonly engine: SyncEngine, + private readonly opts: SyncEngineOptions, + initiateWith?: { remote: DeviceInfo; passphrase: string }, + resumeWith?: { remote: DeviceInfo; sharedSecret: string } + ) { + this.pairing = createPairingState(opts.localDevice); + if (initiateWith) { + this.passphrase = initiateWith.passphrase; + this.remoteDevice = initiateWith.remote; + this.pairing = { ...this.pairing, remoteDevice: initiateWith.remote, passphrase: initiateWith.passphrase }; + } else if (resumeWith) { + this.remoteDevice = resumeWith.remote; + this.resumeSecret = resumeWith.sharedSecret; + } + conn.onData((data) => this.onData(data)); + conn.onClose(() => this.engine._removeSession(this)); + if (initiateWith) { + this.sendPlain(createPairRequest(opts.localDevice)); + } else if (resumeWith) { + // Reconnect: greet with a plaintext hello so the peer resumes with the + // stored secret. The secret only goes "live" once hello round-trips. + this.sendPlain(createHello(opts.localDevice)); + this.helloSent = true; + } + } + + get pairedSecret(): string | undefined { + return this.sharedSecret; + } + + /** Send an application message to this peer (must be paired). */ + sendMessage(message: Message): boolean { + if (!this.sharedSecret) return false; + this.conn.send(encodeEncryptedFrame(message, this.sharedSecret)); + return true; + } + + private sendPlain(message: Message): void { + this.conn.send(encodePlaintextFrame(message)); + } + + private onData(data: Uint8Array): void { + this.buffer.append(data); + const frames = this.buffer.drain(this.sharedSecret); + // Serialize handling so async passphrase prompts keep handshake order. + for (const f of frames) { + this.queue = this.queue.then(() => this.route(f.message)); + } + } + + private async route(message: Message): Promise { + if (message.type === 'hello') { + this.handleHello(message); + return; + } + if (PAIRING_TYPES.has(message.type)) { + await this.handlePairing(message); + return; + } + if (this.sharedSecret && this.remoteDevice) { + if (message.type === 'app') { + const p = message.payload as { channel: string; data: unknown }; + this.opts.onAppMessage?.(this.remoteDevice.id, p.channel, p.data); + } else { + this.opts.onMessage?.(this.remoteDevice.id, message); + } + } + } + + /** Resume an already-paired device using the stored secret (no handshake). */ + private handleHello(message: Message): void { + const remote = (message as { payload: { deviceInfo: DeviceInfo } }).payload.deviceInfo; + this.remoteDevice = remote; + const secret = this.resumeSecret ?? this.opts.getSharedSecret?.(remote.id); + if (!secret) { + this.opts.onPairingFailed?.(remote, 'unknown_device'); + this.conn.close(); + return; + } + this.sharedSecret = secret; + if (!this.helloSent) { + this.sendPlain(createHello(this.opts.localDevice)); + this.helloSent = true; + } + const paired: PairedDevice = { ...remote, sharedSecret: secret, pairedAt: Date.now() }; + this.engine._registerPaired(remote.id, this); + this.opts.onPaired?.(paired); + } + + private async handlePairing(message: Message): Promise { + // First inbound pair_request: enforce the device cap, then ask for the passphrase. + if (message.type === 'pair_request' && this.passphrase == null) { + const remote = (message as { payload: { deviceInfo: DeviceInfo } }).payload.deviceInfo; + this.remoteDevice = remote; + if (!pairingAllowed(this.opts.cap, remote.id)) { + this.sendPlain(createPairReject('device limit reached')); + this.opts.onPairingFailed?.(remote, 'device_cap_reached'); + this.conn.close(); + return; + } + const pass = await this.opts.getPassphrase?.(remote); + if (pass == null) { + this.opts.onPairingFailed?.(remote, 'pairing refused'); + this.conn.close(); + return; + } + this.passphrase = pass; + } + + const { newState, response } = handlePairingMessage(this.pairing, message, this.passphrase); + this.pairing = newState; + if (newState.sharedSecret) this.sharedSecret = newState.sharedSecret; + if (newState.remoteDevice) this.remoteDevice = newState.remoteDevice; + if (response) this.sendPlain(response); + + if (newState.status === 'success') { + const paired = createPairedDevice(newState); + if (paired) { + this.engine._registerPaired(paired.id, this); + this.opts.onPaired?.(paired); + } + } else if (newState.status === 'failed') { + this.opts.onPairingFailed?.(this.remoteDevice, newState.error ?? 'pairing failed'); + } + } +} + +export class SyncEngine { + private sessions = new Set(); + private paired = new Map(); + + constructor(private readonly opts: SyncEngineOptions) {} + + /** Start accepting inbound connections on `port`. */ + async start(port: number): Promise { + await this.opts.transport.listen(port, (conn) => { + this.sessions.add(new PeerSession(conn, this, this.opts)); + }); + } + + /** Dial a discovered device and begin pairing with `passphrase`. Refuses if + * pairing a new device would exceed the device cap. */ + async pair(device: DeviceInfo, passphrase: string): Promise { + if (!pairingAllowed(this.opts.cap, device.id)) { + this.opts.onPairingFailed?.(device, 'device_cap_reached'); + return; + } + const conn = await this.opts.transport.connect(device.host, device.port); + const session = new PeerSession(conn, this, this.opts, { remote: device, passphrase }); + this.sessions.add(session); + } + + /** Reconnect to an already-paired device using its stored shared secret, + * skipping the pairing handshake. Used for auto-reconnect on discovery. */ + async reconnect(device: DeviceInfo, sharedSecret: string): Promise { + const conn = await this.opts.transport.connect(device.host, device.port); + const session = new PeerSession(conn, this, this.opts, undefined, { remote: device, sharedSecret }); + this.sessions.add(session); + } + + /** Send an application message to an already-paired device. */ + send(deviceId: string, message: Message): boolean { + return this.paired.get(deviceId)?.sendMessage(message) ?? false; + } + + /** Send a generic app-channel message (encrypted) to a paired device. */ + sendApp(deviceId: string, channel: string, data: unknown): boolean { + return this.send(deviceId, createAppMessage(channel, data)); + } + + isPaired(deviceId: string): boolean { + return this.paired.has(deviceId); + } + + async stop(): Promise { + for (const s of this.sessions) s.conn.close(); + this.sessions.clear(); + this.paired.clear(); + await this.opts.transport.stop(); + } + + /** @internal */ + _registerPaired(deviceId: string, session: PeerSession): void { + this.paired.set(deviceId, session); + } + + /** @internal */ + _removeSession(session: PeerSession): void { + this.sessions.delete(session); + for (const [id, s] of this.paired) { + if (s === session) this.paired.delete(id); + } + } +} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts new file mode 100644 index 00000000..46c847e2 --- /dev/null +++ b/packages/sync/src/index.ts @@ -0,0 +1,46 @@ +// @offgrid/sync - platform-agnostic device-to-device sync engine. +// Extracted from EasyShare. Pairing, discovery contracts, encrypted framed +// messaging, and transfer live here; the actual sockets and mDNS are provided +// by the host through TransportBridge / DiscoveryService so this package is +// embeddable in Off Grid Desktop (Node) and Off Grid Mobile (React Native). + +// Types +export * from './types'; + +// Crypto utilities +export * from './crypto'; + +// Discovery protocol + DiscoveryService interface +export * from './discovery'; + +// Pairing protocol / state machine +export * from './pairing'; + +// Transfer protocol +export * from './transfer'; + +// Message protocol (serialization, framing, encryption) +export * from './protocol'; + +// Wire codec (length-prefixed plaintext/encrypted frames) +export * from './wire'; + +// Transport abstraction (sockets injected by the host) +export * from './transport'; + +// High-level engine that ties pairing + messaging over a transport +export * from './engine'; + +// Device cap (open-core 2 free / 3+ paid) +export * from './cap'; + +// Discovery orchestrator (auto-reconnect known devices on discovery) +export * from './orchestrator'; + +// Op-log replication: chats / projects / memory converge across devices +// (Lamport + last-writer-wins). Pure; reused by desktop and mobile. +export * from './oplog'; +export * from './state-sync'; + +export const VERSION = '0.0.1'; +export const APP_NAME = 'Off Grid Sync'; diff --git a/packages/sync/src/oplog.ts b/packages/sync/src/oplog.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa54442411ff19a4ec7dce53104acb3d38dac11e GIT binary patch literal 5390 zcmbVQU31&U70t7L#m!7-2s$K`^pUEhs=Hf50sTd0~cr6v9ExBt?cx7X)0 zYD!_(N>_8Ssg7KZSj%d%KWEvzi}wANCRTGM$cGpjN2 zA>I7*vak{t*HUDbUSq+Xq@rq+wuX!%yOliO&DqsCt&37pCTgk_zf@aAe^u-CMi=>^ z+1D8*O80jvSt-4rlBjc9No=S!5?6bkZn<95D-5G6wZfz+;+3?;W-#l&5S*b#YEu|1 z>r7H2$Q1RalylY4y+**=Pb+vXyg8S(E$p5u>{Az_EPj>xh~!S{z1?CY$yOcUT zIFSEg#*qb)0l-fMICD&srnM9LL=%9Bs3%9=d*GhbYXNR78+te(I(+ehUX^Mk%5pz% z>*C)nnBE`?O4_!-<{iUwnYRrABodich{S_qQhITSNmO%@FUVL8${dABFV)8FBB0Iz z&Pb-4lqQ}E#?`0`v%==YM!M@pD<<+JOzjE!bn+XA4Mzz`827jeS1-b^S_?)SN~#v6 zfg%$0_U*;X8P&>?Xss&hOv?(BV}ouBex30)?ttbZpc=RJ_qHtKYU@Ilxgkgj=J%K< zBdT4#e~RbegI#qY(8gjM|vr;EVbzjz2|)Yf;ky01^-sL*KS-g*vl^Txlup$W~cf?TM6`U-YVWs8ZD>&lSm?^|6I^l9dbwRYCOU#TAKwmdk1F3Wsgj!3+ zECtV@1+g2n~;T-t}Czo6m1 zB8T31F9Ex~jUegwkO`4xvSCJ}(h^tF7q4&5uWvn;Y-m42=OPn=A%WzHYPg)HbbQk7 z*6EL|ozr3+)*MQPhPI$g?;ER94L?*BmoZ34uVD}~3}{rjhrL1&Yr1|N*W_uio?B@w zOU!d`V&Hj77tS@Y74O)-V*oMsDC=PQFOl#R)kp~x#pzC51vld&PihagAWASP-*a3j zCRip+U(4-2=bpk*le-mI2j$c8Zm&B7Lf|r}o$|Yz)D+R|y-9LbkSTqr7 zEX{9^k7*TuO|d20YWQ%xLnr-HrjZ{U=|_Ha{5cpNZ@KaDhcAb)0Go_^$`?ljPqxZN zC*xo5T%M#W)WjI5*WN26nc8yZ;h0M;1+_0%1Gu2TOO(!qQ z<|LWAWnCci6TFB0HDQpK&SUx>`a|68fOakSMoD~w>EP5;H7t(95}msB$;F?zv7A@e zE?ARxl%Ug7`tUKnOdwHVQbe1gxj=2<@*Jmb zyQfc&>EY1_$9^+{sRII?Q7cU{?J;5qzGV<1ryx?a+fC`yr@^MdtKIIy;4Ps1)8Nc!qOvjn2Fy5W!2iWY0VY@2huTdI!ySnL3^4c z0@C8txEG3fFBEQU2c7qIFzIsn= z9O|6``QGq%4RuDtelI%qmwPh@{up{^Zy<^%a_ox(J5kAOOUZQRAfnJDjDMkk!&43> zUdQHv@6j;?*f*P}p{t;UbAWYG%Wg>56@K@fTi+FZoG;eGB1#Bii}&I?tWkaBu(}v{ zD8jDho)@2fX!QTICy9HAKBsS`LU#JjDbOhtmug1Eb;ZXzx<0>J-kzh1;p06y6b`Je zO~Fwmv?)njb6HK1Qz!?{k4246HB=a`VCxBrOjWq{wewtI#pyn)E?;+6wb>R0?w#XC z+Ta|~HGcWigYOC-zlm2D`QtAx+n)(qjApE39pumomNeFT6d~5PMcqngPD9m2F$6Mo zBcD&!S2_iIu*i3UKQ*Wp$nv*=@ru2DoeuI(U2dMJ2RzNgn2J1402y}Kqt^Xk;6{2g z0FV!TQ&~rQ&Ts+tHJB)Y0X+B~Eal&^2?jq56 zGoSITfO|-%AYW}69$MGpg-h?T{s)>jZvNtr0c`_goKyRd*qmKgs?d`9Ti|(k#?6Y4 z(c?ZC7yC*7m>>8tX)m1Ni%4jHxD`uB2#-!*J&1u7?sHf#ZKIL>Ij|oVqff)90@&um uqnoR9yIbGHqFvbHgvclF_;s=~FoVx>+%5S-Mo3^##=f%glIPsx!T$kom)*_) literal 0 HcmV?d00001 diff --git a/packages/sync/src/orchestrator.ts b/packages/sync/src/orchestrator.ts new file mode 100644 index 00000000..24d6cc4d --- /dev/null +++ b/packages/sync/src/orchestrator.ts @@ -0,0 +1,61 @@ +// DiscoveryOrchestrator: ties a DiscoveryService to the SyncEngine. Advertises +// this device, browses for peers, and on finding one either auto-reconnects (if +// already paired, using the stored secret) or surfaces it for the UI to pair. + +import type { DeviceInfo, DiscoveredDevice } from './types'; +import type { DiscoveryService } from './discovery'; + +/** The slice of SyncEngine the orchestrator drives. */ +export interface ReconnectingEngine { + isPaired(deviceId: string): boolean; + reconnect(device: DeviceInfo, sharedSecret: string): Promise; +} + +export interface DiscoveryOrchestratorOptions { + engine: ReconnectingEngine; + discovery: DiscoveryService; + localDevice: DeviceInfo; + /** Stored shared secret for a device, or undefined if not yet paired. */ + getSharedSecret: (deviceId: string) => string | undefined; + /** A discovered device we have no secret for - surface it so the UI can pair. */ + onDiscovered?: (device: DiscoveredDevice) => void; + /** A previously discovered device went away. */ + onLost?: (deviceId: string) => void; +} + +export class DiscoveryOrchestrator { + private connecting = new Set(); + + constructor(private readonly opts: DiscoveryOrchestratorOptions) {} + + async start(): Promise { + this.opts.discovery.onDeviceFound((d) => this.handleFound(d)); + this.opts.discovery.onDeviceLost((id) => { + this.connecting.delete(id); + this.opts.onLost?.(id); + }); + await this.opts.discovery.start(); + await this.opts.discovery.advertise(this.opts.localDevice); + } + + async stop(): Promise { + await this.opts.discovery.stop(); + } + + private handleFound(device: DiscoveredDevice): void { + if (device.id === this.opts.localDevice.id) return; // ignore self + if (this.opts.engine.isPaired(device.id)) return; // already connected + if (this.connecting.has(device.id)) return; // in-flight + + const secret = this.opts.getSharedSecret(device.id); + if (secret) { + this.connecting.add(device.id); + this.opts.engine + .reconnect(device, secret) + .catch(() => undefined) + .finally(() => this.connecting.delete(device.id)); + } else { + this.opts.onDiscovered?.(device); + } + } +} diff --git a/packages/sync/src/pairing/index.ts b/packages/sync/src/pairing/index.ts new file mode 100644 index 00000000..8c0cd600 --- /dev/null +++ b/packages/sync/src/pairing/index.ts @@ -0,0 +1,319 @@ +import type { + DeviceInfo, + PairedDevice, + PairingStatus, + Message, + PairRequestMessage, + PairChallengeMessage, + PairResponseMessage, + PairConfirmMessage, + PairRejectMessage, +} from '../types'; +import { + deriveSharedSecret, + generateChallenge, + createChallengeResponse, + verifyChallengeResponse, + generateMessageId, +} from '../crypto'; + +/** + * Pairing state machine for managing the pairing handshake + */ +export interface PairingState { + status: PairingStatus; + localDevice: DeviceInfo; + remoteDevice?: DeviceInfo; + passphrase?: string; + sharedSecret?: string; + challenge?: string; + error?: string; +} + +/** + * Create initial pairing state + */ +export function createPairingState(localDevice: DeviceInfo): PairingState { + return { + status: 'idle', + localDevice, + }; +} + +/** + * Create a pair request message + */ +export function createPairRequest(localDevice: DeviceInfo): PairRequestMessage { + return { + type: 'pair_request', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + deviceInfo: localDevice, + }, + }; +} + +/** + * Create a pair challenge message + */ +export function createPairChallenge(): PairChallengeMessage { + const challenge = generateChallenge(); + return { + type: 'pair_challenge', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + challenge, + timestamp: Date.now(), + }, + }; +} + +/** + * Create a pair response message + */ +export function createPairResponse( + challenge: string, + sharedSecret: string, + localDevice: DeviceInfo +): PairResponseMessage { + const response = createChallengeResponse(challenge, sharedSecret); + return { + type: 'pair_response', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + response, + deviceInfo: localDevice, + }, + }; +} + +/** + * Create a pair confirm message + */ +export function createPairConfirm(localDevice: DeviceInfo): PairConfirmMessage { + return { + type: 'pair_confirm', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + deviceInfo: localDevice, + }, + }; +} + +/** + * Create a pair reject message + */ +export function createPairReject(reason: string): PairRejectMessage { + return { + type: 'pair_reject', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + reason, + }, + }; +} + +/** + * Handle pairing state transitions + */ +export function handlePairingMessage( + state: PairingState, + message: Message, + passphrase?: string +): { newState: PairingState; response?: Message } { + switch (message.type) { + case 'pair_request': { + const msg = message as PairRequestMessage; + const remoteDevice = msg.payload.deviceInfo; + + if (!passphrase) { + // Waiting for user to enter passphrase + return { + newState: { + ...state, + status: 'waiting', + remoteDevice, + }, + }; + } + + // Generate shared secret and challenge + const sharedSecret = deriveSharedSecret( + passphrase, + state.localDevice.id, + remoteDevice.id + ); + const challengeMsg = createPairChallenge(); + + return { + newState: { + ...state, + status: 'verifying', + remoteDevice, + passphrase, + sharedSecret, + challenge: challengeMsg.payload.challenge, + }, + response: challengeMsg, + }; + } + + case 'pair_challenge': { + const msg = message as PairChallengeMessage; + + if (!passphrase || !state.remoteDevice) { + return { + newState: { + ...state, + status: 'failed', + error: 'Missing passphrase or remote device', + }, + }; + } + + const sharedSecret = deriveSharedSecret( + passphrase, + state.localDevice.id, + state.remoteDevice.id + ); + const responseMsg = createPairResponse( + msg.payload.challenge, + sharedSecret, + state.localDevice + ); + + return { + newState: { + ...state, + status: 'verifying', + sharedSecret, + }, + response: responseMsg, + }; + } + + case 'pair_response': { + const msg = message as PairResponseMessage; + + if (!state.sharedSecret || !state.challenge) { + return { + newState: { + ...state, + status: 'failed', + error: 'Invalid pairing state', + }, + }; + } + + const isValid = verifyChallengeResponse( + state.challenge, + msg.payload.response, + state.sharedSecret + ); + + if (isValid) { + const confirmMsg = createPairConfirm(state.localDevice); + return { + newState: { + ...state, + status: 'success', + remoteDevice: msg.payload.deviceInfo, + }, + response: confirmMsg, + }; + } else { + const rejectMsg = createPairReject('Passphrase mismatch'); + return { + newState: { + ...state, + status: 'failed', + error: 'Passphrase mismatch', + }, + response: rejectMsg, + }; + } + } + + case 'pair_confirm': { + return { + newState: { + ...state, + status: 'success', + }, + }; + } + + case 'pair_reject': { + const msg = message as PairRejectMessage; + return { + newState: { + ...state, + status: 'failed', + error: msg.payload.reason, + }, + }; + } + + default: + return { newState: state }; + } +} + +/** + * Create a PairedDevice from successful pairing + */ +export function createPairedDevice(state: PairingState): PairedDevice | null { + if (state.status !== 'success' || !state.remoteDevice || !state.sharedSecret) { + return null; + } + + return { + ...state.remoteDevice, + sharedSecret: state.sharedSecret, + pairedAt: Date.now(), + }; +} + +/** + * Check if a device is already paired + */ +export function isPaired(deviceId: string, pairedDevices: PairedDevice[]): boolean { + return pairedDevices.some((d) => d.id === deviceId); +} + +/** + * Get a paired device by ID + */ +export function getPairedDevice( + deviceId: string, + pairedDevices: PairedDevice[] +): PairedDevice | undefined { + return pairedDevices.find((d) => d.id === deviceId); +} + +/** + * Update last connected time for a paired device + */ +export function updateLastConnected( + deviceId: string, + pairedDevices: PairedDevice[] +): PairedDevice[] { + return pairedDevices.map((d) => + d.id === deviceId ? { ...d, lastConnected: Date.now() } : d + ); +} + +/** + * Remove a paired device + */ +export function removePairedDevice( + deviceId: string, + pairedDevices: PairedDevice[] +): PairedDevice[] { + return pairedDevices.filter((d) => d.id !== deviceId); +} diff --git a/packages/sync/src/portable/bundle.ts b/packages/sync/src/portable/bundle.ts new file mode 100644 index 00000000..7005aa00 --- /dev/null +++ b/packages/sync/src/portable/bundle.ts @@ -0,0 +1,73 @@ +import { BUNDLE_FORMAT, BUNDLE_VERSION, BundleError } from './types'; +import type { PortableBundle } from './types'; + +export interface CreateBundleInput { + data: T; + /** ISO timestamp — the caller supplies the clock; this code takes none. */ + exportedAt: string; + /** Defaults to the current BUNDLE_VERSION. */ + version?: number; +} + +/** Assemble a versioned bundle around an app payload. Pure. */ +export function createBundle(input: CreateBundleInput): PortableBundle { + return { + format: BUNDLE_FORMAT, + version: input.version ?? BUNDLE_VERSION, + exportedAt: input.exportedAt, + data: input.data, + }; +} + +/** Serialize a bundle to the JSON text written to a file / sent over the wire. */ +export function serializeBundle(bundle: PortableBundle): string { + return JSON.stringify(bundle, null, 2); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export interface ParseBundleOptions { + /** Expected envelope version; a mismatch throws a user-facing BundleError. */ + expectedVersion?: number; + /** + * App payload validator. Receives the raw `data` and returns the typed + * payload (or throws a BundleError with a user-facing message). The shared + * core validates only the envelope; payload shape is the app's business. + */ + validateData: (data: unknown) => T; +} + +/** + * Parse + validate bundle JSON. Checks the format discriminator and version — + * the shared envelope contract — then hands the payload to the app-supplied + * validator. Throws BundleError with a user-facing message on any problem. + */ +export function parseBundle(raw: string, opts: ParseBundleOptions): PortableBundle { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new BundleError('This file is not a valid backup (could not read it as JSON).'); + } + if (!isObject(parsed)) { + throw new BundleError('This file is not a valid Off Grid backup.'); + } + if (parsed.format !== BUNDLE_FORMAT) { + throw new BundleError('This file is not an Off Grid backup.'); + } + const expected = opts.expectedVersion ?? BUNDLE_VERSION; + if (parsed.version !== expected) { + throw new BundleError( + `This backup was made by a different app version (backup v${String(parsed.version)}, expected v${expected}).`, + ); + } + const data = opts.validateData((parsed as { data: unknown }).data); + return { + format: BUNDLE_FORMAT, + version: expected, + exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : '', + data, + }; +} diff --git a/packages/sync/src/portable/engine.ts b/packages/sync/src/portable/engine.ts new file mode 100644 index 00000000..4c0c48a0 --- /dev/null +++ b/packages/sync/src/portable/engine.ts @@ -0,0 +1,153 @@ +import { createBundle, serializeBundle, parseBundle } from './bundle'; + +// The shared export / import ENGINE. It owns the flow every Off Grid app needs — +// collect the app's data, move the files it points at into a zip alongside a +// backup.json envelope, hand the zip to a sink; and on restore, unpack the zip, +// copy the files back onto the device, rewrite the payload's paths, and apply it +// additively. Zero platform code: store/DB access, file/zip I/O, path rewriting, +// and the clock are all injected through the ports below, so the flow (and its +// correctness) is written and tested once here and inherited by mobile + desktop. + +/** A file the payload points at, paired with the bundle-relative key it travels under. */ +export interface FileRef { + /** Path INSIDE the bundle, e.g. "files/img-0.png". */ + key: string; + /** On-device absolute path/uri to read from on export (or read back on import). */ + sourcePath: string; +} + +/** + * Pure mapping between a payload's on-device file paths and bundle-relative keys. + * The app implements this because only it knows which fields carry file paths; + * it stays pure (no I/O) so it is unit-testable. `extract` (export) lists the + * files and returns a copy of the payload with paths replaced by keys; `listKeys` + * reads the keys back out of a keyed payload (import); `restore` swaps keys for + * the real restored paths. + */ +export interface FileMapper { + extract(data: T): { files: FileRef[]; keyed: T }; + listKeys(keyed: T): string[]; + restore(keyed: T, keyToPath: Record): T; +} + +/** + * Host filesystem + archive I/O. All the platform-specific, on-device work of + * assembling a zip and reading it back. Absolute paths throughout. + */ +export interface ArchivePort { + /** A fresh empty directory to assemble a bundle in. */ + stageDir(): Promise; + writeText(absPath: string, text: string): Promise; + readText(absPath: string): Promise; + /** Copy a source file to an absolute dest path, creating parent dirs. */ + copyInto(srcPath: string, destAbsPath: string): Promise; + /** Zip the CONTENTS of stageDir into an archive; return its path. */ + pack(stageDir: string, suggestedName: string): Promise; + /** Unzip an archive into a fresh dir; return that dir. */ + unpack(archivePath: string): Promise; + /** The permanent on-device path a restored file with this key should live at. */ + restorePathFor(key: string): string; + join(...parts: string[]): string; +} + +/** + * Host access to the app's data. Every store / SQLite read and every additive + * write lives behind this port. `T` = the app's payload shape; `S` = its restore + * summary. + */ +export interface BackupDataPort { + collectAll(): Promise; + collectProject(projectId: string): Promise; + collectConversation(conversationId: string): Promise; + validate(data: unknown): T; + apply(data: T): Promise; +} + +/** Host sink: how the finished bundle FILE leaves the device and how one is picked back. */ +export interface BackupSink { + /** Hand a finished bundle file (already written at absPath) to the user. */ + deliverFile(absPath: string, suggestedName: string): Promise; + /** Pick a bundle file; return a readable local path to it, or null if cancelled. */ + pickFile(): Promise; +} + +/** Turn an ISO timestamp into a filename-safe stamp. Pure. */ +export const fileStamp = (iso: string): string => iso.replaceAll(/[:.]/g, '-'); + +/** The name of the envelope entry inside every bundle zip. */ +export const ENVELOPE_ENTRY = 'backup.json'; + +/** + * The engine. Constructed with the four ports + an injected clock (`now`) so the + * core stays free of `Date`. Export assembles a zip (envelope + files) and + * delivers it; import unpacks a zip, restores files, and applies additively. + */ +export class BackupEngine { + constructor( + private readonly data: BackupDataPort, + private readonly files: FileMapper, + private readonly archive: ArchivePort, + private readonly sink: BackupSink, + private readonly now: () => string, + ) {} + + private async exportBundle(prefix: string, payload: T | null): Promise { + if (payload == null) return null; + const { files: refs, keyed } = this.files.extract(payload); + const exportedAt = this.now(); + const stage = await this.archive.stageDir(); + await this.archive.writeText( + this.archive.join(stage, ENVELOPE_ENTRY), + serializeBundle(createBundle({ data: keyed, exportedAt })), + ); + for (const ref of refs) { + await this.archive.copyInto(ref.sourcePath, this.archive.join(stage, ref.key)); + } + const name = `${prefix}-${fileStamp(exportedAt)}.zip`; + const zipPath = await this.archive.pack(stage, name); + return this.sink.deliverFile(zipPath, name); + } + + /** Export everything. */ + exportAll = (): Promise => + this.data.collectAll().then((d) => this.exportBundle('offgrid-backup', d)); + + /** Export one project (its chats + knowledge base). Null if the project is gone. */ + exportProject = (projectId: string): Promise => + this.data.collectProject(projectId).then((d) => this.exportBundle('offgrid-project', d)); + + /** Export one conversation, self-contained. Null if the conversation is gone. */ + exportConversation = (conversationId: string): Promise => + this.data.collectConversation(conversationId).then((d) => this.exportBundle('offgrid-chat', d)); + + /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ + async import(): Promise { + const picked = await this.sink.pickFile(); + if (picked == null) return null; + return this.importPath(picked); + } + + /** + * Restore + apply a bundle at a known local path, WITHOUT the picker. This is + * the receiver side of device-to-device sharing: a peer pushes a bundle file, + * the transport saves it locally, and this applies it — the same unpack → + * restore-files → rewrite → apply flow `import()` uses after picking. + */ + async importPath(archivePath: string): Promise { + const dir = await this.archive.unpack(archivePath); + const raw = await this.archive.readText(this.archive.join(dir, ENVELOPE_ENTRY)); + const bundle = parseBundle(raw, { validateData: (d) => this.data.validate(d) }); + const keyed = bundle.data; + + // Copy every bundled file back onto the device, then rewrite the payload's + // keys to the real restored paths so apply() writes valid on-device paths. + const keyToPath: Record = {}; + for (const key of this.files.listKeys(keyed)) { + const dest = this.archive.restorePathFor(key); + await this.archive.copyInto(this.archive.join(dir, key), dest); + keyToPath[key] = dest; + } + const restored = this.files.restore(keyed, keyToPath); + return this.data.apply(restored); + } +} diff --git a/packages/sync/src/portable/index.ts b/packages/sync/src/portable/index.ts new file mode 100644 index 00000000..33853204 --- /dev/null +++ b/packages/sync/src/portable/index.ts @@ -0,0 +1,9 @@ +// @offgrid/sync/portable — the portable-bundle foundation: a versioned, +// app-agnostic envelope, the additive-merge import rule, and (de)serialization. +// Pure logic, zero I/O. Consumed by Off Grid Mobile and Desktop; file I/O and +// compression are the host app's job (injected adapters), never this module's. + +export * from './types'; +export * from './merge'; +export * from './bundle'; +export * from './engine'; diff --git a/packages/sync/src/portable/merge.ts b/packages/sync/src/portable/merge.ts new file mode 100644 index 00000000..39c8b5b3 --- /dev/null +++ b/packages/sync/src/portable/merge.ts @@ -0,0 +1,23 @@ +import type { HasId, MergeResult } from './types'; + +/** + * Additive, non-destructive merge by id — the single import rule for every + * record type. Incoming items whose id is not already present (and not + * duplicated within the incoming batch itself) are appended; existing ids are + * left untouched. It NEVER deletes or overwrites, so importing a backup can + * only ever add what is missing. Defined once here and reused by every store's + * import path so the semantics can never drift between record types. + */ +export function mergeById(existing: T[], incoming: T[]): MergeResult { + const existingIds = new Set(existing.map((item) => item.id)); + const additions: T[] = []; + const addedIds: string[] = []; + const seenIncoming = new Set(); + for (const item of incoming) { + if (existingIds.has(item.id) || seenIncoming.has(item.id)) continue; + seenIncoming.add(item.id); + additions.push(item); + addedIds.push(item.id); + } + return { merged: [...existing, ...additions], addedIds }; +} diff --git a/packages/sync/src/portable/types.ts b/packages/sync/src/portable/types.ts new file mode 100644 index 00000000..6f008bb3 --- /dev/null +++ b/packages/sync/src/portable/types.ts @@ -0,0 +1,49 @@ +// Portable bundle — the versioned, app-agnostic envelope + merge contract that +// backs export / import today and, later, device-to-device transfer of the SAME +// bundle over this package's transport (export -> transfer -> import). +// +// The envelope machinery lives here so Off Grid Desktop and Off Grid Mobile +// share one on-disk/on-wire format and can recognize each other's bundles. +// Each app's *payload* differs (their Project / Conversation shapes are not the +// same, and one may carry workspaces the other lacks), so a bundle is GENERIC +// over its `data`. This package owns format + version + merge + (de)serialize; +// the app owns its payload section types and their validation. + +/** Stable format discriminator. A file whose `format` differs is rejected. */ +export const BUNDLE_FORMAT = 'offgrid-backup' as const; + +/** Envelope version. Bump when the envelope shape changes incompatibly. */ +export const BUNDLE_VERSION = 1 as const; + +/** Anything mergeable by stable id (projects, conversations, images, ...). */ +export interface HasId { + id: string; +} + +export interface MergeResult { + /** existing followed by the newly-added items, in incoming order. */ + merged: T[]; + /** ids that were actually added (not already present). */ + addedIds: string[]; +} + +/** + * A portable bundle: a stable header plus an app-defined `data` payload. + * `T` is the app's payload shape. The header is identical across apps so a + * bundle produced by one surface is recognizable by another. + */ +export interface PortableBundle { + format: typeof BUNDLE_FORMAT; + version: number; + /** ISO timestamp of export. */ + exportedAt: string; + data: T; +} + +/** Thrown when a file is not a valid/compatible bundle. Message is user-facing. */ +export class BundleError extends Error { + constructor(message: string) { + super(message); + this.name = 'BundleError'; + } +} diff --git a/packages/sync/src/protocol/index.ts b/packages/sync/src/protocol/index.ts new file mode 100644 index 00000000..c00a62e3 --- /dev/null +++ b/packages/sync/src/protocol/index.ts @@ -0,0 +1,287 @@ +import type { Message } from '../types'; +import { encrypt, decrypt, encodeBase64, decodeBase64 } from '../crypto'; + +// Protocol version for compatibility checking +export const PROTOCOL_VERSION = '1.0.0'; + +// Message header format: [length (4 bytes)] [type (1 byte)] [payload] +export const HEADER_LENGTH = 5; +export const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB max message + +// Message type byte codes +export const MESSAGE_TYPE_CODES: Record = { + ping: 0x01, + pong: 0x02, + pair_request: 0x10, + pair_challenge: 0x11, + pair_response: 0x12, + pair_confirm: 0x13, + pair_reject: 0x14, + hello: 0x15, + text: 0x20, + file_request: 0x30, + file_accept: 0x31, + file_reject: 0x32, + file_chunk: 0x33, + file_complete: 0x34, + file_ack: 0x35, + app: 0x40, + error: 0xff, +}; + +/** Build a reconnect hello identifying the local device. */ +export function createHello(deviceInfo: unknown): Message { + return { + type: 'hello', + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { deviceInfo }, + }; +} + +/** Build a generic encrypted application message for a named channel. */ +export function createAppMessage(channel: string, data: unknown): Message { + return { + type: 'app', + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { channel, data }, + }; +} + +// Reverse lookup +export const MESSAGE_CODE_TYPES: Record = Object.fromEntries( + Object.entries(MESSAGE_TYPE_CODES).map(([k, v]) => [v, k]) +); + +/** + * Serialize a message to a buffer for transmission + */ +export function serializeMessage(message: Message): Uint8Array { + const jsonPayload = JSON.stringify(message); + const payloadBytes = new TextEncoder().encode(jsonPayload); + const typeCode = MESSAGE_TYPE_CODES[message.type] || 0xff; + + // Create buffer: 4 bytes length + 1 byte type + payload + const buffer = new Uint8Array(HEADER_LENGTH + payloadBytes.length); + const view = new DataView(buffer.buffer); + + // Write length (big-endian) + view.setUint32(0, payloadBytes.length, false); + + // Write type code + buffer[4] = typeCode; + + // Write payload + buffer.set(payloadBytes, HEADER_LENGTH); + + return buffer; +} + +/** + * Deserialize a message from a buffer + */ +export function deserializeMessage(buffer: Uint8Array): Message | null { + if (buffer.length < HEADER_LENGTH) { + return null; + } + + const view = new DataView(buffer.buffer, buffer.byteOffset); + const payloadLength = view.getUint32(0, false); + + if (buffer.length < HEADER_LENGTH + payloadLength) { + return null; + } + + const payloadBytes = buffer.slice(HEADER_LENGTH, HEADER_LENGTH + payloadLength); + const jsonPayload = new TextDecoder().decode(payloadBytes); + + try { + return JSON.parse(jsonPayload) as Message; + } catch { + return null; + } +} + +/** + * Get the expected message length from a header + */ +export function getMessageLength(header: Uint8Array): number | null { + if (header.length < 4) { + return null; + } + + const view = new DataView(header.buffer, header.byteOffset); + const length = view.getUint32(0, false); + + if (length > MAX_MESSAGE_SIZE) { + return null; // Message too large + } + + return HEADER_LENGTH + length; +} + +/** + * Encrypt a message for transmission over an established connection + */ +export function encryptMessage( + message: Message, + secretKey: string +): { encrypted: Uint8Array; nonce: string } { + const serialized = serializeMessage(message); + const { encrypted, nonce } = encrypt(serialized, secretKey); + return { + encrypted: decodeBase64(encrypted), + nonce, + }; +} + +/** + * Decrypt a received encrypted message + */ +export function decryptMessage( + encrypted: Uint8Array, + nonce: string, + secretKey: string +): Message | null { + const decrypted = decrypt(encodeBase64(encrypted), nonce, secretKey); + if (!decrypted) return null; + return deserializeMessage(decrypted); +} + +/** + * Message frame for encrypted transmission + * Format: [nonce length (1 byte)] [nonce] [encrypted data] + */ +export function createEncryptedFrame(encrypted: Uint8Array, nonce: string): Uint8Array { + const nonceBytes = decodeBase64(nonce); + const frame = new Uint8Array(1 + nonceBytes.length + encrypted.length); + + frame[0] = nonceBytes.length; + frame.set(nonceBytes, 1); + frame.set(encrypted, 1 + nonceBytes.length); + + return frame; +} + +/** + * Parse an encrypted frame + */ +export function parseEncryptedFrame( + frame: Uint8Array +): { encrypted: Uint8Array; nonce: string } | null { + if (frame.length < 2) return null; + + const nonceLength = frame[0]; + if (frame.length < 1 + nonceLength) return null; + + const nonceBytes = frame.slice(1, 1 + nonceLength); + const encrypted = frame.slice(1 + nonceLength); + + return { + encrypted, + nonce: encodeBase64(nonceBytes), + }; +} + +/** + * Buffer for accumulating incoming data and extracting complete messages + */ +export class MessageBuffer { + private buffer: Uint8Array = new Uint8Array(0); + + /** + * Add data to the buffer + */ + append(data: Uint8Array): void { + const newBuffer = new Uint8Array(this.buffer.length + data.length); + newBuffer.set(this.buffer); + newBuffer.set(data, this.buffer.length); + this.buffer = newBuffer; + } + + /** + * Try to extract a complete message from the buffer + */ + extractMessage(): Message | null { + const length = getMessageLength(this.buffer); + if (length === null || this.buffer.length < length) { + return null; + } + + const messageBytes = this.buffer.slice(0, length); + this.buffer = this.buffer.slice(length); + + return deserializeMessage(messageBytes); + } + + /** + * Extract all complete messages from the buffer + */ + extractAllMessages(): Message[] { + const messages: Message[] = []; + let message: Message | null; + + while ((message = this.extractMessage()) !== null) { + messages.push(message); + } + + return messages; + } + + /** + * Get current buffer size + */ + get size(): number { + return this.buffer.length; + } + + /** + * Clear the buffer + */ + clear(): void { + this.buffer = new Uint8Array(0); + } +} + +/** + * Create a ping message + */ +export function createPingMessage(): Message { + return { + type: 'ping', + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + }; +} + +/** + * Create a pong message in response to a ping + */ +export function createPongMessage(pingId: string): Message { + return { + type: 'pong', + id: pingId, + timestamp: Date.now(), + }; +} + +/** + * Create an error message + */ +export function createErrorMessage( + code: string, + errorMessage: string, + originalMessageId?: string +): Message { + return { + type: 'error', + id: Math.random().toString(36).substring(2, 10), + timestamp: Date.now(), + payload: { + code, + message: errorMessage, + originalMessageId, + }, + }; +} diff --git a/packages/sync/src/state-sync.ts b/packages/sync/src/state-sync.ts new file mode 100644 index 00000000..b288a232 --- /dev/null +++ b/packages/sync/src/state-sync.ts @@ -0,0 +1,42 @@ +// State replication protocol over the @offgrid/sync 'state' app channel. +// PURE / platform-agnostic (same portability rationale as oplog.ts). +// +// Gossip is minimal and convergent: +// • on connect, each peer sends `have` (its version vector) +// • on receiving `have`, reply with `ops` the peer is missing +// • on receiving `ops`, ingest them (and the materializer updates real tables) +// • on a local change, broadcast `ops:[op]` to connected peers +// One round-trip each direction reconciles two devices; live ops stream after. + +import type { Op, VersionVector } from './oplog'; +import type { OpLog } from './oplog'; + +export type StateMsg = { t: 'have'; vv: VersionVector } | { t: 'ops'; ops: Op[] }; + +export interface StateSyncOptions { + oplog: OpLog; + /** Send a state message to one peer (host wires → sendApp(id,'state',msg)). */ + send: (deviceId: string, msg: StateMsg) => void; +} + +export class StateSync { + constructor(private readonly opts: StateSyncOptions) {} + + /** A peer connected: advertise our version vector so it can backfill us; we + * backfill it when its own `have` arrives. */ + onConnect(deviceId: string): void { + this.opts.send(deviceId, { t: 'have', vv: this.opts.oplog.versionVector() }); + } + + /** Inbound message on the 'state' channel from a paired peer. */ + onMessage(deviceId: string, data: unknown): void { + const msg = data as StateMsg | undefined; + if (!msg || typeof msg !== 'object' || !('t' in msg)) return; + if (msg.t === 'have') { + const missing = this.opts.oplog.opsSince(msg.vv); + if (missing.length) this.opts.send(deviceId, { t: 'ops', ops: missing }); + } else if (msg.t === 'ops' && Array.isArray(msg.ops)) { + this.opts.oplog.ingest(msg.ops); + } + } +} diff --git a/packages/sync/src/transfer/index.ts b/packages/sync/src/transfer/index.ts new file mode 100644 index 00000000..7f8e7b36 --- /dev/null +++ b/packages/sync/src/transfer/index.ts @@ -0,0 +1,512 @@ +import type { + TextTransfer, + FileTransfer, + TransferProgress, + TextMessage, + FileRequestMessage, + FileAcceptMessage, + FileRejectMessage, + FileChunkMessage, + FileCompleteMessage, + FileAckMessage, + DeviceInfo, +} from '../types'; +import { + generateMessageId, + encrypt, + decrypt, + calculateChecksum, + verifyChecksum, + encodeBase64, + decodeBase64, +} from '../crypto'; + +// Constants +export const CHUNK_SIZE = 64 * 1024; // 64KB chunks +export const MAX_TEXT_LENGTH = 1024 * 1024; // 1MB max text + +/** + * Create a text transfer record + */ +export function createTextTransfer( + content: string, + device: DeviceInfo, + direction: 'send' | 'receive' +): TextTransfer { + return { + id: generateMessageId(), + type: 'text', + timestamp: Date.now(), + direction, + deviceId: device.id, + deviceName: device.name, + content, + }; +} + +/** + * Create a file transfer record + */ +export function createFileTransfer( + fileName: string, + fileSize: number, + mimeType: string, + device: DeviceInfo, + direction: 'send' | 'receive', + durationMs?: number +): FileTransfer { + const transfer: FileTransfer = { + id: generateMessageId(), + type: 'file', + timestamp: Date.now(), + direction, + deviceId: device.id, + deviceName: device.name, + fileName, + fileSize, + mimeType, + }; + if (durationMs != null && durationMs > 0) { + transfer.durationMs = durationMs; + transfer.speedBytesPerSec = Math.round((fileSize / durationMs) * 1000); + } + return transfer; +} + +/** + * Create a text message + */ +export function createTextMessage(content: string): TextMessage { + return { + type: 'text', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + content, + }, + }; +} + +/** + * Create an encrypted text message + */ +export function createEncryptedTextMessage( + content: string, + secretKey: string +): { message: TextMessage; nonce: string } { + const { encrypted, nonce } = encrypt(content, secretKey); + return { + message: createTextMessage(encrypted), + nonce, + }; +} + +/** + * Decrypt a text message + */ +export function decryptTextMessage( + message: TextMessage, + nonce: string, + secretKey: string +): string | null { + const decrypted = decrypt(message.payload.content, nonce, secretKey); + if (!decrypted) return null; + return new TextDecoder().decode(decrypted); +} + +/** + * Create a file request message + */ +export function createFileRequest( + fileName: string, + fileSize: number, + mimeType: string, + fileData: Uint8Array +): FileRequestMessage { + return { + type: 'file_request', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum: calculateChecksum(fileData), + }, + }; +} + +/** + * Create a file request message with a pre-computed checksum (for streaming/large files). + * Avoids needing the entire file in memory. + */ +export function createFileRequestStreaming( + fileName: string, + fileSize: number, + mimeType: string, + checksum: string +): FileRequestMessage { + return { + type: 'file_request', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum, + }, + }; +} + +/** + * Create a file complete message with a pre-computed checksum (for streaming/large files). + * Avoids needing the entire file in memory. + */ +export function createFileCompleteStreaming(requestId: string, checksum: string): FileCompleteMessage { + return { + type: 'file_complete', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + checksum, + }, + }; +} + +/** + * Create a file request message with an HTTP download URL (for large files sent via HTTP). + */ +export function createFileRequestHttp( + fileName: string, + fileSize: number, + mimeType: string, + checksum: string, + httpUrl: string +): FileRequestMessage { + return { + type: 'file_request', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + fileName, + fileSize, + mimeType, + checksum, + httpUrl, + }, + }; +} + +/** + * Create a file accept message + */ +export function createFileAccept(requestId: string): FileAcceptMessage { + return { + type: 'file_accept', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + }, + }; +} + +/** + * Create a file accept message with an HTTP upload URL (for receiving large files via HTTP). + */ +export function createFileAcceptHttp(requestId: string, uploadUrl: string): FileAcceptMessage { + return { + type: 'file_accept', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + uploadUrl, + }, + }; +} + +/** + * Create a file ack message (sent after HTTP transfer completes). + */ +export function createFileAck(requestId: string, success: boolean): FileAckMessage { + return { + type: 'file_ack', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + success, + }, + }; +} + +/** + * Create a file reject message + */ +export function createFileReject(requestId: string, reason: string): FileRejectMessage { + return { + type: 'file_reject', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + reason, + }, + }; +} + +/** + * Create a file chunk message + */ +export function createFileChunk( + requestId: string, + chunkIndex: number, + totalChunks: number, + data: Uint8Array +): FileChunkMessage { + return { + type: 'file_chunk', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + chunkIndex, + totalChunks, + data: encodeBase64(data), + }, + }; +} + +/** + * Create a file chunk message from already-base64-encoded data. + * Avoids the decode → re-encode roundtrip when data is read as base64 from disk. + */ +export function createFileChunkFromBase64( + requestId: string, + chunkIndex: number, + totalChunks: number, + base64Data: string +): FileChunkMessage { + return { + type: 'file_chunk', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + chunkIndex, + totalChunks, + data: base64Data, + }, + }; +} + +/** + * Create a file complete message + */ +export function createFileComplete(requestId: string, fileData: Uint8Array): FileCompleteMessage { + return { + type: 'file_complete', + id: generateMessageId(), + timestamp: Date.now(), + payload: { + requestId, + checksum: calculateChecksum(fileData), + }, + }; +} + +/** + * Split file data into chunks + */ +export function* chunkFile( + data: Uint8Array, + chunkSize: number = CHUNK_SIZE +): Generator<{ chunk: Uint8Array; index: number; total: number }> { + const totalChunks = Math.ceil(data.length / chunkSize); + + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, data.length); + yield { + chunk: data.slice(start, end), + index: i, + total: totalChunks, + }; + } +} + +/** + * Reassemble chunks into complete file data + */ +export function reassembleChunks( + chunks: Map, + totalChunks: number +): Uint8Array | null { + // Verify all chunks are present + if (chunks.size !== totalChunks) { + return null; + } + + // Calculate total size + let totalSize = 0; + for (let i = 0; i < totalChunks; i++) { + const chunk = chunks.get(i); + if (!chunk) return null; + totalSize += chunk.length; + } + + // Reassemble + const result = new Uint8Array(totalSize); + let offset = 0; + for (let i = 0; i < totalChunks; i++) { + const chunk = chunks.get(i)!; + result.set(chunk, offset); + offset += chunk.length; + } + + return result; +} + +/** + * Calculate transfer progress with optional speed/ETA computation + */ +export function calculateProgress( + transferId: string, + bytesTransferred: number, + totalBytes: number, + currentFile?: string, + startTime?: number +): TransferProgress { + const clampedBytes = Math.min(bytesTransferred, totalBytes); + const result: TransferProgress = { + transferId, + bytesTransferred: clampedBytes, + totalBytes, + percentage: totalBytes > 0 ? Math.min(100, Math.round((clampedBytes / totalBytes) * 100)) : 0, + currentFile, + }; + + if (startTime && startTime > 0) { + const elapsedMs = Date.now() - startTime; + result.elapsedMs = elapsedMs; + if (elapsedMs > 500 && clampedBytes > 0) { + result.speedBytesPerSec = Math.round((clampedBytes / elapsedMs) * 1000); + if (result.speedBytesPerSec > 0 && clampedBytes < totalBytes) { + const remainingBytes = totalBytes - clampedBytes; + result.etaSeconds = Math.round(remainingBytes / result.speedBytesPerSec); + } + } + } + + return result; +} + +/** + * Verify received file integrity + */ +export function verifyFileIntegrity(data: Uint8Array, expectedChecksum: string): boolean { + return verifyChecksum(data, expectedChecksum); +} + +/** + * Format file size for display + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * Format transfer speed for display + */ +export function formatTransferSpeed(bytesPerSec: number): string { + if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; + if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; + if (bytesPerSec < 1024 * 1024 * 1024) return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; + return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)} GB/s`; +} + +/** + * Format transfer duration for display + */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds.toFixed(0)}s`; +} + +/** + * Format ETA for display + */ +export function formatEta(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +/** + * Format live transfer progress info string (speed · elapsed · ETA) + */ +export function formatProgressInfo(progress: TransferProgress): string { + const parts: string[] = []; + if (progress.speedBytesPerSec != null && progress.speedBytesPerSec > 0) { + parts.push(formatTransferSpeed(progress.speedBytesPerSec)); + } + if (progress.elapsedMs != null && progress.elapsedMs >= 1000) { + parts.push(formatDuration(progress.elapsedMs) + ' elapsed'); + } + if (progress.etaSeconds != null && progress.etaSeconds > 0) { + parts.push('~' + formatEta(progress.etaSeconds) + ' left'); + } + return parts.join(' · '); +} + +/** + * Get MIME type from file extension + */ +export function getMimeType(fileName: string): string { + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + const mimeTypes: Record = { + txt: 'text/plain', + html: 'text/html', + css: 'text/css', + js: 'application/javascript', + json: 'application/json', + xml: 'application/xml', + pdf: 'application/pdf', + zip: 'application/zip', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + svg: 'image/svg+xml', + webp: 'image/webp', + mp3: 'audio/mpeg', + wav: 'audio/wav', + mp4: 'video/mp4', + webm: 'video/webm', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +} + +// Re-export for convenience +export { decodeBase64 }; diff --git a/packages/sync/src/transport.ts b/packages/sync/src/transport.ts new file mode 100644 index 00000000..46cc3b87 --- /dev/null +++ b/packages/sync/src/transport.ts @@ -0,0 +1,28 @@ +// Transport abstraction for @offgrid/sync. +// +// The engine speaks frames (see wire.ts) over a duplex byte connection. The +// host supplies the actual transport: a Node TCP server/client on desktop, a +// React Native socket module on mobile. Keeping this an interface is what makes +// the sync engine embeddable in both apps without platform code leaking in. + +/** A duplex, ordered, reliable byte stream to one remote peer. */ +export interface SyncConnection { + /** Stable id for this connection (host:port or a socket id). */ + readonly id: string; + /** Remote host/address, when the transport knows it. */ + readonly remoteHost?: string; + send(data: Uint8Array): void; + onData(cb: (data: Uint8Array) => void): void; + onClose(cb: () => void): void; + close(): void; +} + +/** Listens for inbound connections and dials outbound ones. */ +export interface TransportBridge { + /** Start accepting inbound connections on `port`. */ + listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; + /** Dial a remote peer and resolve once the byte stream is open. */ + connect(host: string, port: number): Promise; + /** Stop listening and release resources. */ + stop(): Promise; +} diff --git a/packages/sync/src/types/index.ts b/packages/sync/src/types/index.ts new file mode 100644 index 00000000..051ff1c5 --- /dev/null +++ b/packages/sync/src/types/index.ts @@ -0,0 +1,291 @@ +// Device and Discovery Types +export type DevicePlatform = 'macos' | 'windows' | 'linux' | 'android' | 'ios'; + +export interface DeviceInfo { + id: string; + name: string; + platform: DevicePlatform; + version: string; + host: string; + port: number; +} + +export interface DiscoveredDevice extends DeviceInfo { + lastSeen: number; +} + +export interface PairedDevice extends DeviceInfo { + sharedSecret: string; // Base64 encoded + pairedAt: number; + lastConnected?: number; +} + +// Pairing Types +export interface PairingChallenge { + challenge: string; // Base64 encoded random bytes + timestamp: number; +} + +export interface PairingResponse { + response: string; // Base64 encoded HMAC + deviceInfo: DeviceInfo; +} + +export type PairingStatus = 'idle' | 'waiting' | 'verifying' | 'success' | 'failed'; + +// Transfer Types +export type TransferType = 'text' | 'file' | 'files'; + +export interface TransferMetadata { + id: string; + type: TransferType; + timestamp: number; + direction: 'send' | 'receive'; + deviceId: string; + deviceName: string; +} + +export interface TextTransfer extends TransferMetadata { + type: 'text'; + content: string; +} + +export interface FileTransfer extends TransferMetadata { + type: 'file'; + fileName: string; + fileSize: number; + mimeType: string; + filePath?: string; // Local path after receiving + durationMs?: number; // Transfer duration in milliseconds + speedBytesPerSec?: number; // Transfer speed in bytes per second +} + +export interface FilesTransfer extends TransferMetadata { + type: 'files'; + files: Array<{ + fileName: string; + fileSize: number; + mimeType: string; + filePath?: string; + }>; + totalSize: number; +} + +export type Transfer = TextTransfer | FileTransfer | FilesTransfer; + +export interface TransferProgress { + transferId: string; + bytesTransferred: number; + totalBytes: number; + percentage: number; + currentFile?: string; + speedBytesPerSec?: number; + etaSeconds?: number; + elapsedMs?: number; +} + +export interface TransferQueueItem { + id: string; + fileName: string; + fileSize: number; + status: 'pending' | 'transferring' | 'completed' | 'failed'; + progress: number; + direction: 'send' | 'receive'; +} + +// Message Protocol Types +export type MessageType = + | 'ping' + | 'pong' + | 'pair_request' + | 'pair_challenge' + | 'pair_response' + | 'pair_confirm' + | 'pair_reject' + | 'hello' + | 'text' + | 'file_request' + | 'file_accept' + | 'file_reject' + | 'file_chunk' + | 'file_complete' + | 'file_ack' + | 'app' + | 'error'; + +export interface Message { + type: MessageType; + id: string; + timestamp: number; + payload?: unknown; +} + +/** Generic encrypted application message: a channel name + arbitrary payload. + * Lets features (memory sync, clipboard sync, ...) ride the paired channel + * without each needing its own protocol message type. */ +export interface AppMessage extends Message { + type: 'app'; + payload: { + channel: string; + data: unknown; + }; +} + +/** Reconnect greeting: identifies the device so an already-paired peer can + * resume with the stored shared secret, skipping the pairing handshake. */ +export interface HelloMessage extends Message { + type: 'hello'; + payload: { + deviceInfo: DeviceInfo; + }; +} + +export interface PingMessage extends Message { + type: 'ping'; +} + +export interface PongMessage extends Message { + type: 'pong'; +} + +export interface PairRequestMessage extends Message { + type: 'pair_request'; + payload: { + deviceInfo: DeviceInfo; + }; +} + +export interface PairChallengeMessage extends Message { + type: 'pair_challenge'; + payload: PairingChallenge; +} + +export interface PairResponseMessage extends Message { + type: 'pair_response'; + payload: PairingResponse; +} + +export interface PairConfirmMessage extends Message { + type: 'pair_confirm'; + payload: { + deviceInfo: DeviceInfo; + }; +} + +export interface PairRejectMessage extends Message { + type: 'pair_reject'; + payload: { + reason: string; + }; +} + +export interface TextMessage extends Message { + type: 'text'; + payload: { + content: string; + }; +} + +export interface FileRequestMessage extends Message { + type: 'file_request'; + payload: { + fileName: string; + fileSize: number; + mimeType: string; + checksum: string; + httpUrl?: string; + }; +} + +export interface FileAcceptMessage extends Message { + type: 'file_accept'; + payload: { + requestId: string; + uploadUrl?: string; + }; +} + +export interface FileRejectMessage extends Message { + type: 'file_reject'; + payload: { + requestId: string; + reason: string; + }; +} + +export interface FileChunkMessage extends Message { + type: 'file_chunk'; + payload: { + requestId: string; + chunkIndex: number; + totalChunks: number; + data: string; // Base64 encoded + }; +} + +export interface FileCompleteMessage extends Message { + type: 'file_complete'; + payload: { + requestId: string; + checksum: string; + }; +} + +export interface FileAckMessage extends Message { + type: 'file_ack'; + payload: { + requestId: string; + success: boolean; + }; +} + +export interface ErrorMessage extends Message { + type: 'error'; + payload: { + code: string; + message: string; + originalMessageId?: string; + }; +} + +// Connection Types +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; + +export type PairingStep = + | 'idle' + | 'connecting' + | 'sending_request' + | 'waiting_for_passphrase' + | 'deriving_key' + | 'sending_challenge' + | 'waiting_for_challenge' + | 'responding_to_challenge' + | 'verifying_response' + | 'confirming' + | 'success' + | 'failed'; + +export interface ConnectionState { + status: ConnectionStatus; + device?: DeviceInfo; + error?: string; + /** Verbose status message for UI display */ + statusMessage?: string; + /** Current step in the pairing process */ + pairingStep?: PairingStep; +} + +// Storage Types +export interface AppSettings { + deviceName: string; + deviceId: string; + autoAcceptFromPaired: boolean; + saveDirectory: string; + notificationsEnabled: boolean; +} + +export interface StoredData { + settings: AppSettings; + pairedDevices: PairedDevice[]; + transferHistory: Transfer[]; +} diff --git a/packages/sync/src/wire.ts b/packages/sync/src/wire.ts new file mode 100644 index 00000000..44e7ff94 --- /dev/null +++ b/packages/sync/src/wire.ts @@ -0,0 +1,126 @@ +// Wire codec for @offgrid/sync. +// +// One length-prefixed framing carries both the plaintext pairing handshake and +// the encrypted application traffic that follows it on the same stream: +// +// [4-byte big-endian length L][1-byte kind][L-1 byte body] +// kind 0x00 = plaintext : body is UTF-8 JSON of a Message +// kind 0x01 = encrypted : body is [nonceLen:1][nonce][ciphertext], +// ciphertext = encryptMessage(message) (see protocol.ts) +// +// Pairing messages are sent plaintext (no shared secret yet); everything after +// a successful pairing is encrypted with the per-pair shared secret. + +import type { Message } from './types'; +import { encryptMessage, decryptMessage } from './protocol'; +import { encodeBase64, decodeBase64 } from './crypto'; + +export const FRAME_HEADER_LENGTH = 4; +export const MAX_FRAME_SIZE = 16 * 1024 * 1024; // 16MB + +export const FRAME_KIND_PLAINTEXT = 0x00; +export const FRAME_KIND_ENCRYPTED = 0x01; + +function frame(body: Uint8Array): Uint8Array { + const out = new Uint8Array(FRAME_HEADER_LENGTH + body.length); + new DataView(out.buffer).setUint32(0, body.length, false); + out.set(body, FRAME_HEADER_LENGTH); + return out; +} + +/** Encode a plaintext (unencrypted) message frame, used for pairing. */ +export function encodePlaintextFrame(message: Message): Uint8Array { + const json = new TextEncoder().encode(JSON.stringify(message)); + const body = new Uint8Array(1 + json.length); + body[0] = FRAME_KIND_PLAINTEXT; + body.set(json, 1); + return frame(body); +} + +/** Encode an encrypted message frame using the per-pair shared secret. */ +export function encodeEncryptedFrame(message: Message, secretKey: string): Uint8Array { + const { encrypted, nonce } = encryptMessage(message, secretKey); + const nonceBytes = decodeBase64(nonce); + const body = new Uint8Array(1 + 1 + nonceBytes.length + encrypted.length); + body[0] = FRAME_KIND_ENCRYPTED; + body[1] = nonceBytes.length; + body.set(nonceBytes, 2); + body.set(encrypted, 2 + nonceBytes.length); + return frame(body); +} + +export type DecodedFrame = + | { kind: 'plaintext'; message: Message } + | { kind: 'encrypted'; message: Message }; + +/** Decode one frame body. `secretKey` is required to read encrypted frames. */ +export function decodeFrameBody(body: Uint8Array, secretKey?: string): DecodedFrame | null { + if (body.length < 1) return null; + const kind = body[0]; + const payload = body.subarray(1); + + if (kind === FRAME_KIND_PLAINTEXT) { + try { + const message = JSON.parse(new TextDecoder().decode(payload)) as Message; + return { kind: 'plaintext', message }; + } catch { + return null; + } + } + + if (kind === FRAME_KIND_ENCRYPTED) { + if (!secretKey || payload.length < 1) return null; + const nonceLen = payload[0]; + if (payload.length < 1 + nonceLen) return null; + const nonce = encodeBase64(payload.subarray(1, 1 + nonceLen)); + const encrypted = payload.subarray(1 + nonceLen); + const message = decryptMessage(encrypted, nonce, secretKey); + return message ? { kind: 'encrypted', message } : null; + } + + return null; +} + +/** + * Accumulates incoming bytes and yields complete frame bodies. The shared + * secret can be set once pairing succeeds so later encrypted frames decode. + */ +export class FrameBuffer { + private buffer: Uint8Array = new Uint8Array(0); + + append(data: Uint8Array): void { + const next = new Uint8Array(this.buffer.length + data.length); + next.set(this.buffer); + next.set(data, this.buffer.length); + this.buffer = next; + } + + /** Pull the next complete frame body, or null if none is fully buffered. */ + private nextBody(): Uint8Array | null { + if (this.buffer.length < FRAME_HEADER_LENGTH) return null; + const len = new DataView( + this.buffer.buffer, + this.buffer.byteOffset + ).getUint32(0, false); + if (len > MAX_FRAME_SIZE) { + // Corrupt/oversized: drop the buffer to avoid getting stuck. + this.buffer = new Uint8Array(0); + return null; + } + if (this.buffer.length < FRAME_HEADER_LENGTH + len) return null; + const body = this.buffer.slice(FRAME_HEADER_LENGTH, FRAME_HEADER_LENGTH + len); + this.buffer = this.buffer.slice(FRAME_HEADER_LENGTH + len); + return body; + } + + /** Decode all complete frames currently buffered. */ + drain(secretKey?: string): DecodedFrame[] { + const out: DecodedFrame[] = []; + let body: Uint8Array | null; + while ((body = this.nextBody()) !== null) { + const decoded = decodeFrameBody(body, secretKey); + if (decoded) out.push(decoded); + } + return out; + } +} diff --git a/packages/sync/test/cap.test.mjs b/packages/sync/test/cap.test.mjs new file mode 100644 index 00000000..eb7f2abb --- /dev/null +++ b/packages/sync/test/cap.test.mjs @@ -0,0 +1,85 @@ +// Phase 1.4: device cap (open-core 2 free / 3+ paid) refuses a new pairing +// past the limit on the accepting side, and a pro entitlement lifts it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import pkg from '../dist/index.js'; +const { SyncEngine, policyFor, FREE_DEVICE_CAP } = pkg; + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +function makeNetwork() { + const listeners = new Map(); + const makePipe = () => { + const ends = {}; + const mk = (self, peer) => { + let onData = null; + const onClose = []; + return { + _deliver: (d) => onData && onData(d), + _close: () => onClose.forEach((f) => f()), + id: self, + send: (data) => queueMicrotask(() => ends[peer]._deliver(data)), + onData: (cb) => (onData = cb), + onClose: (cb) => onClose.push(cb), + close: () => ends[peer]._close(), + }; + }; + ends.client = mk('client', 'server'); + ends.server = mk('server', 'client'); + return ends; + }; + return { + listen: async (port, onConnection) => listeners.set(port, onConnection), + connect: async (_h, port) => { + const cb = listeners.get(port); + if (!cb) throw new Error('no listener'); + const ends = makePipe(); + cb(ends.server); + return ends.client; + }, + stop: async () => listeners.clear(), + }; +} + +const dev = (id) => ({ id, name: id, platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }); + +test(`free tier (cap ${FREE_DEVICE_CAP}) refuses a 3rd new device; pro lifts it`, async () => { + const transport = makeNetwork(); + + // Host A is at the free cap (already has 2 paired devices). + let failReason; + const hostFree = new SyncEngine({ + localDevice: dev('host'), + transport, + getPassphrase: () => 'pw', + cap: { policy: policyFor(false), pairedCount: () => FREE_DEVICE_CAP, isKnown: () => false }, + onPairingFailed: (_d, reason) => (failReason = reason), + }); + await hostFree.start(9001); + + let cPaired = false; + const devC = new SyncEngine({ localDevice: dev('dev-c'), transport, onPairingFailed: () => {}, onPaired: () => (cPaired = true) }); + await devC.pair(dev('host'), 'pw'); + await delay(50); + + assert.equal(cPaired, false, 'a 3rd new device must be refused at the free cap'); + assert.equal(failReason, 'device_cap_reached'); + await hostFree.stop(); + + // Pro entitlement lifts the cap. + const transport2 = makeNetwork(); + let dPaired = false; + const hostPro = new SyncEngine({ + localDevice: dev('host2'), + transport: transport2, + getPassphrase: () => 'pw', + cap: { policy: policyFor(true), pairedCount: () => 5, isKnown: () => false }, + onPaired: () => {}, + }); + await hostPro.start(9001); + const devD = new SyncEngine({ localDevice: dev('dev-d'), transport: transport2, onPaired: () => (dPaired = true) }); + await devD.pair(dev('host2'), 'pw'); + await delay(50); + assert.equal(dPaired, true, 'pro entitlement allows pairing beyond the free cap'); + await hostPro.stop(); +}); diff --git a/packages/sync/test/discovery.test.mjs b/packages/sync/test/discovery.test.mjs new file mode 100644 index 00000000..896ee221 --- /dev/null +++ b/packages/sync/test/discovery.test.mjs @@ -0,0 +1,40 @@ +// Real mDNS discovery on the local interface: advertise one device, browse from +// another, confirm it is found with the right TXT data. Needs Local Network +// permission on macOS. node:test has no default timeout, so the 6s wait is fine. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import disc from '../dist/adapters/node-discovery.js'; +const { NodeDiscovery } = disc; + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +test('advertise + browse discovers a device over mDNS', async () => { + const advertiser = new NodeDiscovery(); + const browser = new NodeDiscovery(); + let found; + browser.onDeviceFound((d) => { + if (d.id === 'dev-adv') found = d; + }); + + await browser.start(); + await advertiser.advertise({ + id: 'dev-adv', + name: 'Advertised Mac', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 9999, + }); + + // Poll for up to ~6s for the multicast announcement to land. + for (let i = 0; i < 30 && !found; i++) await delay(200); + + await advertiser.stop(); + await browser.stop(); + + assert.ok(found, 'advertised device should be discovered'); + assert.equal(found.id, 'dev-adv'); + assert.equal(found.name, 'Advertised Mac'); + assert.equal(found.port, 9999); + assert.ok(found.lastSeen > 0); +}); diff --git a/packages/sync/test/handshake.test.mjs b/packages/sync/test/handshake.test.mjs new file mode 100644 index 00000000..158b2b72 --- /dev/null +++ b/packages/sync/test/handshake.test.mjs @@ -0,0 +1,101 @@ +// Smoke test: two SyncEngines pair over an in-memory transport, then exchange +// an encrypted application message. Verifies the handshake + wire codec end to +// end without sockets. Run: node --test packages/sync/test/ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +// Import the CJS build: bundlers (vite/metro) resolve the ESM build's +// tweetnacl-util named imports, but Node's strict ESM loader does not, so the +// CJS bundle is the reliable target for a direct node --test run. +import pkg from '../dist/index.js'; +const { SyncEngine, createTextMessage } = pkg; + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Linked in-memory connection pair: each end's send() delivers to the other's +// onData on a microtask, mimicking an ordered byte stream. +function makePipe() { + const ends = {}; + const mk = (self, peer) => { + let onData = null; + const onClose = []; + return { + _deliver: (d) => onData && onData(d), + _close: () => onClose.forEach((f) => f()), + id: self, + send: (data) => queueMicrotask(() => ends[peer]._deliver(data)), + onData: (cb) => (onData = cb), + onClose: (cb) => onClose.push(cb), + close: () => ends[peer]._close(), + }; + }; + ends.client = mk('client', 'server'); + ends.server = mk('server', 'client'); + return ends; +} + +// Shared in-memory network: listen() registers by port, connect() links a pipe. +function makeNetwork() { + const listeners = new Map(); + return { + listen: async (port, onConnection) => listeners.set(port, onConnection), + connect: async (_host, port) => { + const onConnection = listeners.get(port); + if (!onConnection) throw new Error(`no listener on ${port}`); + const ends = makePipe(); + onConnection(ends.server); + return ends.client; + }, + stop: async () => listeners.clear(), + }; +} + +test('two engines pair and exchange an encrypted message', async () => { + const transport = makeNetwork(); + const devA = { id: 'dev-a', name: 'Mac A', platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }; + const devB = { id: 'dev-b', name: 'Phone B', platform: 'android', version: '1', host: '127.0.0.1', port: 9001 }; + + let aPaired, bPaired, received; + const engineA = new SyncEngine({ + localDevice: devA, + transport, + getPassphrase: () => 'correct horse battery', + onPaired: (d) => (aPaired = d), + onMessage: (id, m) => (received = { id, m }), + }); + const engineB = new SyncEngine({ + localDevice: devB, + transport, + onPaired: (d) => (bPaired = d), + }); + + await engineA.start(9001); + await engineB.pair(devA, 'correct horse battery'); + await delay(50); + + // Both sides completed pairing and agree on the peer identity. + assert.ok(aPaired, 'A should be paired'); + assert.ok(bPaired, 'B should be paired'); + assert.equal(aPaired.id, devB.id); + assert.equal(bPaired.id, devA.id); + // Independently derived shared secrets must match. + assert.equal(aPaired.sharedSecret, bPaired.sharedSecret); + assert.equal(engineA.isPaired(devB.id), true); + assert.equal(engineB.isPaired(devA.id), true); + + // Encrypted application message B -> A. + const sent = engineB.send(devA.id, createTextMessage('hello off grid')); + await delay(20); + assert.equal(sent, true); + assert.ok(received, 'A should receive the message'); + assert.equal(received.id, devB.id); + assert.equal(received.m.type, 'text'); + assert.equal(received.m.payload.content, 'hello off grid'); + + // A wrong passphrase must NOT pair. + let cPaired = false; + const devC = { id: 'dev-c', name: 'Mac C', platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }; + const engineC = new SyncEngine({ localDevice: devC, transport, onPaired: () => (cPaired = true) }); + await engineC.pair(devA, 'wrong passphrase'); + await delay(50); + assert.equal(cPaired, false, 'mismatched passphrase must not pair'); +}); diff --git a/packages/sync/test/node-tcp.test.mjs b/packages/sync/test/node-tcp.test.mjs new file mode 100644 index 00000000..2288db70 --- /dev/null +++ b/packages/sync/test/node-tcp.test.mjs @@ -0,0 +1,52 @@ +// Full C1.1 over REAL sockets: two SyncEngines pair across localhost TCP and +// exchange an encrypted message. No GUI/permissions needed, so this runs +// headlessly and verifies the actual NodeTcpTransport, not a mock. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import pkg from '../dist/index.js'; +import nodeAdapter from '../dist/adapters/node-tcp.js'; +const { SyncEngine, createTextMessage } = pkg; +const { NodeTcpTransport } = nodeAdapter; + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); +const dev = (id, port) => ({ id, name: id, platform: 'macos', version: '1', host: '127.0.0.1', port }); + +test('two engines pair and message over real localhost TCP', async () => { + const transportA = new NodeTcpTransport(); + const transportB = new NodeTcpTransport(); + + let aPaired, received; + const engineA = new SyncEngine({ + localDevice: dev('dev-a', 0), + transport: transportA, + getPassphrase: () => 'localhost-secret', + onPaired: (d) => (aPaired = d), + onMessage: (id, m) => (received = { id, m }), + }); + let bPaired; + const engineB = new SyncEngine({ + localDevice: dev('dev-b', 0), + transport: transportB, + onPaired: (d) => (bPaired = d), + }); + + await engineA.start(0); // ephemeral port + const port = transportA.boundPort; + assert.ok(port, 'server should bind a port'); + + await engineB.pair(dev('dev-a', port), 'localhost-secret'); + await delay(150); + + assert.ok(aPaired && bPaired, 'both sides paired over TCP'); + assert.equal(aPaired.id, 'dev-b'); + assert.equal(bPaired.id, 'dev-a'); + assert.equal(aPaired.sharedSecret, bPaired.sharedSecret); + + const sent = engineB.send('dev-a', createTextMessage('over the wire')); + await delay(80); + assert.equal(sent, true); + assert.equal(received?.m?.payload?.content, 'over the wire'); + + await engineA.stop(); + await engineB.stop(); +}); diff --git a/packages/sync/test/portable-engine.test.mjs b/packages/sync/test/portable-engine.test.mjs new file mode 100644 index 00000000..3a80e1a5 --- /dev/null +++ b/packages/sync/test/portable-engine.test.mjs @@ -0,0 +1,225 @@ +// Real tests for the zip-flow BackupEngine, driven through fake-but-real ports +// (an in-memory archive that records every op, a real FileMapper that rewrites a +// path field, a recording sink) — NOT mocks of the engine's own logic. Deleting +// the flow fails these: export must stage a backup.json whose payload has its +// file path swapped for a bundle KEY, copy the real file under that key, pack a +// .zip, and deliver it; import must unpack, restore each keyed file to a real +// path, rewrite the payload back to real paths, and apply it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BackupEngine, createBundle, serializeBundle } from '../dist/portable/index.js'; + +const NOW = '2026-07-09T12:00:00.000Z'; + +// A real FileMapper for the payload shape { marker, file }: `file` is the one +// file-bearing field. extract lists it + returns a keyed copy; restore swaps back. +const fileMapper = { + extract(data) { + if (data.file) { + return { files: [{ key: 'files/f0', sourcePath: data.file }], keyed: { ...data, file: 'files/f0' } }; + } + return { files: [], keyed: data }; + }, + listKeys(keyed) { + return typeof keyed.file === 'string' && keyed.file.startsWith('files/') ? [keyed.file] : []; + }, + restore(keyed, keyToPath) { + return { ...keyed, file: keyToPath[keyed.file] ?? keyed.file }; + }, +}; + +function makeData() { + const applied = []; + return { + applied, + async collectAll() { + return { marker: 'all', file: '/real/a.png' }; + }, + async collectProject(id) { + return id === 'p1' ? { marker: 'p1', file: '' } : null; // no file -> empty + }, + async collectConversation() { + return { marker: 'c', file: '' }; + }, + validate(d) { + if (typeof d !== 'object' || d === null) throw new Error('bad payload'); + return d; + }, + async apply(d) { + applied.push(d); + return { ok: true }; + }, + }; +} + +// In-memory archive recording every operation. Text files land in `writes`; +// copies are recorded; pack/unpack return synthetic paths. +function makeArchive(seed = {}) { + let n = 0; + const ops = { writes: { ...seed }, copies: [], packed: null, unpacked: null }; + return { + ops, + async stageDir() { + return `/stage${n++}`; + }, + async writeText(p, t) { + ops.writes[p] = t; + }, + async readText(p) { + return ops.writes[p]; + }, + async copyInto(src, dest) { + ops.copies.push({ src, dest }); + }, + async pack(dir, name) { + ops.packed = { dir, name }; + return `/out/${name}`; + }, + async unpack(archivePath) { + ops.unpacked = archivePath; + return '/unpack'; + }, + restorePathFor(key) { + return `/restored/${key}`; + }, + join(...parts) { + return parts.join('/'); + }, + }; +} + +function makeSink(pickPath = null) { + const delivered = []; + return { delivered, async deliverFile(absPath, name) { delivered.push({ absPath, name }); return { path: absPath }; }, async pickFile() { return pickPath; } }; +} + +test('exportAll stages a keyed envelope, copies the real file under its key, packs + delivers a .zip', async () => { + const data = makeData(); + const archive = makeArchive(); + const sink = makeSink(); + const engine = new BackupEngine(data, fileMapper, archive, sink, () => NOW); + + const result = await engine.exportAll(); + assert.deepEqual(result, { path: '/out/offgrid-backup-2026-07-09T12-00-00-000Z.zip' }); + + // backup.json in the stage carries the payload with the path swapped for a KEY. + const raw = archive.ops.writes['/stage0/backup.json']; + assert.ok(raw, 'backup.json written to the stage dir'); + const bundle = JSON.parse(raw); + assert.equal(bundle.data.file, 'files/f0'); // path rewritten to bundle key + assert.equal(bundle.data.marker, 'all'); + + // the real file was copied under its key inside the stage. + assert.deepEqual(archive.ops.copies, [{ src: '/real/a.png', dest: '/stage0/files/f0' }]); + assert.equal(archive.ops.packed.name, 'offgrid-backup-2026-07-09T12-00-00-000Z.zip'); + assert.deepEqual(sink.delivered, [{ absPath: '/out/offgrid-backup-2026-07-09T12-00-00-000Z.zip', name: 'offgrid-backup-2026-07-09T12-00-00-000Z.zip' }]); +}); + +test('a payload with no files still packs (envelope only), no copies', async () => { + const archive = makeArchive(); + const engine = new BackupEngine(makeData(), fileMapper, archive, makeSink(), () => NOW); + const result = await engine.exportProject('p1'); + assert.ok(result.path.startsWith('/out/offgrid-project-')); + assert.equal(archive.ops.copies.length, 0); +}); + +test('exportProject returns null (packs nothing) for a missing project', async () => { + const archive = makeArchive(); + const engine = new BackupEngine(makeData(), fileMapper, archive, makeSink(), () => NOW); + assert.equal(await engine.exportProject('missing'), null); + assert.equal(archive.ops.packed, null); +}); + +test('import unpacks, restores the keyed file to a real path, rewrites + applies', async () => { + // Seed the archive so unpack->/unpack has a backup.json holding a KEYED payload. + const keyedBundle = serializeBundle(createBundle({ data: { marker: 'x', file: 'files/f0' }, exportedAt: NOW })); + const archive = makeArchive({ '/unpack/backup.json': keyedBundle }); + const data = makeData(); + const engine = new BackupEngine(data, fileMapper, archive, makeSink('/picked.zip'), () => NOW); + + const summary = await engine.import(); + assert.deepEqual(summary, { ok: true }); + assert.equal(archive.ops.unpacked, '/picked.zip'); + // the bundled file was copied out to its restore path... + assert.deepEqual(archive.ops.copies, [{ src: '/unpack/files/f0', dest: '/restored/files/f0' }]); + // ...and apply received the payload with the key rewritten to that real path. + assert.deepEqual(data.applied, [{ marker: 'x', file: '/restored/files/f0' }]); +}); + +test('import returns null and applies nothing when the picker is cancelled', async () => { + const data = makeData(); + const engine = new BackupEngine(data, fileMapper, makeArchive(), makeSink(null), () => NOW); + assert.equal(await engine.import(), null); + assert.equal(data.applied.length, 0); +}); + +// A round-trip-capable in-memory archive: pack snapshots a stage dir's entries, +// unpack restores them into a fresh dir. This lets one engine's exported bundle +// be imported by another through the same archive — the "wire" between devices. +function makeRoundTripArchive() { + const files = new Map(); + const zips = new Map(); + let n = 0; + return { + files, + async stageDir() { return `/stage${n++}`; }, + async writeText(p, t) { files.set(p, t); }, + async readText(p) { return files.get(p); }, + async copyInto(src, dest) { files.set(dest, files.get(src) ?? `COPY:${src}`); }, + async pack(dir, name) { + const entries = []; + for (const [p, c] of files) if (p.startsWith(`${dir}/`)) entries.push({ rel: p.slice(dir.length + 1), content: c }); + const zip = `/out/${name}`; + zips.set(zip, entries); + return zip; + }, + async unpack(zip) { + const dir = `/unpack${n++}`; + for (const { rel, content } of zips.get(zip)) files.set(`${dir}/${rel}`, content); + return dir; + }, + restorePathFor(key) { return `/restored/${key}`; }, + join(...parts) { return parts.join('/'); }, + }; +} + +test('round-trip: a bundle exported on device A imports + applies on device B (the share model)', async () => { + const wire = makeRoundTripArchive(); // shared "wire" both devices see + + // Device A exports; capture the zip it produced. + let zipPath; + const sinkA = { async deliverFile(p) { zipPath = p; return { path: p }; }, async pickFile() { return null; } }; + await new BackupEngine(makeData(), fileMapper, wire, sinkA, () => NOW).exportAll(); + assert.ok(zipPath.endsWith('.zip')); + + // Device B receives that zip and imports it. + const dataB = makeData(); + const sinkB = { async deliverFile() { throw new Error('n/a'); }, async pickFile() { return zipPath; } }; + const summary = await new BackupEngine(dataB, fileMapper, wire, sinkB, () => NOW).import(); + + assert.deepEqual(summary, { ok: true }); + // B applied A's payload, with the bundled file restored to a real local path. + assert.equal(dataB.applied[0].marker, 'all'); + assert.equal(dataB.applied[0].file, '/restored/files/f0'); +}); + +test('importPath applies a pushed bundle from a known path, no picker (receiver side)', async () => { + const keyedBundle = serializeBundle(createBundle({ data: { marker: 'pushed', file: 'files/f0' }, exportedAt: NOW })); + const archive = makeArchive({ '/unpack/backup.json': keyedBundle }); + const data = makeData(); + const engine = new BackupEngine(data, fileMapper, archive, makeSink(null), () => NOW); + + const summary = await engine.importPath('/received/backup.zip'); + assert.deepEqual(summary, { ok: true }); + assert.equal(archive.ops.unpacked, '/received/backup.zip'); // unpacked the given path, not a picked one + assert.deepEqual(data.applied[0], { marker: 'pushed', file: '/restored/files/f0' }); +}); + +test('import surfaces a bad-payload rejection from the data port validator', async () => { + const badBundle = serializeBundle(createBundle({ data: 42, exportedAt: NOW })); + const archive = makeArchive({ '/unpack/backup.json': badBundle }); + const data = makeData(); + const engine = new BackupEngine(data, fileMapper, archive, makeSink('/x.zip'), () => NOW); + await assert.rejects(() => engine.import(), /bad payload/); + assert.equal(data.applied.length, 0); +}); diff --git a/packages/sync/test/portable.test.mjs b/packages/sync/test/portable.test.mjs new file mode 100644 index 00000000..1b15e824 --- /dev/null +++ b/packages/sync/test/portable.test.mjs @@ -0,0 +1,94 @@ +// Real tests for the portable-bundle core, exercised through the built dist +// (matching this package's node:test convention). Pure logic, so every branch +// is covered directly: the additive-merge rule (add / skip-existing / +// dedup-incoming) and the envelope round-trip + every rejection path. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mergeById, + createBundle, + serializeBundle, + parseBundle, + BUNDLE_FORMAT, + BUNDLE_VERSION, + BundleError, +} from '../dist/portable/index.js'; + +test('mergeById appends only genuinely-new ids', () => { + const existing = [{ id: 'a' }, { id: 'b' }]; + const incoming = [{ id: 'b' }, { id: 'c' }, { id: 'd' }]; + const { merged, addedIds } = mergeById(existing, incoming); + assert.deepEqual( + merged.map((x) => x.id), + ['a', 'b', 'c', 'd'], + ); + assert.deepEqual(addedIds, ['c', 'd']); +}); + +test('mergeById never removes or overwrites an existing item', () => { + const existing = [{ id: 'a', v: 1 }]; + const incoming = [{ id: 'a', v: 2 }]; // same id, different content + const { merged, addedIds } = mergeById(existing, incoming); + assert.deepEqual(merged, [{ id: 'a', v: 1 }]); // original kept, not clobbered + assert.deepEqual(addedIds, []); +}); + +test('mergeById dedups repeated ids within the incoming batch', () => { + const { merged, addedIds } = mergeById([], [{ id: 'x' }, { id: 'x' }]); + assert.deepEqual( + merged.map((x) => x.id), + ['x'], + ); + assert.deepEqual(addedIds, ['x']); +}); + +test('createBundle + serialize + parse round-trips the payload', () => { + const data = { projects: [{ id: 'p1' }], note: 'hi' }; + const bundle = createBundle({ data, exportedAt: '2026-07-09T00:00:00.000Z' }); + assert.equal(bundle.format, BUNDLE_FORMAT); + assert.equal(bundle.version, BUNDLE_VERSION); + + const parsed = parseBundle(serializeBundle(bundle), { validateData: (d) => d }); + assert.deepEqual(parsed.data, data); + assert.equal(parsed.exportedAt, '2026-07-09T00:00:00.000Z'); +}); + +test('parseBundle runs the app payload validator', () => { + const bundle = createBundle({ data: { n: 1 }, exportedAt: 'now' }); + const raw = serializeBundle(bundle); + let received; + parseBundle(raw, { + validateData: (d) => { + received = d; + return d; + }, + }); + assert.deepEqual(received, { n: 1 }); +}); + +test('parseBundle rejects non-JSON', () => { + assert.throws(() => parseBundle('{not json', { validateData: (d) => d }), BundleError); +}); + +test('parseBundle rejects a non-object payload file', () => { + assert.throws(() => parseBundle('42', { validateData: (d) => d }), BundleError); +}); + +test('parseBundle rejects a foreign format', () => { + const raw = JSON.stringify({ format: 'something-else', version: 1, data: {} }); + assert.throws(() => parseBundle(raw, { validateData: (d) => d }), BundleError); +}); + +test('parseBundle rejects an incompatible version', () => { + const raw = JSON.stringify({ format: BUNDLE_FORMAT, version: 999, data: {} }); + assert.throws( + () => parseBundle(raw, { validateData: (d) => d }), + /different app version/, + ); +}); + +test('parseBundle honors a caller-supplied expectedVersion', () => { + const raw = JSON.stringify({ format: BUNDLE_FORMAT, version: 2, exportedAt: '', data: { ok: true } }); + const parsed = parseBundle(raw, { expectedVersion: 2, validateData: (d) => d }); + assert.deepEqual(parsed.data, { ok: true }); +}); diff --git a/packages/sync/test/reconnect.test.mjs b/packages/sync/test/reconnect.test.mjs new file mode 100644 index 00000000..0076a55d --- /dev/null +++ b/packages/sync/test/reconnect.test.mjs @@ -0,0 +1,113 @@ +// Reconnect/resume: two devices that already share a secret reconnect WITHOUT +// re-running the passphrase handshake, then exchange an encrypted message. Plus +// the DiscoveryOrchestrator auto-reconnects known devices and surfaces unknowns. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import pkg from '../dist/index.js'; +const { SyncEngine, DiscoveryOrchestrator, deriveSharedSecret } = pkg; + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +function makeNetwork() { + const listeners = new Map(); + const pipe = () => { + const ends = {}; + const mk = (self, peer) => { + let onData = null; + const onClose = []; + return { + _deliver: (d) => onData && onData(d), + _close: () => onClose.forEach((f) => f()), + id: self, + send: (data) => queueMicrotask(() => ends[peer]._deliver(data)), + onData: (cb) => (onData = cb), + onClose: (cb) => onClose.push(cb), + close: () => ends[peer]._close(), + }; + }; + ends.client = mk('client', 'server'); + ends.server = mk('server', 'client'); + return ends; + }; + return { + listen: async (port, onConnection) => listeners.set(port, onConnection), + connect: async (_h, port) => { + const cb = listeners.get(port); + if (!cb) throw new Error('no listener'); + const ends = pipe(); + cb(ends.server); + return ends.client; + }, + stop: async () => listeners.clear(), + }; +} + +const dev = (id) => ({ id, name: id, platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }); + +test('reconnect resumes with stored secret, no passphrase', async () => { + const transport = makeNetwork(); + // Both sides already know the pair secret (as if previously paired). + const secret = deriveSharedSecret('the-passphrase', 'dev-a', 'dev-b'); + + let aPaired, bPaired, received; + const engineA = new SyncEngine({ + localDevice: dev('dev-a'), + transport, + getSharedSecret: (id) => (id === 'dev-b' ? secret : undefined), + onPaired: (d) => (aPaired = d), + onMessage: (id, m) => (received = m), + }); + const engineB = new SyncEngine({ + localDevice: dev('dev-b'), + transport, + getSharedSecret: (id) => (id === 'dev-a' ? secret : undefined), + onPaired: (d) => (bPaired = d), + }); + + await engineA.start(9001); + await engineB.reconnect(dev('dev-a'), secret); // no passphrase + await delay(50); + + assert.ok(aPaired && bPaired, 'both resumed without handshake'); + assert.equal(aPaired.id, 'dev-b'); + assert.equal(bPaired.id, 'dev-a'); + + const sent = engineB.send('dev-a', { type: 'text', id: '1', timestamp: 1, payload: { content: 'resumed!' } }); + await delay(20); + assert.equal(sent, true); + assert.equal(received?.payload?.content, 'resumed!'); +}); + +test('orchestrator auto-reconnects known devices, surfaces unknown', async () => { + const reconnected = []; + const discoveredUnknown = []; + // Fake discovery we can drive manually. + let foundCb; + const discovery = { + onDeviceFound: (cb) => (foundCb = cb), + onDeviceLost: () => {}, + start: async () => {}, + advertise: async () => {}, + stop: async () => {}, + }; + const engine = { + isPaired: () => false, + reconnect: async (device) => { reconnected.push(device.id); }, + }; + const orch = new DiscoveryOrchestrator({ + engine, + discovery, + localDevice: dev('me'), + getSharedSecret: (id) => (id === 'known' ? 'secret' : undefined), + onDiscovered: (d) => discoveredUnknown.push(d.id), + }); + await orch.start(); + + foundCb({ ...dev('me'), lastSeen: 1 }); // self -> ignored + foundCb({ ...dev('known'), lastSeen: 1 }); // known -> reconnect + foundCb({ ...dev('stranger'), lastSeen: 1 }); // unknown -> surfaced + await delay(10); + + assert.deepEqual(reconnected, ['known']); + assert.deepEqual(discoveredUnknown, ['stranger']); +}); diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json new file mode 100644 index 00000000..3e9a43f7 --- /dev/null +++ b/packages/sync/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From b37e5b035b1db2392fab8fdb3c4dcd024e33506d Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 17:39:04 +0530 Subject: [PATCH 002/145] feat(sync): rag_messages.uuid so chat messages can replicate + consume the engine directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both prerequisites for cross-device message sync. 1) CORE SCHEMA — rag_messages.uuid (src/main/database.ts) rag_messages.id is INTEGER AUTOINCREMENT and therefore DEVICE-LOCAL. Live sync keys records by (entity, entityId) across devices, so device A's row 7 and device B's row 7 would look like the SAME message and silently overwrite each other. The autoincrement id stays the local primary key; uuid is the cross-device identity. Includes a JS backfill for existing profiles (SQLite has no uuid()), a UNIQUE index so a replayed remote op upserts instead of duplicating, and uuid on every new insert. Mobile adds the equivalent to its message store under the same names. Verified by src/main/__tests__/rag-message-uuid.dbtest.ts against a REAL legacy profile on disk (4/4): the column appears, every pre-existing row is backfilled with a DISTINCT uuid, uniqueness is enforced, the production writer populates it, and the migration is IDEMPOTENT — rewriting uuids on each launch would orphan the record on every other device. 2) CORRECTION — stop vendoring @offgrid/sync M0 vendored shared/packages/sync into desktop/packages/sync, copying the existing @offgrid/clipboard|design|models|rag convention. shared/docs/DESKTOP_SYNC_INTEGRATION_PLAN.md §1 says explicitly NOT to duplicate this package: reference it directly, as mobile does. Now 'file:../shared/packages/sync'; the copy is removed and the plan doc records the correction. (The other desktop/packages/* copies have already silently drifted from shared/, which is the argument for the direct ref.) Full suite: 377 files / 3084 tests. The renderer integration failures seen while running this are load-dependent flakes, not regressions — a clean tree fails a DIFFERENT test, and all five pass in isolation with these changes applied. --- docs/SYNC_INTEGRATION_PLAN.md | 34 +- package-lock.json | 16 +- package.json | 2 +- .../sync/dist/adapters/node-discovery.d.mts | 17 - .../sync/dist/adapters/node-discovery.d.ts | 17 - packages/sync/dist/adapters/node-discovery.js | 117 -- .../sync/dist/adapters/node-discovery.mjs | 59 - packages/sync/dist/adapters/node-tcp.d.mts | 12 - packages/sync/dist/adapters/node-tcp.d.ts | 12 - packages/sync/dist/adapters/node-tcp.js | 82 - packages/sync/dist/adapters/node-tcp.mjs | 47 - .../sync/dist/adapters/rn-discovery.d.mts | 38 - packages/sync/dist/adapters/rn-discovery.d.ts | 38 - packages/sync/dist/adapters/rn-discovery.js | 122 -- packages/sync/dist/adapters/rn-discovery.mjs | 64 - packages/sync/dist/adapters/rn-tcp.d.mts | 52 - packages/sync/dist/adapters/rn-tcp.d.ts | 52 - packages/sync/dist/adapters/rn-tcp.js | 76 - packages/sync/dist/adapters/rn-tcp.mjs | 51 - packages/sync/dist/chunk-UMHRNOI2.mjs | 74 - packages/sync/dist/index-D7PLqM1E.d.mts | 264 --- packages/sync/dist/index-D7PLqM1E.d.ts | 264 --- packages/sync/dist/index.d.mts | 575 ------ packages/sync/dist/index.d.ts | 575 ------ packages/sync/dist/index.js | 1565 ----------------- packages/sync/dist/index.mjs | 1381 --------------- packages/sync/dist/portable/index.d.mts | 165 -- packages/sync/dist/portable/index.d.ts | 165 -- packages/sync/dist/portable/index.js | 181 -- packages/sync/dist/portable/index.mjs | 145 -- packages/sync/dist/transport-1cXLtrs5.d.mts | 22 - packages/sync/dist/transport-1cXLtrs5.d.ts | 22 - packages/sync/package.json | 61 - packages/sync/src/adapters/node-discovery.ts | 68 - packages/sync/src/adapters/node-tcp.ts | 56 - packages/sync/src/adapters/rn-discovery.ts | 103 -- packages/sync/src/adapters/rn-tcp.ts | 93 - packages/sync/src/cap.ts | 37 - packages/sync/src/crypto/index.ts | 210 --- packages/sync/src/discovery/index.ts | 115 -- packages/sync/src/engine.ts | 256 --- packages/sync/src/index.ts | 46 - packages/sync/src/oplog.ts | Bin 5390 -> 0 bytes packages/sync/src/orchestrator.ts | 61 - packages/sync/src/pairing/index.ts | 319 ---- packages/sync/src/portable/bundle.ts | 73 - packages/sync/src/portable/engine.ts | 153 -- packages/sync/src/portable/index.ts | 9 - packages/sync/src/portable/merge.ts | 23 - packages/sync/src/portable/types.ts | 49 - packages/sync/src/protocol/index.ts | 287 --- packages/sync/src/state-sync.ts | 42 - packages/sync/src/transfer/index.ts | 512 ------ packages/sync/src/transport.ts | 28 - packages/sync/src/types/index.ts | 291 --- packages/sync/src/wire.ts | 126 -- packages/sync/test/cap.test.mjs | 85 - packages/sync/test/discovery.test.mjs | 40 - packages/sync/test/handshake.test.mjs | 101 -- packages/sync/test/node-tcp.test.mjs | 52 - packages/sync/test/portable-engine.test.mjs | 225 --- packages/sync/test/portable.test.mjs | 94 - packages/sync/test/reconnect.test.mjs | 113 -- packages/sync/tsconfig.json | 12 - src/main/__tests__/rag-message-uuid.dbtest.ts | 136 ++ src/main/database.ts | 35 +- 66 files changed, 205 insertions(+), 10012 deletions(-) delete mode 100644 packages/sync/dist/adapters/node-discovery.d.mts delete mode 100644 packages/sync/dist/adapters/node-discovery.d.ts delete mode 100644 packages/sync/dist/adapters/node-discovery.js delete mode 100644 packages/sync/dist/adapters/node-discovery.mjs delete mode 100644 packages/sync/dist/adapters/node-tcp.d.mts delete mode 100644 packages/sync/dist/adapters/node-tcp.d.ts delete mode 100644 packages/sync/dist/adapters/node-tcp.js delete mode 100644 packages/sync/dist/adapters/node-tcp.mjs delete mode 100644 packages/sync/dist/adapters/rn-discovery.d.mts delete mode 100644 packages/sync/dist/adapters/rn-discovery.d.ts delete mode 100644 packages/sync/dist/adapters/rn-discovery.js delete mode 100644 packages/sync/dist/adapters/rn-discovery.mjs delete mode 100644 packages/sync/dist/adapters/rn-tcp.d.mts delete mode 100644 packages/sync/dist/adapters/rn-tcp.d.ts delete mode 100644 packages/sync/dist/adapters/rn-tcp.js delete mode 100644 packages/sync/dist/adapters/rn-tcp.mjs delete mode 100644 packages/sync/dist/chunk-UMHRNOI2.mjs delete mode 100644 packages/sync/dist/index-D7PLqM1E.d.mts delete mode 100644 packages/sync/dist/index-D7PLqM1E.d.ts delete mode 100644 packages/sync/dist/index.d.mts delete mode 100644 packages/sync/dist/index.d.ts delete mode 100644 packages/sync/dist/index.js delete mode 100644 packages/sync/dist/index.mjs delete mode 100644 packages/sync/dist/portable/index.d.mts delete mode 100644 packages/sync/dist/portable/index.d.ts delete mode 100644 packages/sync/dist/portable/index.js delete mode 100644 packages/sync/dist/portable/index.mjs delete mode 100644 packages/sync/dist/transport-1cXLtrs5.d.mts delete mode 100644 packages/sync/dist/transport-1cXLtrs5.d.ts delete mode 100644 packages/sync/package.json delete mode 100644 packages/sync/src/adapters/node-discovery.ts delete mode 100644 packages/sync/src/adapters/node-tcp.ts delete mode 100644 packages/sync/src/adapters/rn-discovery.ts delete mode 100644 packages/sync/src/adapters/rn-tcp.ts delete mode 100644 packages/sync/src/cap.ts delete mode 100644 packages/sync/src/crypto/index.ts delete mode 100644 packages/sync/src/discovery/index.ts delete mode 100644 packages/sync/src/engine.ts delete mode 100644 packages/sync/src/index.ts delete mode 100644 packages/sync/src/oplog.ts delete mode 100644 packages/sync/src/orchestrator.ts delete mode 100644 packages/sync/src/pairing/index.ts delete mode 100644 packages/sync/src/portable/bundle.ts delete mode 100644 packages/sync/src/portable/engine.ts delete mode 100644 packages/sync/src/portable/index.ts delete mode 100644 packages/sync/src/portable/merge.ts delete mode 100644 packages/sync/src/portable/types.ts delete mode 100644 packages/sync/src/protocol/index.ts delete mode 100644 packages/sync/src/state-sync.ts delete mode 100644 packages/sync/src/transfer/index.ts delete mode 100644 packages/sync/src/transport.ts delete mode 100644 packages/sync/src/types/index.ts delete mode 100644 packages/sync/src/wire.ts delete mode 100644 packages/sync/test/cap.test.mjs delete mode 100644 packages/sync/test/discovery.test.mjs delete mode 100644 packages/sync/test/handshake.test.mjs delete mode 100644 packages/sync/test/node-tcp.test.mjs delete mode 100644 packages/sync/test/portable-engine.test.mjs delete mode 100644 packages/sync/test/portable.test.mjs delete mode 100644 packages/sync/test/reconnect.test.mjs delete mode 100644 packages/sync/tsconfig.json create mode 100644 src/main/__tests__/rag-message-uuid.dbtest.ts diff --git a/docs/SYNC_INTEGRATION_PLAN.md b/docs/SYNC_INTEGRATION_PLAN.md index 040a5108..05566fac 100644 --- a/docs/SYNC_INTEGRATION_PLAN.md +++ b/docs/SYNC_INTEGRATION_PLAN.md @@ -14,18 +14,23 @@ present + wired is not closure (same bar as `docs/GAPS_BACKLOG.md`). ## Non-negotiable placement rules -| Thing | Where | Why | -|---|---|---| -| Sync **engine** (crypto, pairing, wire protocol, transfer, op-log) | `@offgrid/sync` in `shared/` — **public** | The encryption and wire format must be auditable. That is the whole point of publishing it. | -| Desktop **integration** of that engine (service, IPC, UI, settings) | `pro/` (the `desktop-pro` submodule) | Sync is a **pro feature**. Core must not carry pro business logic. | -| Core's share | `proCatalog` entry + `locked: !isPro` nav item → `UpgradeScreen`; dimmed `ProPlaceholder` in Settings | The inert shell only. | -| Pro renderer → main | generic `proInvoke` / `proOn` passthrough | Do **not** add per-feature namespaces to the core preload. | +| Thing | Where | Why | +| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Sync **engine** (crypto, pairing, wire protocol, transfer, op-log) | `@offgrid/sync` in `shared/` — **public** | The encryption and wire format must be auditable. That is the whole point of publishing it. | +| Desktop **integration** of that engine (service, IPC, UI, settings) | `pro/` (the `desktop-pro` submodule) | Sync is a **pro feature**. Core must not carry pro business logic. | +| Core's share | `proCatalog` entry + `locked: !isPro` nav item → `UpgradeScreen`; dimmed `ProPlaceholder` in Settings | The inert shell only. | +| Pro renderer → main | generic `proInvoke` / `proOn` passthrough | Do **not** add per-feature namespaces to the core preload. | Commit order for pro changes: land in `desktop-pro` first, then bump the submodule pointer in `desktop` with `git add pro`. ## Cross-lane contract (desktop ↔ mobile, read this first) +> Shared log both lanes update: **`shared/docs/SYNC_CROSS_LANE_LOG.md`** — the entity/channel/column +> contracts, per-lane progress, engine asks, and a corrections log. The authoritative design is +> `shared/docs/DESKTOP_SYNC_INTEGRATION_PLAN.md` (Track A free export/import in core, Track B pro +> sync in `pro/`). + The one guaranteed conflict is two sessions editing `shared/packages/sync`. Therefore: - **The desktop lane consumes `@offgrid/sync` UNCHANGED.** It already builds and passes 24/24 tests. @@ -47,7 +52,7 @@ The one guaranteed conflict is two sessions editing `shared/packages/sync`. Ther `AMBIENT_SHARING.md`). M4 is therefore **not blocked**. - ~~**A-2 (ACK semantics).**~~ **RESTATED as a host-wiring rule.** `createFileAck` and `verifyFileIntegrity` exist in the engine, so the vocabulary is there. G-007 was a defect in - *EasyShare's desktop* resolve timing, not in the engine: it resolved `sendFile(true)` after + _EasyShare's desktop_ resolve timing, not in the engine: it resolved `sendFile(true)` after emitting chunks rather than after peer confirmation. **Our integration must resolve only on a correlated positive ACK following the peer's durable write + integrity check**, and must surface negative ACKs. "Synced" that does not mean "written and verified on the peer" silently loses data. @@ -65,12 +70,15 @@ The one guaranteed conflict is two sessions editing `shared/packages/sync`. Ther Each milestone states its **verification gate**. No milestone is done without it. -### M0 — Vendor the engine into desktop +### M0 — Consume the engine (do NOT vendor it) -- Copy `shared/packages/sync` → `desktop/packages/sync` (the existing convention: `desktop/packages/*` - are real, git-tracked copies consumed via `file:` deps, as `@offgrid/clipboard|design|models|rag` - already are). Note those copies have **drifted** from `shared/` — record the source commit in the - vendored `package.json` so drift is visible rather than silent. +- **CORRECTED.** This originally said to copy `shared/packages/sync` → `desktop/packages/sync`, + following the existing `@offgrid/clipboard|design|models|rag` convention. That is wrong: + `shared/docs/DESKTOP_SYNC_INTEGRATION_PLAN.md` §1 says explicitly **do not duplicate + `@offgrid/sync`** — reference it directly, as mobile does: + `"@offgrid/sync": "file:../shared/packages/sync"`. The vendored copy was removed. + (The other `desktop/packages/*` copies have silently drifted from `shared/`, which is the + argument for the direct ref.) - Add `@offgrid/sync` as a `file:./packages/sync` dep, plus `bonjour-service` (pure-JS mDNS, no native build) for `node-discovery`. - **Gate:** `npx tsc --noEmit` clean on both tsconfigs; the package's own 24 tests pass from the @@ -79,6 +87,7 @@ Each milestone states its **verification gate**. No milestone is done without it ### M1 — Pairing + discovery + transport, headless and real `pro/main/sync/`: + - `sync-service.ts` — composes `NodeDiscovery` + `NodeTcpTransport` + the engine. Owns lifecycle and teardown (no leaked sockets/timers). - `sync-store.ts` — persistence behind a **small interface** (`getPairedDevices`, `addPairedDevice`, @@ -128,6 +137,7 @@ Then: compose watcher → policy → `FileSender` (over the sync transport) in ` share-mode matrix in Settings. macOS watcher at the OS boundary (`NSMetadataQuery` on `kMDItemIsScreenCapture` + FSEvents), every event through `shouldEmit` (dedup + anti-loop on the app's own save dir). + - **Gate:** an observed screenshot reaches the paired peer with no user interaction, is **not** re-shared on receipt, and `off` genuinely sends nothing. diff --git a/package-lock.json b/package-lock.json index 6a9d2d13..b77baf60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@offgrid/design": "file:./packages/design", "@offgrid/models": "file:./packages/models", "@offgrid/rag": "file:./packages/rag", - "@offgrid/sync": "file:./packages/sync", + "@offgrid/sync": "file:../shared/packages/sync", "@phosphor-icons/react": "^2.1.10", "@scure/bip39": "^2.2.0", "@tabler/icons-react": "^3.36.1", @@ -114,6 +114,17 @@ "extraneous": true, "license": "AGPL-3.0-only" }, + "../shared/packages/sync": { + "name": "@offgrid/sync", + "version": "0.0.1", + "license": "AGPL-3.0-only", + "dependencies": { + "bonjour-service": "^1.2.1", + "js-sha512": "^0.9.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -3844,7 +3855,7 @@ "link": true }, "node_modules/@offgrid/sync": { - "resolved": "packages/sync", + "resolved": "../shared/packages/sync", "link": true }, "node_modules/@oxc-parser/binding-android-arm-eabi": { @@ -20922,6 +20933,7 @@ "packages/sync": { "name": "@offgrid/sync", "version": "0.0.1", + "extraneous": true, "license": "AGPL-3.0-only", "dependencies": { "bonjour-service": "^1.2.1", diff --git a/package.json b/package.json index 7e84e167..320f7314 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@offgrid/design": "file:./packages/design", "@offgrid/models": "file:./packages/models", "@offgrid/rag": "file:./packages/rag", - "@offgrid/sync": "file:./packages/sync", + "@offgrid/sync": "file:../shared/packages/sync", "@phosphor-icons/react": "^2.1.10", "@scure/bip39": "^2.2.0", "@tabler/icons-react": "^3.36.1", diff --git a/packages/sync/dist/adapters/node-discovery.d.mts b/packages/sync/dist/adapters/node-discovery.d.mts deleted file mode 100644 index 3b024339..00000000 --- a/packages/sync/dist/adapters/node-discovery.d.mts +++ /dev/null @@ -1,17 +0,0 @@ -import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.mjs'; - -declare class NodeDiscovery implements DiscoveryService { - private bonjour; - private browser?; - private published?; - private foundCb?; - private lostCb?; - start(): Promise; - advertise(device: DeviceInfo): Promise; - stopAdvertising(): Promise; - onDeviceFound(callback: (device: DiscoveredDevice) => void): void; - onDeviceLost(callback: (deviceId: string) => void): void; - stop(): Promise; -} - -export { NodeDiscovery }; diff --git a/packages/sync/dist/adapters/node-discovery.d.ts b/packages/sync/dist/adapters/node-discovery.d.ts deleted file mode 100644 index 19ea5043..00000000 --- a/packages/sync/dist/adapters/node-discovery.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.js'; - -declare class NodeDiscovery implements DiscoveryService { - private bonjour; - private browser?; - private published?; - private foundCb?; - private lostCb?; - start(): Promise; - advertise(device: DeviceInfo): Promise; - stopAdvertising(): Promise; - onDeviceFound(callback: (device: DiscoveredDevice) => void): void; - onDeviceLost(callback: (deviceId: string) => void): void; - stop(): Promise; -} - -export { NodeDiscovery }; diff --git a/packages/sync/dist/adapters/node-discovery.js b/packages/sync/dist/adapters/node-discovery.js deleted file mode 100644 index 205f477c..00000000 --- a/packages/sync/dist/adapters/node-discovery.js +++ /dev/null @@ -1,117 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/adapters/node-discovery.ts -var node_discovery_exports = {}; -__export(node_discovery_exports, { - NodeDiscovery: () => NodeDiscovery -}); -module.exports = __toCommonJS(node_discovery_exports); -var import_bonjour_service = require("bonjour-service"); - -// src/discovery/index.ts -var TXT_DEVICE_ID = "id"; -var TXT_DEVICE_NAME = "name"; -var TXT_PLATFORM = "platform"; -var TXT_VERSION = "version"; -function createTxtRecord(device) { - return { - [TXT_DEVICE_ID]: device.id, - [TXT_DEVICE_NAME]: device.name, - [TXT_PLATFORM]: device.platform, - [TXT_VERSION]: device.version - }; -} -function parseTxtRecord(txt, host, port) { - const id = txt[TXT_DEVICE_ID]; - const name = txt[TXT_DEVICE_NAME]; - const platform = txt[TXT_PLATFORM]; - const version = txt[TXT_VERSION]; - if (!id || !name || !platform || !version) { - return null; - } - return { - id, - name, - platform, - version, - host, - port - }; -} -function createDiscoveredDevice(device) { - return { - ...device, - lastSeen: Date.now() - }; -} - -// src/adapters/node-discovery.ts -var SERVICE_TYPE = "offgrid"; -var NodeDiscovery = class { - bonjour = new import_bonjour_service.Bonjour(); - browser; - published; - foundCb; - lostCb; - async start() { - this.browser = this.bonjour.find({ type: SERVICE_TYPE }); - this.browser.on("up", (service) => { - const txt = service.txt ?? {}; - const host = service.addresses?.find((a) => a.includes(".")) ?? service.host ?? ""; - const info = parseTxtRecord(txt, host, service.port); - if (info) this.foundCb?.(createDiscoveredDevice(info)); - }); - this.browser.on("down", (service) => { - const txt = service.txt ?? {}; - this.lostCb?.(txt.id || service.name); - }); - } - async advertise(device) { - this.published = this.bonjour.publish({ - name: `OffGrid-${device.id}`, - type: SERVICE_TYPE, - port: device.port, - txt: createTxtRecord(device) - }); - } - async stopAdvertising() { - await new Promise((resolve) => { - if (!this.published) return resolve(); - this.published.stop?.(() => resolve()); - this.published = void 0; - setTimeout(resolve, 50); - }); - } - onDeviceFound(callback) { - this.foundCb = callback; - } - onDeviceLost(callback) { - this.lostCb = callback; - } - async stop() { - this.browser?.stop(); - await this.stopAdvertising(); - this.bonjour.destroy(); - } -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - NodeDiscovery -}); diff --git a/packages/sync/dist/adapters/node-discovery.mjs b/packages/sync/dist/adapters/node-discovery.mjs deleted file mode 100644 index 824e06cb..00000000 --- a/packages/sync/dist/adapters/node-discovery.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import { - createDiscoveredDevice, - createTxtRecord, - parseTxtRecord -} from "../chunk-UMHRNOI2.mjs"; - -// src/adapters/node-discovery.ts -import { Bonjour } from "bonjour-service"; -var SERVICE_TYPE = "offgrid"; -var NodeDiscovery = class { - bonjour = new Bonjour(); - browser; - published; - foundCb; - lostCb; - async start() { - this.browser = this.bonjour.find({ type: SERVICE_TYPE }); - this.browser.on("up", (service) => { - const txt = service.txt ?? {}; - const host = service.addresses?.find((a) => a.includes(".")) ?? service.host ?? ""; - const info = parseTxtRecord(txt, host, service.port); - if (info) this.foundCb?.(createDiscoveredDevice(info)); - }); - this.browser.on("down", (service) => { - const txt = service.txt ?? {}; - this.lostCb?.(txt.id || service.name); - }); - } - async advertise(device) { - this.published = this.bonjour.publish({ - name: `OffGrid-${device.id}`, - type: SERVICE_TYPE, - port: device.port, - txt: createTxtRecord(device) - }); - } - async stopAdvertising() { - await new Promise((resolve) => { - if (!this.published) return resolve(); - this.published.stop?.(() => resolve()); - this.published = void 0; - setTimeout(resolve, 50); - }); - } - onDeviceFound(callback) { - this.foundCb = callback; - } - onDeviceLost(callback) { - this.lostCb = callback; - } - async stop() { - this.browser?.stop(); - await this.stopAdvertising(); - this.bonjour.destroy(); - } -}; -export { - NodeDiscovery -}; diff --git a/packages/sync/dist/adapters/node-tcp.d.mts b/packages/sync/dist/adapters/node-tcp.d.mts deleted file mode 100644 index 8f129bbb..00000000 --- a/packages/sync/dist/adapters/node-tcp.d.mts +++ /dev/null @@ -1,12 +0,0 @@ -import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.mjs'; - -declare class NodeTcpTransport implements TransportBridge { - private server?; - /** The port actually bound after listen() (useful when listening on 0). */ - boundPort?: number; - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; - connect(host: string, port: number): Promise; - stop(): Promise; -} - -export { NodeTcpTransport }; diff --git a/packages/sync/dist/adapters/node-tcp.d.ts b/packages/sync/dist/adapters/node-tcp.d.ts deleted file mode 100644 index f0ff8da2..00000000 --- a/packages/sync/dist/adapters/node-tcp.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.js'; - -declare class NodeTcpTransport implements TransportBridge { - private server?; - /** The port actually bound after listen() (useful when listening on 0). */ - boundPort?: number; - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; - connect(host: string, port: number): Promise; - stop(): Promise; -} - -export { NodeTcpTransport }; diff --git a/packages/sync/dist/adapters/node-tcp.js b/packages/sync/dist/adapters/node-tcp.js deleted file mode 100644 index 0d1de37c..00000000 --- a/packages/sync/dist/adapters/node-tcp.js +++ /dev/null @@ -1,82 +0,0 @@ -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/adapters/node-tcp.ts -var node_tcp_exports = {}; -__export(node_tcp_exports, { - NodeTcpTransport: () => NodeTcpTransport -}); -module.exports = __toCommonJS(node_tcp_exports); -var import_net = __toESM(require("net")); -function wrap(socket) { - const id = `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`; - socket.on("error", () => socket.destroy()); - return { - id, - remoteHost: socket.remoteAddress ?? void 0, - send: (data) => socket.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), - onData: (cb) => socket.on("data", (d) => cb(new Uint8Array(d.buffer, d.byteOffset, d.byteLength))), - onClose: (cb) => socket.on("close", () => cb()), - close: () => socket.destroy() - }; -} -var NodeTcpTransport = class { - server; - /** The port actually bound after listen() (useful when listening on 0). */ - boundPort; - listen(port, onConnection) { - return new Promise((resolve, reject) => { - const server = import_net.default.createServer((socket) => onConnection(wrap(socket))); - server.once("error", reject); - server.listen(port, () => { - const addr = server.address(); - if (addr && typeof addr === "object") this.boundPort = addr.port; - this.server = server; - resolve(); - }); - }); - } - connect(host, port) { - return new Promise((resolve, reject) => { - const socket = import_net.default.createConnection({ host, port }, () => resolve(wrap(socket))); - socket.once("error", reject); - }); - } - stop() { - return new Promise((resolve) => { - if (!this.server) return resolve(); - this.server.close(() => resolve()); - this.server = void 0; - }); - } -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - NodeTcpTransport -}); diff --git a/packages/sync/dist/adapters/node-tcp.mjs b/packages/sync/dist/adapters/node-tcp.mjs deleted file mode 100644 index caf9b78f..00000000 --- a/packages/sync/dist/adapters/node-tcp.mjs +++ /dev/null @@ -1,47 +0,0 @@ -// src/adapters/node-tcp.ts -import net from "net"; -function wrap(socket) { - const id = `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`; - socket.on("error", () => socket.destroy()); - return { - id, - remoteHost: socket.remoteAddress ?? void 0, - send: (data) => socket.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), - onData: (cb) => socket.on("data", (d) => cb(new Uint8Array(d.buffer, d.byteOffset, d.byteLength))), - onClose: (cb) => socket.on("close", () => cb()), - close: () => socket.destroy() - }; -} -var NodeTcpTransport = class { - server; - /** The port actually bound after listen() (useful when listening on 0). */ - boundPort; - listen(port, onConnection) { - return new Promise((resolve, reject) => { - const server = net.createServer((socket) => onConnection(wrap(socket))); - server.once("error", reject); - server.listen(port, () => { - const addr = server.address(); - if (addr && typeof addr === "object") this.boundPort = addr.port; - this.server = server; - resolve(); - }); - }); - } - connect(host, port) { - return new Promise((resolve, reject) => { - const socket = net.createConnection({ host, port }, () => resolve(wrap(socket))); - socket.once("error", reject); - }); - } - stop() { - return new Promise((resolve) => { - if (!this.server) return resolve(); - this.server.close(() => resolve()); - this.server = void 0; - }); - } -}; -export { - NodeTcpTransport -}; diff --git a/packages/sync/dist/adapters/rn-discovery.d.mts b/packages/sync/dist/adapters/rn-discovery.d.mts deleted file mode 100644 index ccfa7c9b..00000000 --- a/packages/sync/dist/adapters/rn-discovery.d.mts +++ /dev/null @@ -1,38 +0,0 @@ -import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.mjs'; - -/** Minimal shape of a react-native-zeroconf resolved service. */ -interface RnZeroconfService { - txt?: Record; - addresses?: string[]; - host?: string; - port: number; - name: string; -} -/** Minimal shape of the react-native-zeroconf instance we use. Publish methods - * are optional — not every RN zeroconf build can advertise; discovery still - * works one-way (we browse; a peer that can advertise gets found and dialed). */ -interface RnZeroconf { - on(event: 'resolved', cb: (service: RnZeroconfService) => void): void; - on(event: 'remove', cb: (name: string) => void): void; - on(event: 'error', cb: (err: unknown) => void): void; - scan(type: string, protocol: string, domain: string): void; - stop(): void; - removeDeviceListeners?(): void; - publishService?(type: string, protocol: string, domain: string, name: string, port: number, txt?: Record): void; - unpublishService?(name: string): void; -} -declare class RnDiscovery implements DiscoveryService { - private readonly zeroconf; - private foundCb?; - private lostCb?; - private publishedName?; - constructor(zeroconf: RnZeroconf); - start(): Promise; - advertise(device: DeviceInfo): Promise; - stopAdvertising(): Promise; - onDeviceFound(callback: (device: DiscoveredDevice) => void): void; - onDeviceLost(callback: (deviceId: string) => void): void; - stop(): Promise; -} - -export { RnDiscovery, type RnZeroconf, type RnZeroconfService }; diff --git a/packages/sync/dist/adapters/rn-discovery.d.ts b/packages/sync/dist/adapters/rn-discovery.d.ts deleted file mode 100644 index 7bf4d38e..00000000 --- a/packages/sync/dist/adapters/rn-discovery.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { D as DiscoveryService, a as DeviceInfo, b as DiscoveredDevice } from '../index-D7PLqM1E.js'; - -/** Minimal shape of a react-native-zeroconf resolved service. */ -interface RnZeroconfService { - txt?: Record; - addresses?: string[]; - host?: string; - port: number; - name: string; -} -/** Minimal shape of the react-native-zeroconf instance we use. Publish methods - * are optional — not every RN zeroconf build can advertise; discovery still - * works one-way (we browse; a peer that can advertise gets found and dialed). */ -interface RnZeroconf { - on(event: 'resolved', cb: (service: RnZeroconfService) => void): void; - on(event: 'remove', cb: (name: string) => void): void; - on(event: 'error', cb: (err: unknown) => void): void; - scan(type: string, protocol: string, domain: string): void; - stop(): void; - removeDeviceListeners?(): void; - publishService?(type: string, protocol: string, domain: string, name: string, port: number, txt?: Record): void; - unpublishService?(name: string): void; -} -declare class RnDiscovery implements DiscoveryService { - private readonly zeroconf; - private foundCb?; - private lostCb?; - private publishedName?; - constructor(zeroconf: RnZeroconf); - start(): Promise; - advertise(device: DeviceInfo): Promise; - stopAdvertising(): Promise; - onDeviceFound(callback: (device: DiscoveredDevice) => void): void; - onDeviceLost(callback: (deviceId: string) => void): void; - stop(): Promise; -} - -export { RnDiscovery, type RnZeroconf, type RnZeroconfService }; diff --git a/packages/sync/dist/adapters/rn-discovery.js b/packages/sync/dist/adapters/rn-discovery.js deleted file mode 100644 index c1c153ed..00000000 --- a/packages/sync/dist/adapters/rn-discovery.js +++ /dev/null @@ -1,122 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/adapters/rn-discovery.ts -var rn_discovery_exports = {}; -__export(rn_discovery_exports, { - RnDiscovery: () => RnDiscovery -}); -module.exports = __toCommonJS(rn_discovery_exports); - -// src/discovery/index.ts -var TXT_DEVICE_ID = "id"; -var TXT_DEVICE_NAME = "name"; -var TXT_PLATFORM = "platform"; -var TXT_VERSION = "version"; -function createTxtRecord(device) { - return { - [TXT_DEVICE_ID]: device.id, - [TXT_DEVICE_NAME]: device.name, - [TXT_PLATFORM]: device.platform, - [TXT_VERSION]: device.version - }; -} -function parseTxtRecord(txt, host, port) { - const id = txt[TXT_DEVICE_ID]; - const name = txt[TXT_DEVICE_NAME]; - const platform = txt[TXT_PLATFORM]; - const version = txt[TXT_VERSION]; - if (!id || !name || !platform || !version) { - return null; - } - return { - id, - name, - platform, - version, - host, - port - }; -} -function createDiscoveredDevice(device) { - return { - ...device, - lastSeen: Date.now() - }; -} - -// src/adapters/rn-discovery.ts -var SERVICE_TYPE = "offgrid"; -var PROTOCOL = "tcp"; -var DOMAIN = "local."; -var RnDiscovery = class { - constructor(zeroconf) { - this.zeroconf = zeroconf; - } - zeroconf; - foundCb; - lostCb; - publishedName; - async start() { - this.zeroconf.on("resolved", (svc) => { - const txt = svc.txt ?? {}; - const ipv4 = svc.addresses?.find((a) => a.includes(".")); - const host = ipv4 ?? svc.host ?? svc.addresses?.[0] ?? ""; - const info = parseTxtRecord(txt, host, svc.port); - if (info) this.foundCb?.(createDiscoveredDevice(info)); - }); - this.zeroconf.on("remove", (name) => { - const m = /OffGrid-([^.]+)/.exec(name); - this.lostCb?.(m ? m[1] : name); - }); - this.zeroconf.on("error", () => { - }); - this.zeroconf.scan(SERVICE_TYPE, PROTOCOL, DOMAIN); - } - async advertise(device) { - const name = `OffGrid-${device.id}`; - this.publishedName = name; - if (typeof this.zeroconf.publishService === "function") { - this.zeroconf.publishService(SERVICE_TYPE, PROTOCOL, DOMAIN, name, device.port, createTxtRecord(device)); - } else { - console.warn("[sync] zeroconf.publishService unavailable \u2014 browse-only on this device"); - } - } - async stopAdvertising() { - if (this.publishedName && typeof this.zeroconf.unpublishService === "function") { - this.zeroconf.unpublishService(this.publishedName); - } - this.publishedName = void 0; - } - onDeviceFound(callback) { - this.foundCb = callback; - } - onDeviceLost(callback) { - this.lostCb = callback; - } - async stop() { - await this.stopAdvertising(); - this.zeroconf.stop(); - this.zeroconf.removeDeviceListeners?.(); - } -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - RnDiscovery -}); diff --git a/packages/sync/dist/adapters/rn-discovery.mjs b/packages/sync/dist/adapters/rn-discovery.mjs deleted file mode 100644 index fac3d261..00000000 --- a/packages/sync/dist/adapters/rn-discovery.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import { - createDiscoveredDevice, - createTxtRecord, - parseTxtRecord -} from "../chunk-UMHRNOI2.mjs"; - -// src/adapters/rn-discovery.ts -var SERVICE_TYPE = "offgrid"; -var PROTOCOL = "tcp"; -var DOMAIN = "local."; -var RnDiscovery = class { - constructor(zeroconf) { - this.zeroconf = zeroconf; - } - zeroconf; - foundCb; - lostCb; - publishedName; - async start() { - this.zeroconf.on("resolved", (svc) => { - const txt = svc.txt ?? {}; - const ipv4 = svc.addresses?.find((a) => a.includes(".")); - const host = ipv4 ?? svc.host ?? svc.addresses?.[0] ?? ""; - const info = parseTxtRecord(txt, host, svc.port); - if (info) this.foundCb?.(createDiscoveredDevice(info)); - }); - this.zeroconf.on("remove", (name) => { - const m = /OffGrid-([^.]+)/.exec(name); - this.lostCb?.(m ? m[1] : name); - }); - this.zeroconf.on("error", () => { - }); - this.zeroconf.scan(SERVICE_TYPE, PROTOCOL, DOMAIN); - } - async advertise(device) { - const name = `OffGrid-${device.id}`; - this.publishedName = name; - if (typeof this.zeroconf.publishService === "function") { - this.zeroconf.publishService(SERVICE_TYPE, PROTOCOL, DOMAIN, name, device.port, createTxtRecord(device)); - } else { - console.warn("[sync] zeroconf.publishService unavailable \u2014 browse-only on this device"); - } - } - async stopAdvertising() { - if (this.publishedName && typeof this.zeroconf.unpublishService === "function") { - this.zeroconf.unpublishService(this.publishedName); - } - this.publishedName = void 0; - } - onDeviceFound(callback) { - this.foundCb = callback; - } - onDeviceLost(callback) { - this.lostCb = callback; - } - async stop() { - await this.stopAdvertising(); - this.zeroconf.stop(); - this.zeroconf.removeDeviceListeners?.(); - } -}; -export { - RnDiscovery -}; diff --git a/packages/sync/dist/adapters/rn-tcp.d.mts b/packages/sync/dist/adapters/rn-tcp.d.mts deleted file mode 100644 index c38c24d3..00000000 --- a/packages/sync/dist/adapters/rn-tcp.d.mts +++ /dev/null @@ -1,52 +0,0 @@ -import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.mjs'; - -/** Minimal shape of a react-native-tcp-socket socket we use. */ -interface RnSocket { - remoteAddress?: string; - on(event: 'data', cb: (data: unknown) => void): void; - on(event: 'close', cb: () => void): void; - on(event: 'error', cb: (err: unknown) => void): void; - write(data: unknown): void; - destroy(): void; -} -/** Minimal shape of a react-native-tcp-socket server we use. */ -interface RnTcpServer { - listen(opts: { - port: number; - host?: string; - }, cb?: () => void): void; - address(): { - port: number; - } | string | null; - on(event: 'error', cb: (err: unknown) => void): void; - close(): void; -} -/** Minimal shape of the react-native-tcp-socket module we use. */ -interface RnTcpModule { - createServer(onConnection: (socket: RnSocket) => void): RnTcpServer; - createConnection(opts: { - host: string; - port: number; - }, cb?: () => void): RnSocket; -} -/** Bytes <-> wire conversion. Injected because RN needs its Buffer polyfill and - * react-native-tcp-socket may deliver 'data' as a (base64) string on Android. */ -interface ByteCodec { - /** Normalize an inbound 'data' payload (Buffer or string) to raw bytes. */ - toBytes(data: unknown): Uint8Array; - /** Convert raw bytes into what socket.write() expects (a Buffer). */ - fromBytes(bytes: Uint8Array): unknown; -} -declare class RnTcpTransport implements TransportBridge { - private readonly tcp; - private readonly codec; - private server?; - /** Port actually bound after listen() (we listen on 0 and advertise this). */ - boundPort?: number; - constructor(tcp: RnTcpModule, codec: ByteCodec); - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; - connect(host: string, port: number): Promise; - stop(): Promise; -} - -export { type ByteCodec, type RnSocket, type RnTcpModule, type RnTcpServer, RnTcpTransport }; diff --git a/packages/sync/dist/adapters/rn-tcp.d.ts b/packages/sync/dist/adapters/rn-tcp.d.ts deleted file mode 100644 index c542b7e2..00000000 --- a/packages/sync/dist/adapters/rn-tcp.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { T as TransportBridge, S as SyncConnection } from '../transport-1cXLtrs5.js'; - -/** Minimal shape of a react-native-tcp-socket socket we use. */ -interface RnSocket { - remoteAddress?: string; - on(event: 'data', cb: (data: unknown) => void): void; - on(event: 'close', cb: () => void): void; - on(event: 'error', cb: (err: unknown) => void): void; - write(data: unknown): void; - destroy(): void; -} -/** Minimal shape of a react-native-tcp-socket server we use. */ -interface RnTcpServer { - listen(opts: { - port: number; - host?: string; - }, cb?: () => void): void; - address(): { - port: number; - } | string | null; - on(event: 'error', cb: (err: unknown) => void): void; - close(): void; -} -/** Minimal shape of the react-native-tcp-socket module we use. */ -interface RnTcpModule { - createServer(onConnection: (socket: RnSocket) => void): RnTcpServer; - createConnection(opts: { - host: string; - port: number; - }, cb?: () => void): RnSocket; -} -/** Bytes <-> wire conversion. Injected because RN needs its Buffer polyfill and - * react-native-tcp-socket may deliver 'data' as a (base64) string on Android. */ -interface ByteCodec { - /** Normalize an inbound 'data' payload (Buffer or string) to raw bytes. */ - toBytes(data: unknown): Uint8Array; - /** Convert raw bytes into what socket.write() expects (a Buffer). */ - fromBytes(bytes: Uint8Array): unknown; -} -declare class RnTcpTransport implements TransportBridge { - private readonly tcp; - private readonly codec; - private server?; - /** Port actually bound after listen() (we listen on 0 and advertise this). */ - boundPort?: number; - constructor(tcp: RnTcpModule, codec: ByteCodec); - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; - connect(host: string, port: number): Promise; - stop(): Promise; -} - -export { type ByteCodec, type RnSocket, type RnTcpModule, type RnTcpServer, RnTcpTransport }; diff --git a/packages/sync/dist/adapters/rn-tcp.js b/packages/sync/dist/adapters/rn-tcp.js deleted file mode 100644 index d9f7fa70..00000000 --- a/packages/sync/dist/adapters/rn-tcp.js +++ /dev/null @@ -1,76 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/adapters/rn-tcp.ts -var rn_tcp_exports = {}; -__export(rn_tcp_exports, { - RnTcpTransport: () => RnTcpTransport -}); -module.exports = __toCommonJS(rn_tcp_exports); -function wrap(socket, codec) { - socket.on("error", () => socket.destroy()); - return { - id: socket.remoteAddress ?? "rn-peer", - remoteHost: socket.remoteAddress, - send: (data) => socket.write(codec.fromBytes(data)), - onData: (cb) => socket.on("data", (d) => cb(codec.toBytes(d))), - onClose: (cb) => socket.on("close", cb), - close: () => socket.destroy() - }; -} -var RnTcpTransport = class { - constructor(tcp, codec) { - this.tcp = tcp; - this.codec = codec; - } - tcp; - codec; - server; - /** Port actually bound after listen() (we listen on 0 and advertise this). */ - boundPort; - listen(port, onConnection) { - return new Promise((resolve, reject) => { - const server = this.tcp.createServer((socket) => onConnection(wrap(socket, this.codec))); - server.on("error", reject); - server.listen({ port, host: "0.0.0.0" }, () => { - const addr = server.address(); - if (addr && typeof addr === "object") this.boundPort = addr.port; - this.server = server; - resolve(); - }); - }); - } - connect(host, port) { - return new Promise((resolve, reject) => { - const socket = this.tcp.createConnection({ host, port }, () => resolve(wrap(socket, this.codec))); - socket.on("error", reject); - }); - } - stop() { - return new Promise((resolve) => { - this.server?.close(); - this.server = void 0; - resolve(); - }); - } -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - RnTcpTransport -}); diff --git a/packages/sync/dist/adapters/rn-tcp.mjs b/packages/sync/dist/adapters/rn-tcp.mjs deleted file mode 100644 index aa46dc84..00000000 --- a/packages/sync/dist/adapters/rn-tcp.mjs +++ /dev/null @@ -1,51 +0,0 @@ -// src/adapters/rn-tcp.ts -function wrap(socket, codec) { - socket.on("error", () => socket.destroy()); - return { - id: socket.remoteAddress ?? "rn-peer", - remoteHost: socket.remoteAddress, - send: (data) => socket.write(codec.fromBytes(data)), - onData: (cb) => socket.on("data", (d) => cb(codec.toBytes(d))), - onClose: (cb) => socket.on("close", cb), - close: () => socket.destroy() - }; -} -var RnTcpTransport = class { - constructor(tcp, codec) { - this.tcp = tcp; - this.codec = codec; - } - tcp; - codec; - server; - /** Port actually bound after listen() (we listen on 0 and advertise this). */ - boundPort; - listen(port, onConnection) { - return new Promise((resolve, reject) => { - const server = this.tcp.createServer((socket) => onConnection(wrap(socket, this.codec))); - server.on("error", reject); - server.listen({ port, host: "0.0.0.0" }, () => { - const addr = server.address(); - if (addr && typeof addr === "object") this.boundPort = addr.port; - this.server = server; - resolve(); - }); - }); - } - connect(host, port) { - return new Promise((resolve, reject) => { - const socket = this.tcp.createConnection({ host, port }, () => resolve(wrap(socket, this.codec))); - socket.on("error", reject); - }); - } - stop() { - return new Promise((resolve) => { - this.server?.close(); - this.server = void 0; - resolve(); - }); - } -}; -export { - RnTcpTransport -}; diff --git a/packages/sync/dist/chunk-UMHRNOI2.mjs b/packages/sync/dist/chunk-UMHRNOI2.mjs deleted file mode 100644 index 974e92ad..00000000 --- a/packages/sync/dist/chunk-UMHRNOI2.mjs +++ /dev/null @@ -1,74 +0,0 @@ -// src/discovery/index.ts -var MDNS_SERVICE_TYPE = "_easyshare._tcp"; -var MDNS_SERVICE_NAME = "EasyShare"; -var MDNS_DOMAIN = "local"; -var TXT_DEVICE_ID = "id"; -var TXT_DEVICE_NAME = "name"; -var TXT_PLATFORM = "platform"; -var TXT_VERSION = "version"; -function createTxtRecord(device) { - return { - [TXT_DEVICE_ID]: device.id, - [TXT_DEVICE_NAME]: device.name, - [TXT_PLATFORM]: device.platform, - [TXT_VERSION]: device.version - }; -} -function parseTxtRecord(txt, host, port) { - const id = txt[TXT_DEVICE_ID]; - const name = txt[TXT_DEVICE_NAME]; - const platform = txt[TXT_PLATFORM]; - const version = txt[TXT_VERSION]; - if (!id || !name || !platform || !version) { - return null; - } - return { - id, - name, - platform, - version, - host, - port - }; -} -function createDiscoveredDevice(device) { - return { - ...device, - lastSeen: Date.now() - }; -} -function isDeviceStale(device, maxAgeMs = 3e4) { - return Date.now() - device.lastSeen > maxAgeMs; -} -function filterStaleDevices(devices, maxAgeMs = 3e4) { - return devices.filter((device) => !isDeviceStale(device, maxAgeMs)); -} -function updateDeviceList(devices, newDevice) { - const existingIndex = devices.findIndex((d) => d.id === newDevice.id); - if (existingIndex >= 0) { - const updated = [...devices]; - updated[existingIndex] = { ...newDevice, lastSeen: Date.now() }; - return updated; - } - return [...devices, newDevice]; -} -function removeDevice(devices, deviceId) { - return devices.filter((d) => d.id !== deviceId); -} - -export { - MDNS_SERVICE_TYPE, - MDNS_SERVICE_NAME, - MDNS_DOMAIN, - TXT_DEVICE_ID, - TXT_DEVICE_NAME, - TXT_PLATFORM, - TXT_VERSION, - createTxtRecord, - parseTxtRecord, - createDiscoveredDevice, - isDeviceStale, - filterStaleDevices, - updateDeviceList, - removeDevice -}; diff --git a/packages/sync/dist/index-D7PLqM1E.d.mts b/packages/sync/dist/index-D7PLqM1E.d.mts deleted file mode 100644 index 8f427f44..00000000 --- a/packages/sync/dist/index-D7PLqM1E.d.mts +++ /dev/null @@ -1,264 +0,0 @@ -type DevicePlatform = 'macos' | 'windows' | 'linux' | 'android' | 'ios'; -interface DeviceInfo { - id: string; - name: string; - platform: DevicePlatform; - version: string; - host: string; - port: number; -} -interface DiscoveredDevice extends DeviceInfo { - lastSeen: number; -} -interface PairedDevice extends DeviceInfo { - sharedSecret: string; - pairedAt: number; - lastConnected?: number; -} -interface PairingChallenge { - challenge: string; - timestamp: number; -} -interface PairingResponse { - response: string; - deviceInfo: DeviceInfo; -} -type PairingStatus = 'idle' | 'waiting' | 'verifying' | 'success' | 'failed'; -type TransferType = 'text' | 'file' | 'files'; -interface TransferMetadata { - id: string; - type: TransferType; - timestamp: number; - direction: 'send' | 'receive'; - deviceId: string; - deviceName: string; -} -interface TextTransfer extends TransferMetadata { - type: 'text'; - content: string; -} -interface FileTransfer extends TransferMetadata { - type: 'file'; - fileName: string; - fileSize: number; - mimeType: string; - filePath?: string; - durationMs?: number; - speedBytesPerSec?: number; -} -interface FilesTransfer extends TransferMetadata { - type: 'files'; - files: Array<{ - fileName: string; - fileSize: number; - mimeType: string; - filePath?: string; - }>; - totalSize: number; -} -type Transfer = TextTransfer | FileTransfer | FilesTransfer; -interface TransferProgress { - transferId: string; - bytesTransferred: number; - totalBytes: number; - percentage: number; - currentFile?: string; - speedBytesPerSec?: number; - etaSeconds?: number; - elapsedMs?: number; -} -interface TransferQueueItem { - id: string; - fileName: string; - fileSize: number; - status: 'pending' | 'transferring' | 'completed' | 'failed'; - progress: number; - direction: 'send' | 'receive'; -} -type MessageType = 'ping' | 'pong' | 'pair_request' | 'pair_challenge' | 'pair_response' | 'pair_confirm' | 'pair_reject' | 'hello' | 'text' | 'file_request' | 'file_accept' | 'file_reject' | 'file_chunk' | 'file_complete' | 'file_ack' | 'app' | 'error'; -interface Message { - type: MessageType; - id: string; - timestamp: number; - payload?: unknown; -} -/** Generic encrypted application message: a channel name + arbitrary payload. - * Lets features (memory sync, clipboard sync, ...) ride the paired channel - * without each needing its own protocol message type. */ -interface AppMessage extends Message { - type: 'app'; - payload: { - channel: string; - data: unknown; - }; -} -/** Reconnect greeting: identifies the device so an already-paired peer can - * resume with the stored shared secret, skipping the pairing handshake. */ -interface HelloMessage extends Message { - type: 'hello'; - payload: { - deviceInfo: DeviceInfo; - }; -} -interface PingMessage extends Message { - type: 'ping'; -} -interface PongMessage extends Message { - type: 'pong'; -} -interface PairRequestMessage extends Message { - type: 'pair_request'; - payload: { - deviceInfo: DeviceInfo; - }; -} -interface PairChallengeMessage extends Message { - type: 'pair_challenge'; - payload: PairingChallenge; -} -interface PairResponseMessage extends Message { - type: 'pair_response'; - payload: PairingResponse; -} -interface PairConfirmMessage extends Message { - type: 'pair_confirm'; - payload: { - deviceInfo: DeviceInfo; - }; -} -interface PairRejectMessage extends Message { - type: 'pair_reject'; - payload: { - reason: string; - }; -} -interface TextMessage extends Message { - type: 'text'; - payload: { - content: string; - }; -} -interface FileRequestMessage extends Message { - type: 'file_request'; - payload: { - fileName: string; - fileSize: number; - mimeType: string; - checksum: string; - httpUrl?: string; - }; -} -interface FileAcceptMessage extends Message { - type: 'file_accept'; - payload: { - requestId: string; - uploadUrl?: string; - }; -} -interface FileRejectMessage extends Message { - type: 'file_reject'; - payload: { - requestId: string; - reason: string; - }; -} -interface FileChunkMessage extends Message { - type: 'file_chunk'; - payload: { - requestId: string; - chunkIndex: number; - totalChunks: number; - data: string; - }; -} -interface FileCompleteMessage extends Message { - type: 'file_complete'; - payload: { - requestId: string; - checksum: string; - }; -} -interface FileAckMessage extends Message { - type: 'file_ack'; - payload: { - requestId: string; - success: boolean; - }; -} -interface ErrorMessage extends Message { - type: 'error'; - payload: { - code: string; - message: string; - originalMessageId?: string; - }; -} -type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; -type PairingStep = 'idle' | 'connecting' | 'sending_request' | 'waiting_for_passphrase' | 'deriving_key' | 'sending_challenge' | 'waiting_for_challenge' | 'responding_to_challenge' | 'verifying_response' | 'confirming' | 'success' | 'failed'; -interface ConnectionState { - status: ConnectionStatus; - device?: DeviceInfo; - error?: string; - /** Verbose status message for UI display */ - statusMessage?: string; - /** Current step in the pairing process */ - pairingStep?: PairingStep; -} -interface AppSettings { - deviceName: string; - deviceId: string; - autoAcceptFromPaired: boolean; - saveDirectory: string; - notificationsEnabled: boolean; -} -interface StoredData { - settings: AppSettings; - pairedDevices: PairedDevice[]; - transferHistory: Transfer[]; -} - -declare const MDNS_SERVICE_TYPE = "_easyshare._tcp"; -declare const MDNS_SERVICE_NAME = "EasyShare"; -declare const MDNS_DOMAIN = "local"; -declare const TXT_DEVICE_ID = "id"; -declare const TXT_DEVICE_NAME = "name"; -declare const TXT_PLATFORM = "platform"; -declare const TXT_VERSION = "version"; -/** - * Create TXT record data for mDNS advertisement - */ -declare function createTxtRecord(device: DeviceInfo): Record; -/** - * Parse TXT record data from mDNS discovery - */ -declare function parseTxtRecord(txt: Record, host: string, port: number): DeviceInfo | null; -/** - * Create a DiscoveredDevice from DeviceInfo - */ -declare function createDiscoveredDevice(device: DeviceInfo): DiscoveredDevice; -/** - * Check if a discovered device is stale (not seen recently) - */ -declare function isDeviceStale(device: DiscoveredDevice, maxAgeMs?: number): boolean; -/** - * Filter out stale devices from a list - */ -declare function filterStaleDevices(devices: DiscoveredDevice[], maxAgeMs?: number): DiscoveredDevice[]; -/** - * Update or add a device to a list of discovered devices - */ -declare function updateDeviceList(devices: DiscoveredDevice[], newDevice: DiscoveredDevice): DiscoveredDevice[]; -/** - * Remove a device from the list by ID - */ -declare function removeDevice(devices: DiscoveredDevice[], deviceId: string): DiscoveredDevice[]; -interface DiscoveryService { - start(): Promise; - stop(): Promise; - advertise(device: DeviceInfo): Promise; - stopAdvertising(): Promise; - onDeviceFound(callback: (device: DiscoveredDevice) => void): void; - onDeviceLost(callback: (deviceId: string) => void): void; -} - -export { updateDeviceList as $, type AppMessage as A, type PairingStep as B, type ConnectionState as C, type DiscoveryService as D, type ErrorMessage as E, type FileAcceptMessage as F, type PingMessage as G, type HelloMessage as H, type PongMessage as I, TXT_DEVICE_ID as J, TXT_DEVICE_NAME as K, TXT_PLATFORM as L, type Message as M, TXT_VERSION as N, type Transfer as O, type PairingStatus as P, type TransferMetadata as Q, type TransferQueueItem as R, type StoredData as S, type TransferProgress as T, type TransferType as U, createDiscoveredDevice as V, createTxtRecord as W, filterStaleDevices as X, isDeviceStale as Y, parseTxtRecord as Z, removeDevice as _, type DeviceInfo as a, type DiscoveredDevice as b, type PairChallengeMessage as c, type PairConfirmMessage as d, type PairRejectMessage as e, type PairRequestMessage as f, type PairResponseMessage as g, type PairedDevice as h, type TextMessage as i, type FileAckMessage as j, type FileChunkMessage as k, type FileCompleteMessage as l, type FileRejectMessage as m, type FileRequestMessage as n, type FileTransfer as o, type TextTransfer as p, type AppSettings as q, type ConnectionStatus as r, type DevicePlatform as s, type FilesTransfer as t, MDNS_DOMAIN as u, MDNS_SERVICE_NAME as v, MDNS_SERVICE_TYPE as w, type MessageType as x, type PairingChallenge as y, type PairingResponse as z }; diff --git a/packages/sync/dist/index-D7PLqM1E.d.ts b/packages/sync/dist/index-D7PLqM1E.d.ts deleted file mode 100644 index 8f427f44..00000000 --- a/packages/sync/dist/index-D7PLqM1E.d.ts +++ /dev/null @@ -1,264 +0,0 @@ -type DevicePlatform = 'macos' | 'windows' | 'linux' | 'android' | 'ios'; -interface DeviceInfo { - id: string; - name: string; - platform: DevicePlatform; - version: string; - host: string; - port: number; -} -interface DiscoveredDevice extends DeviceInfo { - lastSeen: number; -} -interface PairedDevice extends DeviceInfo { - sharedSecret: string; - pairedAt: number; - lastConnected?: number; -} -interface PairingChallenge { - challenge: string; - timestamp: number; -} -interface PairingResponse { - response: string; - deviceInfo: DeviceInfo; -} -type PairingStatus = 'idle' | 'waiting' | 'verifying' | 'success' | 'failed'; -type TransferType = 'text' | 'file' | 'files'; -interface TransferMetadata { - id: string; - type: TransferType; - timestamp: number; - direction: 'send' | 'receive'; - deviceId: string; - deviceName: string; -} -interface TextTransfer extends TransferMetadata { - type: 'text'; - content: string; -} -interface FileTransfer extends TransferMetadata { - type: 'file'; - fileName: string; - fileSize: number; - mimeType: string; - filePath?: string; - durationMs?: number; - speedBytesPerSec?: number; -} -interface FilesTransfer extends TransferMetadata { - type: 'files'; - files: Array<{ - fileName: string; - fileSize: number; - mimeType: string; - filePath?: string; - }>; - totalSize: number; -} -type Transfer = TextTransfer | FileTransfer | FilesTransfer; -interface TransferProgress { - transferId: string; - bytesTransferred: number; - totalBytes: number; - percentage: number; - currentFile?: string; - speedBytesPerSec?: number; - etaSeconds?: number; - elapsedMs?: number; -} -interface TransferQueueItem { - id: string; - fileName: string; - fileSize: number; - status: 'pending' | 'transferring' | 'completed' | 'failed'; - progress: number; - direction: 'send' | 'receive'; -} -type MessageType = 'ping' | 'pong' | 'pair_request' | 'pair_challenge' | 'pair_response' | 'pair_confirm' | 'pair_reject' | 'hello' | 'text' | 'file_request' | 'file_accept' | 'file_reject' | 'file_chunk' | 'file_complete' | 'file_ack' | 'app' | 'error'; -interface Message { - type: MessageType; - id: string; - timestamp: number; - payload?: unknown; -} -/** Generic encrypted application message: a channel name + arbitrary payload. - * Lets features (memory sync, clipboard sync, ...) ride the paired channel - * without each needing its own protocol message type. */ -interface AppMessage extends Message { - type: 'app'; - payload: { - channel: string; - data: unknown; - }; -} -/** Reconnect greeting: identifies the device so an already-paired peer can - * resume with the stored shared secret, skipping the pairing handshake. */ -interface HelloMessage extends Message { - type: 'hello'; - payload: { - deviceInfo: DeviceInfo; - }; -} -interface PingMessage extends Message { - type: 'ping'; -} -interface PongMessage extends Message { - type: 'pong'; -} -interface PairRequestMessage extends Message { - type: 'pair_request'; - payload: { - deviceInfo: DeviceInfo; - }; -} -interface PairChallengeMessage extends Message { - type: 'pair_challenge'; - payload: PairingChallenge; -} -interface PairResponseMessage extends Message { - type: 'pair_response'; - payload: PairingResponse; -} -interface PairConfirmMessage extends Message { - type: 'pair_confirm'; - payload: { - deviceInfo: DeviceInfo; - }; -} -interface PairRejectMessage extends Message { - type: 'pair_reject'; - payload: { - reason: string; - }; -} -interface TextMessage extends Message { - type: 'text'; - payload: { - content: string; - }; -} -interface FileRequestMessage extends Message { - type: 'file_request'; - payload: { - fileName: string; - fileSize: number; - mimeType: string; - checksum: string; - httpUrl?: string; - }; -} -interface FileAcceptMessage extends Message { - type: 'file_accept'; - payload: { - requestId: string; - uploadUrl?: string; - }; -} -interface FileRejectMessage extends Message { - type: 'file_reject'; - payload: { - requestId: string; - reason: string; - }; -} -interface FileChunkMessage extends Message { - type: 'file_chunk'; - payload: { - requestId: string; - chunkIndex: number; - totalChunks: number; - data: string; - }; -} -interface FileCompleteMessage extends Message { - type: 'file_complete'; - payload: { - requestId: string; - checksum: string; - }; -} -interface FileAckMessage extends Message { - type: 'file_ack'; - payload: { - requestId: string; - success: boolean; - }; -} -interface ErrorMessage extends Message { - type: 'error'; - payload: { - code: string; - message: string; - originalMessageId?: string; - }; -} -type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; -type PairingStep = 'idle' | 'connecting' | 'sending_request' | 'waiting_for_passphrase' | 'deriving_key' | 'sending_challenge' | 'waiting_for_challenge' | 'responding_to_challenge' | 'verifying_response' | 'confirming' | 'success' | 'failed'; -interface ConnectionState { - status: ConnectionStatus; - device?: DeviceInfo; - error?: string; - /** Verbose status message for UI display */ - statusMessage?: string; - /** Current step in the pairing process */ - pairingStep?: PairingStep; -} -interface AppSettings { - deviceName: string; - deviceId: string; - autoAcceptFromPaired: boolean; - saveDirectory: string; - notificationsEnabled: boolean; -} -interface StoredData { - settings: AppSettings; - pairedDevices: PairedDevice[]; - transferHistory: Transfer[]; -} - -declare const MDNS_SERVICE_TYPE = "_easyshare._tcp"; -declare const MDNS_SERVICE_NAME = "EasyShare"; -declare const MDNS_DOMAIN = "local"; -declare const TXT_DEVICE_ID = "id"; -declare const TXT_DEVICE_NAME = "name"; -declare const TXT_PLATFORM = "platform"; -declare const TXT_VERSION = "version"; -/** - * Create TXT record data for mDNS advertisement - */ -declare function createTxtRecord(device: DeviceInfo): Record; -/** - * Parse TXT record data from mDNS discovery - */ -declare function parseTxtRecord(txt: Record, host: string, port: number): DeviceInfo | null; -/** - * Create a DiscoveredDevice from DeviceInfo - */ -declare function createDiscoveredDevice(device: DeviceInfo): DiscoveredDevice; -/** - * Check if a discovered device is stale (not seen recently) - */ -declare function isDeviceStale(device: DiscoveredDevice, maxAgeMs?: number): boolean; -/** - * Filter out stale devices from a list - */ -declare function filterStaleDevices(devices: DiscoveredDevice[], maxAgeMs?: number): DiscoveredDevice[]; -/** - * Update or add a device to a list of discovered devices - */ -declare function updateDeviceList(devices: DiscoveredDevice[], newDevice: DiscoveredDevice): DiscoveredDevice[]; -/** - * Remove a device from the list by ID - */ -declare function removeDevice(devices: DiscoveredDevice[], deviceId: string): DiscoveredDevice[]; -interface DiscoveryService { - start(): Promise; - stop(): Promise; - advertise(device: DeviceInfo): Promise; - stopAdvertising(): Promise; - onDeviceFound(callback: (device: DiscoveredDevice) => void): void; - onDeviceLost(callback: (deviceId: string) => void): void; -} - -export { updateDeviceList as $, type AppMessage as A, type PairingStep as B, type ConnectionState as C, type DiscoveryService as D, type ErrorMessage as E, type FileAcceptMessage as F, type PingMessage as G, type HelloMessage as H, type PongMessage as I, TXT_DEVICE_ID as J, TXT_DEVICE_NAME as K, TXT_PLATFORM as L, type Message as M, TXT_VERSION as N, type Transfer as O, type PairingStatus as P, type TransferMetadata as Q, type TransferQueueItem as R, type StoredData as S, type TransferProgress as T, type TransferType as U, createDiscoveredDevice as V, createTxtRecord as W, filterStaleDevices as X, isDeviceStale as Y, parseTxtRecord as Z, removeDevice as _, type DeviceInfo as a, type DiscoveredDevice as b, type PairChallengeMessage as c, type PairConfirmMessage as d, type PairRejectMessage as e, type PairRequestMessage as f, type PairResponseMessage as g, type PairedDevice as h, type TextMessage as i, type FileAckMessage as j, type FileChunkMessage as k, type FileCompleteMessage as l, type FileRejectMessage as m, type FileRequestMessage as n, type FileTransfer as o, type TextTransfer as p, type AppSettings as q, type ConnectionStatus as r, type DevicePlatform as s, type FilesTransfer as t, MDNS_DOMAIN as u, MDNS_SERVICE_NAME as v, MDNS_SERVICE_TYPE as w, type MessageType as x, type PairingChallenge as y, type PairingResponse as z }; diff --git a/packages/sync/dist/index.d.mts b/packages/sync/dist/index.d.mts deleted file mode 100644 index ca34b955..00000000 --- a/packages/sync/dist/index.d.mts +++ /dev/null @@ -1,575 +0,0 @@ -import { P as PairingStatus, a as DeviceInfo, c as PairChallengeMessage, d as PairConfirmMessage, e as PairRejectMessage, f as PairRequestMessage, g as PairResponseMessage, h as PairedDevice, M as Message, T as TransferProgress, i as TextMessage, F as FileAcceptMessage, j as FileAckMessage, k as FileChunkMessage, l as FileCompleteMessage, m as FileRejectMessage, n as FileRequestMessage, o as FileTransfer, p as TextTransfer, D as DiscoveryService, b as DiscoveredDevice } from './index-D7PLqM1E.mjs'; -export { A as AppMessage, q as AppSettings, C as ConnectionState, r as ConnectionStatus, s as DevicePlatform, E as ErrorMessage, t as FilesTransfer, H as HelloMessage, u as MDNS_DOMAIN, v as MDNS_SERVICE_NAME, w as MDNS_SERVICE_TYPE, x as MessageType, y as PairingChallenge, z as PairingResponse, B as PairingStep, G as PingMessage, I as PongMessage, S as StoredData, J as TXT_DEVICE_ID, K as TXT_DEVICE_NAME, L as TXT_PLATFORM, N as TXT_VERSION, O as Transfer, Q as TransferMetadata, R as TransferQueueItem, U as TransferType, V as createDiscoveredDevice, W as createTxtRecord, X as filterStaleDevices, Y as isDeviceStale, Z as parseTxtRecord, _ as removeDevice, $ as updateDeviceList } from './index-D7PLqM1E.mjs'; -import { T as TransportBridge, S as SyncConnection } from './transport-1cXLtrs5.mjs'; -export { decodeBase64, decodeUTF8, encodeBase64, encodeUTF8 } from 'tweetnacl-util'; - -/** - * Generate a random device ID - */ -declare function generateDeviceId(): string; -/** - * Generate a random message ID - */ -declare function generateMessageId(): string; -/** - * Simple PBKDF2-like key derivation using iterated hashing - * Note: This is a simplified implementation using NaCl primitives - */ -declare function deriveKey(passphrase: string, salt: Uint8Array, iterations?: number): Uint8Array; -/** - * Derive a shared secret from a passphrase and two device IDs - * This ensures both devices derive the same key - */ -declare function deriveSharedSecret(passphrase: string, deviceId1: string, deviceId2: string): string; -/** - * Generate a random challenge for pairing verification - */ -declare function generateChallenge(): string; -/** - * Create an HMAC-like response to a challenge using the shared secret - */ -declare function createChallengeResponse(challenge: string, sharedSecret: string): string; -/** - * Verify a challenge response - */ -declare function verifyChallengeResponse(challenge: string, response: string, sharedSecret: string): boolean; -/** - * Encrypt data using NaCl secretbox (XSalsa20-Poly1305) - */ -declare function encrypt(data: string | Uint8Array, secretKey: string): { - encrypted: string; - nonce: string; -}; -/** - * Decrypt data using NaCl secretbox - */ -declare function decrypt(encrypted: string, nonce: string, secretKey: string): Uint8Array | null; -/** - * Decrypt data and return as string - */ -declare function decryptToString(encrypted: string, nonce: string, secretKey: string): string | null; -/** - * Calculate a checksum for file integrity verification - */ -declare function calculateChecksum(data: Uint8Array): string; -/** - * Verify a checksum - */ -declare function verifyChecksum(data: Uint8Array, checksum: string): boolean; -/** - * Incremental/streaming checksum calculator using SHA-512. - * Produces the same output format as calculateChecksum() (base64 of first 16 bytes of SHA-512) - * but allows feeding data in chunks to avoid loading entire files into memory. - */ -declare class IncrementalChecksum { - private hasher; - constructor(); - /** - * Feed a chunk of data into the hash - */ - update(data: Uint8Array): void; - /** - * Finalize and return checksum in the same format as calculateChecksum() - * (base64 of first 16 bytes of SHA-512 digest) - */ - digest(): string; -} - -/** - * Pairing state machine for managing the pairing handshake - */ -interface PairingState { - status: PairingStatus; - localDevice: DeviceInfo; - remoteDevice?: DeviceInfo; - passphrase?: string; - sharedSecret?: string; - challenge?: string; - error?: string; -} -/** - * Create initial pairing state - */ -declare function createPairingState(localDevice: DeviceInfo): PairingState; -/** - * Create a pair request message - */ -declare function createPairRequest(localDevice: DeviceInfo): PairRequestMessage; -/** - * Create a pair challenge message - */ -declare function createPairChallenge(): PairChallengeMessage; -/** - * Create a pair response message - */ -declare function createPairResponse(challenge: string, sharedSecret: string, localDevice: DeviceInfo): PairResponseMessage; -/** - * Create a pair confirm message - */ -declare function createPairConfirm(localDevice: DeviceInfo): PairConfirmMessage; -/** - * Create a pair reject message - */ -declare function createPairReject(reason: string): PairRejectMessage; -/** - * Handle pairing state transitions - */ -declare function handlePairingMessage(state: PairingState, message: Message, passphrase?: string): { - newState: PairingState; - response?: Message; -}; -/** - * Create a PairedDevice from successful pairing - */ -declare function createPairedDevice(state: PairingState): PairedDevice | null; -/** - * Check if a device is already paired - */ -declare function isPaired(deviceId: string, pairedDevices: PairedDevice[]): boolean; -/** - * Get a paired device by ID - */ -declare function getPairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice | undefined; -/** - * Update last connected time for a paired device - */ -declare function updateLastConnected(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; -/** - * Remove a paired device - */ -declare function removePairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; - -declare const CHUNK_SIZE: number; -declare const MAX_TEXT_LENGTH: number; -/** - * Create a text transfer record - */ -declare function createTextTransfer(content: string, device: DeviceInfo, direction: 'send' | 'receive'): TextTransfer; -/** - * Create a file transfer record - */ -declare function createFileTransfer(fileName: string, fileSize: number, mimeType: string, device: DeviceInfo, direction: 'send' | 'receive', durationMs?: number): FileTransfer; -/** - * Create a text message - */ -declare function createTextMessage(content: string): TextMessage; -/** - * Create an encrypted text message - */ -declare function createEncryptedTextMessage(content: string, secretKey: string): { - message: TextMessage; - nonce: string; -}; -/** - * Decrypt a text message - */ -declare function decryptTextMessage(message: TextMessage, nonce: string, secretKey: string): string | null; -/** - * Create a file request message - */ -declare function createFileRequest(fileName: string, fileSize: number, mimeType: string, fileData: Uint8Array): FileRequestMessage; -/** - * Create a file request message with a pre-computed checksum (for streaming/large files). - * Avoids needing the entire file in memory. - */ -declare function createFileRequestStreaming(fileName: string, fileSize: number, mimeType: string, checksum: string): FileRequestMessage; -/** - * Create a file complete message with a pre-computed checksum (for streaming/large files). - * Avoids needing the entire file in memory. - */ -declare function createFileCompleteStreaming(requestId: string, checksum: string): FileCompleteMessage; -/** - * Create a file request message with an HTTP download URL (for large files sent via HTTP). - */ -declare function createFileRequestHttp(fileName: string, fileSize: number, mimeType: string, checksum: string, httpUrl: string): FileRequestMessage; -/** - * Create a file accept message - */ -declare function createFileAccept(requestId: string): FileAcceptMessage; -/** - * Create a file accept message with an HTTP upload URL (for receiving large files via HTTP). - */ -declare function createFileAcceptHttp(requestId: string, uploadUrl: string): FileAcceptMessage; -/** - * Create a file ack message (sent after HTTP transfer completes). - */ -declare function createFileAck(requestId: string, success: boolean): FileAckMessage; -/** - * Create a file reject message - */ -declare function createFileReject(requestId: string, reason: string): FileRejectMessage; -/** - * Create a file chunk message - */ -declare function createFileChunk(requestId: string, chunkIndex: number, totalChunks: number, data: Uint8Array): FileChunkMessage; -/** - * Create a file chunk message from already-base64-encoded data. - * Avoids the decode → re-encode roundtrip when data is read as base64 from disk. - */ -declare function createFileChunkFromBase64(requestId: string, chunkIndex: number, totalChunks: number, base64Data: string): FileChunkMessage; -/** - * Create a file complete message - */ -declare function createFileComplete(requestId: string, fileData: Uint8Array): FileCompleteMessage; -/** - * Split file data into chunks - */ -declare function chunkFile(data: Uint8Array, chunkSize?: number): Generator<{ - chunk: Uint8Array; - index: number; - total: number; -}>; -/** - * Reassemble chunks into complete file data - */ -declare function reassembleChunks(chunks: Map, totalChunks: number): Uint8Array | null; -/** - * Calculate transfer progress with optional speed/ETA computation - */ -declare function calculateProgress(transferId: string, bytesTransferred: number, totalBytes: number, currentFile?: string, startTime?: number): TransferProgress; -/** - * Verify received file integrity - */ -declare function verifyFileIntegrity(data: Uint8Array, expectedChecksum: string): boolean; -/** - * Format file size for display - */ -declare function formatFileSize(bytes: number): string; -/** - * Format transfer speed for display - */ -declare function formatTransferSpeed(bytesPerSec: number): string; -/** - * Format transfer duration for display - */ -declare function formatDuration(ms: number): string; -/** - * Format ETA for display - */ -declare function formatEta(seconds: number): string; -/** - * Format live transfer progress info string (speed · elapsed · ETA) - */ -declare function formatProgressInfo(progress: TransferProgress): string; -/** - * Get MIME type from file extension - */ -declare function getMimeType(fileName: string): string; - -declare const PROTOCOL_VERSION = "1.0.0"; -declare const HEADER_LENGTH = 5; -declare const MAX_MESSAGE_SIZE: number; -declare const MESSAGE_TYPE_CODES: Record; -/** Build a reconnect hello identifying the local device. */ -declare function createHello(deviceInfo: unknown): Message; -/** Build a generic encrypted application message for a named channel. */ -declare function createAppMessage(channel: string, data: unknown): Message; -declare const MESSAGE_CODE_TYPES: Record; -/** - * Serialize a message to a buffer for transmission - */ -declare function serializeMessage(message: Message): Uint8Array; -/** - * Deserialize a message from a buffer - */ -declare function deserializeMessage(buffer: Uint8Array): Message | null; -/** - * Get the expected message length from a header - */ -declare function getMessageLength(header: Uint8Array): number | null; -/** - * Encrypt a message for transmission over an established connection - */ -declare function encryptMessage(message: Message, secretKey: string): { - encrypted: Uint8Array; - nonce: string; -}; -/** - * Decrypt a received encrypted message - */ -declare function decryptMessage(encrypted: Uint8Array, nonce: string, secretKey: string): Message | null; -/** - * Message frame for encrypted transmission - * Format: [nonce length (1 byte)] [nonce] [encrypted data] - */ -declare function createEncryptedFrame(encrypted: Uint8Array, nonce: string): Uint8Array; -/** - * Parse an encrypted frame - */ -declare function parseEncryptedFrame(frame: Uint8Array): { - encrypted: Uint8Array; - nonce: string; -} | null; -/** - * Buffer for accumulating incoming data and extracting complete messages - */ -declare class MessageBuffer { - private buffer; - /** - * Add data to the buffer - */ - append(data: Uint8Array): void; - /** - * Try to extract a complete message from the buffer - */ - extractMessage(): Message | null; - /** - * Extract all complete messages from the buffer - */ - extractAllMessages(): Message[]; - /** - * Get current buffer size - */ - get size(): number; - /** - * Clear the buffer - */ - clear(): void; -} -/** - * Create a ping message - */ -declare function createPingMessage(): Message; -/** - * Create a pong message in response to a ping - */ -declare function createPongMessage(pingId: string): Message; -/** - * Create an error message - */ -declare function createErrorMessage(code: string, errorMessage: string, originalMessageId?: string): Message; - -declare const FRAME_HEADER_LENGTH = 4; -declare const MAX_FRAME_SIZE: number; -declare const FRAME_KIND_PLAINTEXT = 0; -declare const FRAME_KIND_ENCRYPTED = 1; -/** Encode a plaintext (unencrypted) message frame, used for pairing. */ -declare function encodePlaintextFrame(message: Message): Uint8Array; -/** Encode an encrypted message frame using the per-pair shared secret. */ -declare function encodeEncryptedFrame(message: Message, secretKey: string): Uint8Array; -type DecodedFrame = { - kind: 'plaintext'; - message: Message; -} | { - kind: 'encrypted'; - message: Message; -}; -/** Decode one frame body. `secretKey` is required to read encrypted frames. */ -declare function decodeFrameBody(body: Uint8Array, secretKey?: string): DecodedFrame | null; -/** - * Accumulates incoming bytes and yields complete frame bodies. The shared - * secret can be set once pairing succeeds so later encrypted frames decode. - */ -declare class FrameBuffer { - private buffer; - append(data: Uint8Array): void; - /** Pull the next complete frame body, or null if none is fully buffered. */ - private nextBody; - /** Decode all complete frames currently buffered. */ - drain(secretKey?: string): DecodedFrame[]; -} - -declare const FREE_DEVICE_CAP = 2; -interface DeviceCapPolicy { - /** Max distinct paired devices allowed. Free returns FREE_DEVICE_CAP; a pro - * entitlement returns a higher number or Infinity. */ - limit(): number; -} -/** A fixed free-tier policy. */ -declare const freePolicy: DeviceCapPolicy; -/** Build a policy from a pro flag supplied by the host's entitlement check. */ -declare function policyFor(isPro: boolean, proLimit?: number): DeviceCapPolicy; -interface DeviceCap { - policy: DeviceCapPolicy; - /** How many distinct devices are already paired (from the host's store). */ - pairedCount: () => number; - /** Whether this device id is already paired (re-pairing does not count). */ - isKnown: (deviceId: string) => boolean; -} -/** True if pairing with `deviceId` is allowed under the cap. */ -declare function pairingAllowed(cap: DeviceCap | undefined, deviceId: string): boolean; - -interface SyncEngineOptions { - localDevice: DeviceInfo; - transport: TransportBridge; - /** Supply the passphrase for an incoming pairing (e.g. a UI prompt). Return - * null/undefined to refuse. Not needed on the side that calls connect(). */ - getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; - /** Application message from a paired peer (pairing traffic is handled internally). */ - onMessage?: (deviceId: string, message: Message) => void; - /** Generic app-channel message from a paired peer (type 'app'). Used by - * features like memory/clipboard sync that ride the paired channel. */ - onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; - /** Look up the stored shared secret for an already-paired device, so an - * inbound reconnect (hello) can resume without re-running the handshake. */ - getSharedSecret?: (deviceId: string) => string | undefined; - /** A pairing handshake completed. */ - onPaired?: (device: PairedDevice) => void; - /** A pairing attempt failed. */ - onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; - /** Optional device cap (open-core 2 free / 3+ paid). When set, pairing a new - * device beyond the limit is refused on both the dialing and accepting side. */ - cap?: DeviceCap; -} -/** One peer connection: owns its frame buffer, pairing state, and shared secret. */ -declare class PeerSession { - readonly conn: SyncConnection; - private readonly engine; - private readonly opts; - private buffer; - private pairing; - private sharedSecret?; - private passphrase?; - private resumeSecret?; - private helloSent; - private queue; - remoteDevice?: DeviceInfo; - constructor(conn: SyncConnection, engine: SyncEngine, opts: SyncEngineOptions, initiateWith?: { - remote: DeviceInfo; - passphrase: string; - }, resumeWith?: { - remote: DeviceInfo; - sharedSecret: string; - }); - get pairedSecret(): string | undefined; - /** Send an application message to this peer (must be paired). */ - sendMessage(message: Message): boolean; - private sendPlain; - private onData; - private route; - /** Resume an already-paired device using the stored secret (no handshake). */ - private handleHello; - private handlePairing; -} -declare class SyncEngine { - private readonly opts; - private sessions; - private paired; - constructor(opts: SyncEngineOptions); - /** Start accepting inbound connections on `port`. */ - start(port: number): Promise; - /** Dial a discovered device and begin pairing with `passphrase`. Refuses if - * pairing a new device would exceed the device cap. */ - pair(device: DeviceInfo, passphrase: string): Promise; - /** Reconnect to an already-paired device using its stored shared secret, - * skipping the pairing handshake. Used for auto-reconnect on discovery. */ - reconnect(device: DeviceInfo, sharedSecret: string): Promise; - /** Send an application message to an already-paired device. */ - send(deviceId: string, message: Message): boolean; - /** Send a generic app-channel message (encrypted) to a paired device. */ - sendApp(deviceId: string, channel: string, data: unknown): boolean; - isPaired(deviceId: string): boolean; - stop(): Promise; - /** @internal */ - _registerPaired(deviceId: string, session: PeerSession): void; - /** @internal */ - _removeSession(session: PeerSession): void; -} - -/** The slice of SyncEngine the orchestrator drives. */ -interface ReconnectingEngine { - isPaired(deviceId: string): boolean; - reconnect(device: DeviceInfo, sharedSecret: string): Promise; -} -interface DiscoveryOrchestratorOptions { - engine: ReconnectingEngine; - discovery: DiscoveryService; - localDevice: DeviceInfo; - /** Stored shared secret for a device, or undefined if not yet paired. */ - getSharedSecret: (deviceId: string) => string | undefined; - /** A discovered device we have no secret for - surface it so the UI can pair. */ - onDiscovered?: (device: DiscoveredDevice) => void; - /** A previously discovered device went away. */ - onLost?: (deviceId: string) => void; -} -declare class DiscoveryOrchestrator { - private readonly opts; - private connecting; - constructor(opts: DiscoveryOrchestratorOptions); - start(): Promise; - stop(): Promise; - private handleFound; -} - -type OpKind = 'put' | 'delete'; -interface Op { - /** Globally-unique op id (uuid). The dedup key across devices. */ - opId: string; - /** Logical record type, e.g. 'conversation' | 'message' | 'project'. */ - entity: string; - /** Stable id of the record this op mutates (must be a UUID, not autoincrement). */ - entityId: string; - kind: OpKind; - /** Full record fields for a 'put' (whole-record LWW). Omitted for 'delete'. */ - fields?: Record; - /** Lamport logical clock. */ - lamport: number; - /** Origin device id. */ - deviceId: string; - /** Wall-clock ms (display / human tiebreak only — never used for ordering). */ - ts: number; -} -/** Version vector: per-device highest lamport seen. */ -type VersionVector = Record; -/** How the op-log writes materialized state into the host's real store. */ -interface Materializer { - put(entity: string, entityId: string, fields: Record): void; - remove(entity: string, entityId: string): void; -} -interface OpLogOptions { - deviceId: string; - materializer: Materializer; - /** Persist a single newly-accepted op (e.g. INSERT into sync_ops). */ - persist?: (op: Op) => void; - /** Generate a uuid (host injects: crypto.randomUUID on Node, a polyfill on RN). */ - uuid: () => string; - /** Wall clock ms. Injected so the core stays free of Date.now (testable). */ - now: () => number; - /** Ops already on disk, to rehydrate the log at startup. */ - persisted?: Op[]; -} -declare class OpLog { - private readonly opts; - private ops; - private clock; - constructor(opts: OpLogOptions); - /** Per-device highest lamport — what we tell a peer we already have. */ - versionVector(): VersionVector; - /** Ops the peer (described by their version vector) hasn't seen yet. */ - opsSince(peerVV: VersionVector): Op[]; - /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ - record(entity: string, entityId: string, kind: OpKind, fields?: Record): Op; - /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ - ingest(incoming: Op[]): Op[]; - /** Recompute the winning op for one record and push it to the materializer. */ - private rematerialize; - /** Total ops held (diagnostics). */ - size(): number; -} - -type StateMsg = { - t: 'have'; - vv: VersionVector; -} | { - t: 'ops'; - ops: Op[]; -}; -interface StateSyncOptions { - oplog: OpLog; - /** Send a state message to one peer (host wires → sendApp(id,'state',msg)). */ - send: (deviceId: string, msg: StateMsg) => void; -} -declare class StateSync { - private readonly opts; - constructor(opts: StateSyncOptions); - /** A peer connected: advertise our version vector so it can backfill us; we - * backfill it when its own `have` arrives. */ - onConnect(deviceId: string): void; - /** Inbound message on the 'state' channel from a paired peer. */ - onMessage(deviceId: string, data: unknown): void; -} - -declare const VERSION = "0.0.1"; -declare const APP_NAME = "Off Grid Sync"; - -export { APP_NAME, CHUNK_SIZE, type DecodedFrame, type DeviceCap, type DeviceCapPolicy, DeviceInfo, DiscoveredDevice, DiscoveryOrchestrator, type DiscoveryOrchestratorOptions, DiscoveryService, FRAME_HEADER_LENGTH, FRAME_KIND_ENCRYPTED, FRAME_KIND_PLAINTEXT, FREE_DEVICE_CAP, FileAcceptMessage, FileAckMessage, FileChunkMessage, FileCompleteMessage, FileRejectMessage, FileRequestMessage, FileTransfer, FrameBuffer, HEADER_LENGTH, IncrementalChecksum, MAX_FRAME_SIZE, MAX_MESSAGE_SIZE, MAX_TEXT_LENGTH, MESSAGE_CODE_TYPES, MESSAGE_TYPE_CODES, type Materializer, Message, MessageBuffer, type Op, type OpKind, OpLog, type OpLogOptions, PROTOCOL_VERSION, PairChallengeMessage, PairConfirmMessage, PairRejectMessage, PairRequestMessage, PairResponseMessage, PairedDevice, type PairingState, PairingStatus, type ReconnectingEngine, type StateMsg, StateSync, type StateSyncOptions, SyncConnection, SyncEngine, type SyncEngineOptions, TextMessage, TextTransfer, TransferProgress, TransportBridge, VERSION, type VersionVector, calculateChecksum, calculateProgress, chunkFile, createAppMessage, createChallengeResponse, createEncryptedFrame, createEncryptedTextMessage, createErrorMessage, createFileAccept, createFileAcceptHttp, createFileAck, createFileChunk, createFileChunkFromBase64, createFileComplete, createFileCompleteStreaming, createFileReject, createFileRequest, createFileRequestHttp, createFileRequestStreaming, createFileTransfer, createHello, createPairChallenge, createPairConfirm, createPairReject, createPairRequest, createPairResponse, createPairedDevice, createPairingState, createPingMessage, createPongMessage, createTextMessage, createTextTransfer, decodeFrameBody, decrypt, decryptMessage, decryptTextMessage, decryptToString, deriveKey, deriveSharedSecret, deserializeMessage, encodeEncryptedFrame, encodePlaintextFrame, encrypt, encryptMessage, formatDuration, formatEta, formatFileSize, formatProgressInfo, formatTransferSpeed, freePolicy, generateChallenge, generateDeviceId, generateMessageId, getMessageLength, getMimeType, getPairedDevice, handlePairingMessage, isPaired, pairingAllowed, parseEncryptedFrame, policyFor, reassembleChunks, removePairedDevice, serializeMessage, updateLastConnected, verifyChallengeResponse, verifyChecksum, verifyFileIntegrity }; diff --git a/packages/sync/dist/index.d.ts b/packages/sync/dist/index.d.ts deleted file mode 100644 index 2ac32fca..00000000 --- a/packages/sync/dist/index.d.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { P as PairingStatus, a as DeviceInfo, c as PairChallengeMessage, d as PairConfirmMessage, e as PairRejectMessage, f as PairRequestMessage, g as PairResponseMessage, h as PairedDevice, M as Message, T as TransferProgress, i as TextMessage, F as FileAcceptMessage, j as FileAckMessage, k as FileChunkMessage, l as FileCompleteMessage, m as FileRejectMessage, n as FileRequestMessage, o as FileTransfer, p as TextTransfer, D as DiscoveryService, b as DiscoveredDevice } from './index-D7PLqM1E.js'; -export { A as AppMessage, q as AppSettings, C as ConnectionState, r as ConnectionStatus, s as DevicePlatform, E as ErrorMessage, t as FilesTransfer, H as HelloMessage, u as MDNS_DOMAIN, v as MDNS_SERVICE_NAME, w as MDNS_SERVICE_TYPE, x as MessageType, y as PairingChallenge, z as PairingResponse, B as PairingStep, G as PingMessage, I as PongMessage, S as StoredData, J as TXT_DEVICE_ID, K as TXT_DEVICE_NAME, L as TXT_PLATFORM, N as TXT_VERSION, O as Transfer, Q as TransferMetadata, R as TransferQueueItem, U as TransferType, V as createDiscoveredDevice, W as createTxtRecord, X as filterStaleDevices, Y as isDeviceStale, Z as parseTxtRecord, _ as removeDevice, $ as updateDeviceList } from './index-D7PLqM1E.js'; -import { T as TransportBridge, S as SyncConnection } from './transport-1cXLtrs5.js'; -export { decodeBase64, decodeUTF8, encodeBase64, encodeUTF8 } from 'tweetnacl-util'; - -/** - * Generate a random device ID - */ -declare function generateDeviceId(): string; -/** - * Generate a random message ID - */ -declare function generateMessageId(): string; -/** - * Simple PBKDF2-like key derivation using iterated hashing - * Note: This is a simplified implementation using NaCl primitives - */ -declare function deriveKey(passphrase: string, salt: Uint8Array, iterations?: number): Uint8Array; -/** - * Derive a shared secret from a passphrase and two device IDs - * This ensures both devices derive the same key - */ -declare function deriveSharedSecret(passphrase: string, deviceId1: string, deviceId2: string): string; -/** - * Generate a random challenge for pairing verification - */ -declare function generateChallenge(): string; -/** - * Create an HMAC-like response to a challenge using the shared secret - */ -declare function createChallengeResponse(challenge: string, sharedSecret: string): string; -/** - * Verify a challenge response - */ -declare function verifyChallengeResponse(challenge: string, response: string, sharedSecret: string): boolean; -/** - * Encrypt data using NaCl secretbox (XSalsa20-Poly1305) - */ -declare function encrypt(data: string | Uint8Array, secretKey: string): { - encrypted: string; - nonce: string; -}; -/** - * Decrypt data using NaCl secretbox - */ -declare function decrypt(encrypted: string, nonce: string, secretKey: string): Uint8Array | null; -/** - * Decrypt data and return as string - */ -declare function decryptToString(encrypted: string, nonce: string, secretKey: string): string | null; -/** - * Calculate a checksum for file integrity verification - */ -declare function calculateChecksum(data: Uint8Array): string; -/** - * Verify a checksum - */ -declare function verifyChecksum(data: Uint8Array, checksum: string): boolean; -/** - * Incremental/streaming checksum calculator using SHA-512. - * Produces the same output format as calculateChecksum() (base64 of first 16 bytes of SHA-512) - * but allows feeding data in chunks to avoid loading entire files into memory. - */ -declare class IncrementalChecksum { - private hasher; - constructor(); - /** - * Feed a chunk of data into the hash - */ - update(data: Uint8Array): void; - /** - * Finalize and return checksum in the same format as calculateChecksum() - * (base64 of first 16 bytes of SHA-512 digest) - */ - digest(): string; -} - -/** - * Pairing state machine for managing the pairing handshake - */ -interface PairingState { - status: PairingStatus; - localDevice: DeviceInfo; - remoteDevice?: DeviceInfo; - passphrase?: string; - sharedSecret?: string; - challenge?: string; - error?: string; -} -/** - * Create initial pairing state - */ -declare function createPairingState(localDevice: DeviceInfo): PairingState; -/** - * Create a pair request message - */ -declare function createPairRequest(localDevice: DeviceInfo): PairRequestMessage; -/** - * Create a pair challenge message - */ -declare function createPairChallenge(): PairChallengeMessage; -/** - * Create a pair response message - */ -declare function createPairResponse(challenge: string, sharedSecret: string, localDevice: DeviceInfo): PairResponseMessage; -/** - * Create a pair confirm message - */ -declare function createPairConfirm(localDevice: DeviceInfo): PairConfirmMessage; -/** - * Create a pair reject message - */ -declare function createPairReject(reason: string): PairRejectMessage; -/** - * Handle pairing state transitions - */ -declare function handlePairingMessage(state: PairingState, message: Message, passphrase?: string): { - newState: PairingState; - response?: Message; -}; -/** - * Create a PairedDevice from successful pairing - */ -declare function createPairedDevice(state: PairingState): PairedDevice | null; -/** - * Check if a device is already paired - */ -declare function isPaired(deviceId: string, pairedDevices: PairedDevice[]): boolean; -/** - * Get a paired device by ID - */ -declare function getPairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice | undefined; -/** - * Update last connected time for a paired device - */ -declare function updateLastConnected(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; -/** - * Remove a paired device - */ -declare function removePairedDevice(deviceId: string, pairedDevices: PairedDevice[]): PairedDevice[]; - -declare const CHUNK_SIZE: number; -declare const MAX_TEXT_LENGTH: number; -/** - * Create a text transfer record - */ -declare function createTextTransfer(content: string, device: DeviceInfo, direction: 'send' | 'receive'): TextTransfer; -/** - * Create a file transfer record - */ -declare function createFileTransfer(fileName: string, fileSize: number, mimeType: string, device: DeviceInfo, direction: 'send' | 'receive', durationMs?: number): FileTransfer; -/** - * Create a text message - */ -declare function createTextMessage(content: string): TextMessage; -/** - * Create an encrypted text message - */ -declare function createEncryptedTextMessage(content: string, secretKey: string): { - message: TextMessage; - nonce: string; -}; -/** - * Decrypt a text message - */ -declare function decryptTextMessage(message: TextMessage, nonce: string, secretKey: string): string | null; -/** - * Create a file request message - */ -declare function createFileRequest(fileName: string, fileSize: number, mimeType: string, fileData: Uint8Array): FileRequestMessage; -/** - * Create a file request message with a pre-computed checksum (for streaming/large files). - * Avoids needing the entire file in memory. - */ -declare function createFileRequestStreaming(fileName: string, fileSize: number, mimeType: string, checksum: string): FileRequestMessage; -/** - * Create a file complete message with a pre-computed checksum (for streaming/large files). - * Avoids needing the entire file in memory. - */ -declare function createFileCompleteStreaming(requestId: string, checksum: string): FileCompleteMessage; -/** - * Create a file request message with an HTTP download URL (for large files sent via HTTP). - */ -declare function createFileRequestHttp(fileName: string, fileSize: number, mimeType: string, checksum: string, httpUrl: string): FileRequestMessage; -/** - * Create a file accept message - */ -declare function createFileAccept(requestId: string): FileAcceptMessage; -/** - * Create a file accept message with an HTTP upload URL (for receiving large files via HTTP). - */ -declare function createFileAcceptHttp(requestId: string, uploadUrl: string): FileAcceptMessage; -/** - * Create a file ack message (sent after HTTP transfer completes). - */ -declare function createFileAck(requestId: string, success: boolean): FileAckMessage; -/** - * Create a file reject message - */ -declare function createFileReject(requestId: string, reason: string): FileRejectMessage; -/** - * Create a file chunk message - */ -declare function createFileChunk(requestId: string, chunkIndex: number, totalChunks: number, data: Uint8Array): FileChunkMessage; -/** - * Create a file chunk message from already-base64-encoded data. - * Avoids the decode → re-encode roundtrip when data is read as base64 from disk. - */ -declare function createFileChunkFromBase64(requestId: string, chunkIndex: number, totalChunks: number, base64Data: string): FileChunkMessage; -/** - * Create a file complete message - */ -declare function createFileComplete(requestId: string, fileData: Uint8Array): FileCompleteMessage; -/** - * Split file data into chunks - */ -declare function chunkFile(data: Uint8Array, chunkSize?: number): Generator<{ - chunk: Uint8Array; - index: number; - total: number; -}>; -/** - * Reassemble chunks into complete file data - */ -declare function reassembleChunks(chunks: Map, totalChunks: number): Uint8Array | null; -/** - * Calculate transfer progress with optional speed/ETA computation - */ -declare function calculateProgress(transferId: string, bytesTransferred: number, totalBytes: number, currentFile?: string, startTime?: number): TransferProgress; -/** - * Verify received file integrity - */ -declare function verifyFileIntegrity(data: Uint8Array, expectedChecksum: string): boolean; -/** - * Format file size for display - */ -declare function formatFileSize(bytes: number): string; -/** - * Format transfer speed for display - */ -declare function formatTransferSpeed(bytesPerSec: number): string; -/** - * Format transfer duration for display - */ -declare function formatDuration(ms: number): string; -/** - * Format ETA for display - */ -declare function formatEta(seconds: number): string; -/** - * Format live transfer progress info string (speed · elapsed · ETA) - */ -declare function formatProgressInfo(progress: TransferProgress): string; -/** - * Get MIME type from file extension - */ -declare function getMimeType(fileName: string): string; - -declare const PROTOCOL_VERSION = "1.0.0"; -declare const HEADER_LENGTH = 5; -declare const MAX_MESSAGE_SIZE: number; -declare const MESSAGE_TYPE_CODES: Record; -/** Build a reconnect hello identifying the local device. */ -declare function createHello(deviceInfo: unknown): Message; -/** Build a generic encrypted application message for a named channel. */ -declare function createAppMessage(channel: string, data: unknown): Message; -declare const MESSAGE_CODE_TYPES: Record; -/** - * Serialize a message to a buffer for transmission - */ -declare function serializeMessage(message: Message): Uint8Array; -/** - * Deserialize a message from a buffer - */ -declare function deserializeMessage(buffer: Uint8Array): Message | null; -/** - * Get the expected message length from a header - */ -declare function getMessageLength(header: Uint8Array): number | null; -/** - * Encrypt a message for transmission over an established connection - */ -declare function encryptMessage(message: Message, secretKey: string): { - encrypted: Uint8Array; - nonce: string; -}; -/** - * Decrypt a received encrypted message - */ -declare function decryptMessage(encrypted: Uint8Array, nonce: string, secretKey: string): Message | null; -/** - * Message frame for encrypted transmission - * Format: [nonce length (1 byte)] [nonce] [encrypted data] - */ -declare function createEncryptedFrame(encrypted: Uint8Array, nonce: string): Uint8Array; -/** - * Parse an encrypted frame - */ -declare function parseEncryptedFrame(frame: Uint8Array): { - encrypted: Uint8Array; - nonce: string; -} | null; -/** - * Buffer for accumulating incoming data and extracting complete messages - */ -declare class MessageBuffer { - private buffer; - /** - * Add data to the buffer - */ - append(data: Uint8Array): void; - /** - * Try to extract a complete message from the buffer - */ - extractMessage(): Message | null; - /** - * Extract all complete messages from the buffer - */ - extractAllMessages(): Message[]; - /** - * Get current buffer size - */ - get size(): number; - /** - * Clear the buffer - */ - clear(): void; -} -/** - * Create a ping message - */ -declare function createPingMessage(): Message; -/** - * Create a pong message in response to a ping - */ -declare function createPongMessage(pingId: string): Message; -/** - * Create an error message - */ -declare function createErrorMessage(code: string, errorMessage: string, originalMessageId?: string): Message; - -declare const FRAME_HEADER_LENGTH = 4; -declare const MAX_FRAME_SIZE: number; -declare const FRAME_KIND_PLAINTEXT = 0; -declare const FRAME_KIND_ENCRYPTED = 1; -/** Encode a plaintext (unencrypted) message frame, used for pairing. */ -declare function encodePlaintextFrame(message: Message): Uint8Array; -/** Encode an encrypted message frame using the per-pair shared secret. */ -declare function encodeEncryptedFrame(message: Message, secretKey: string): Uint8Array; -type DecodedFrame = { - kind: 'plaintext'; - message: Message; -} | { - kind: 'encrypted'; - message: Message; -}; -/** Decode one frame body. `secretKey` is required to read encrypted frames. */ -declare function decodeFrameBody(body: Uint8Array, secretKey?: string): DecodedFrame | null; -/** - * Accumulates incoming bytes and yields complete frame bodies. The shared - * secret can be set once pairing succeeds so later encrypted frames decode. - */ -declare class FrameBuffer { - private buffer; - append(data: Uint8Array): void; - /** Pull the next complete frame body, or null if none is fully buffered. */ - private nextBody; - /** Decode all complete frames currently buffered. */ - drain(secretKey?: string): DecodedFrame[]; -} - -declare const FREE_DEVICE_CAP = 2; -interface DeviceCapPolicy { - /** Max distinct paired devices allowed. Free returns FREE_DEVICE_CAP; a pro - * entitlement returns a higher number or Infinity. */ - limit(): number; -} -/** A fixed free-tier policy. */ -declare const freePolicy: DeviceCapPolicy; -/** Build a policy from a pro flag supplied by the host's entitlement check. */ -declare function policyFor(isPro: boolean, proLimit?: number): DeviceCapPolicy; -interface DeviceCap { - policy: DeviceCapPolicy; - /** How many distinct devices are already paired (from the host's store). */ - pairedCount: () => number; - /** Whether this device id is already paired (re-pairing does not count). */ - isKnown: (deviceId: string) => boolean; -} -/** True if pairing with `deviceId` is allowed under the cap. */ -declare function pairingAllowed(cap: DeviceCap | undefined, deviceId: string): boolean; - -interface SyncEngineOptions { - localDevice: DeviceInfo; - transport: TransportBridge; - /** Supply the passphrase for an incoming pairing (e.g. a UI prompt). Return - * null/undefined to refuse. Not needed on the side that calls connect(). */ - getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; - /** Application message from a paired peer (pairing traffic is handled internally). */ - onMessage?: (deviceId: string, message: Message) => void; - /** Generic app-channel message from a paired peer (type 'app'). Used by - * features like memory/clipboard sync that ride the paired channel. */ - onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; - /** Look up the stored shared secret for an already-paired device, so an - * inbound reconnect (hello) can resume without re-running the handshake. */ - getSharedSecret?: (deviceId: string) => string | undefined; - /** A pairing handshake completed. */ - onPaired?: (device: PairedDevice) => void; - /** A pairing attempt failed. */ - onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; - /** Optional device cap (open-core 2 free / 3+ paid). When set, pairing a new - * device beyond the limit is refused on both the dialing and accepting side. */ - cap?: DeviceCap; -} -/** One peer connection: owns its frame buffer, pairing state, and shared secret. */ -declare class PeerSession { - readonly conn: SyncConnection; - private readonly engine; - private readonly opts; - private buffer; - private pairing; - private sharedSecret?; - private passphrase?; - private resumeSecret?; - private helloSent; - private queue; - remoteDevice?: DeviceInfo; - constructor(conn: SyncConnection, engine: SyncEngine, opts: SyncEngineOptions, initiateWith?: { - remote: DeviceInfo; - passphrase: string; - }, resumeWith?: { - remote: DeviceInfo; - sharedSecret: string; - }); - get pairedSecret(): string | undefined; - /** Send an application message to this peer (must be paired). */ - sendMessage(message: Message): boolean; - private sendPlain; - private onData; - private route; - /** Resume an already-paired device using the stored secret (no handshake). */ - private handleHello; - private handlePairing; -} -declare class SyncEngine { - private readonly opts; - private sessions; - private paired; - constructor(opts: SyncEngineOptions); - /** Start accepting inbound connections on `port`. */ - start(port: number): Promise; - /** Dial a discovered device and begin pairing with `passphrase`. Refuses if - * pairing a new device would exceed the device cap. */ - pair(device: DeviceInfo, passphrase: string): Promise; - /** Reconnect to an already-paired device using its stored shared secret, - * skipping the pairing handshake. Used for auto-reconnect on discovery. */ - reconnect(device: DeviceInfo, sharedSecret: string): Promise; - /** Send an application message to an already-paired device. */ - send(deviceId: string, message: Message): boolean; - /** Send a generic app-channel message (encrypted) to a paired device. */ - sendApp(deviceId: string, channel: string, data: unknown): boolean; - isPaired(deviceId: string): boolean; - stop(): Promise; - /** @internal */ - _registerPaired(deviceId: string, session: PeerSession): void; - /** @internal */ - _removeSession(session: PeerSession): void; -} - -/** The slice of SyncEngine the orchestrator drives. */ -interface ReconnectingEngine { - isPaired(deviceId: string): boolean; - reconnect(device: DeviceInfo, sharedSecret: string): Promise; -} -interface DiscoveryOrchestratorOptions { - engine: ReconnectingEngine; - discovery: DiscoveryService; - localDevice: DeviceInfo; - /** Stored shared secret for a device, or undefined if not yet paired. */ - getSharedSecret: (deviceId: string) => string | undefined; - /** A discovered device we have no secret for - surface it so the UI can pair. */ - onDiscovered?: (device: DiscoveredDevice) => void; - /** A previously discovered device went away. */ - onLost?: (deviceId: string) => void; -} -declare class DiscoveryOrchestrator { - private readonly opts; - private connecting; - constructor(opts: DiscoveryOrchestratorOptions); - start(): Promise; - stop(): Promise; - private handleFound; -} - -type OpKind = 'put' | 'delete'; -interface Op { - /** Globally-unique op id (uuid). The dedup key across devices. */ - opId: string; - /** Logical record type, e.g. 'conversation' | 'message' | 'project'. */ - entity: string; - /** Stable id of the record this op mutates (must be a UUID, not autoincrement). */ - entityId: string; - kind: OpKind; - /** Full record fields for a 'put' (whole-record LWW). Omitted for 'delete'. */ - fields?: Record; - /** Lamport logical clock. */ - lamport: number; - /** Origin device id. */ - deviceId: string; - /** Wall-clock ms (display / human tiebreak only — never used for ordering). */ - ts: number; -} -/** Version vector: per-device highest lamport seen. */ -type VersionVector = Record; -/** How the op-log writes materialized state into the host's real store. */ -interface Materializer { - put(entity: string, entityId: string, fields: Record): void; - remove(entity: string, entityId: string): void; -} -interface OpLogOptions { - deviceId: string; - materializer: Materializer; - /** Persist a single newly-accepted op (e.g. INSERT into sync_ops). */ - persist?: (op: Op) => void; - /** Generate a uuid (host injects: crypto.randomUUID on Node, a polyfill on RN). */ - uuid: () => string; - /** Wall clock ms. Injected so the core stays free of Date.now (testable). */ - now: () => number; - /** Ops already on disk, to rehydrate the log at startup. */ - persisted?: Op[]; -} -declare class OpLog { - private readonly opts; - private ops; - private clock; - constructor(opts: OpLogOptions); - /** Per-device highest lamport — what we tell a peer we already have. */ - versionVector(): VersionVector; - /** Ops the peer (described by their version vector) hasn't seen yet. */ - opsSince(peerVV: VersionVector): Op[]; - /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ - record(entity: string, entityId: string, kind: OpKind, fields?: Record): Op; - /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ - ingest(incoming: Op[]): Op[]; - /** Recompute the winning op for one record and push it to the materializer. */ - private rematerialize; - /** Total ops held (diagnostics). */ - size(): number; -} - -type StateMsg = { - t: 'have'; - vv: VersionVector; -} | { - t: 'ops'; - ops: Op[]; -}; -interface StateSyncOptions { - oplog: OpLog; - /** Send a state message to one peer (host wires → sendApp(id,'state',msg)). */ - send: (deviceId: string, msg: StateMsg) => void; -} -declare class StateSync { - private readonly opts; - constructor(opts: StateSyncOptions); - /** A peer connected: advertise our version vector so it can backfill us; we - * backfill it when its own `have` arrives. */ - onConnect(deviceId: string): void; - /** Inbound message on the 'state' channel from a paired peer. */ - onMessage(deviceId: string, data: unknown): void; -} - -declare const VERSION = "0.0.1"; -declare const APP_NAME = "Off Grid Sync"; - -export { APP_NAME, CHUNK_SIZE, type DecodedFrame, type DeviceCap, type DeviceCapPolicy, DeviceInfo, DiscoveredDevice, DiscoveryOrchestrator, type DiscoveryOrchestratorOptions, DiscoveryService, FRAME_HEADER_LENGTH, FRAME_KIND_ENCRYPTED, FRAME_KIND_PLAINTEXT, FREE_DEVICE_CAP, FileAcceptMessage, FileAckMessage, FileChunkMessage, FileCompleteMessage, FileRejectMessage, FileRequestMessage, FileTransfer, FrameBuffer, HEADER_LENGTH, IncrementalChecksum, MAX_FRAME_SIZE, MAX_MESSAGE_SIZE, MAX_TEXT_LENGTH, MESSAGE_CODE_TYPES, MESSAGE_TYPE_CODES, type Materializer, Message, MessageBuffer, type Op, type OpKind, OpLog, type OpLogOptions, PROTOCOL_VERSION, PairChallengeMessage, PairConfirmMessage, PairRejectMessage, PairRequestMessage, PairResponseMessage, PairedDevice, type PairingState, PairingStatus, type ReconnectingEngine, type StateMsg, StateSync, type StateSyncOptions, SyncConnection, SyncEngine, type SyncEngineOptions, TextMessage, TextTransfer, TransferProgress, TransportBridge, VERSION, type VersionVector, calculateChecksum, calculateProgress, chunkFile, createAppMessage, createChallengeResponse, createEncryptedFrame, createEncryptedTextMessage, createErrorMessage, createFileAccept, createFileAcceptHttp, createFileAck, createFileChunk, createFileChunkFromBase64, createFileComplete, createFileCompleteStreaming, createFileReject, createFileRequest, createFileRequestHttp, createFileRequestStreaming, createFileTransfer, createHello, createPairChallenge, createPairConfirm, createPairReject, createPairRequest, createPairResponse, createPairedDevice, createPairingState, createPingMessage, createPongMessage, createTextMessage, createTextTransfer, decodeFrameBody, decrypt, decryptMessage, decryptTextMessage, decryptToString, deriveKey, deriveSharedSecret, deserializeMessage, encodeEncryptedFrame, encodePlaintextFrame, encrypt, encryptMessage, formatDuration, formatEta, formatFileSize, formatProgressInfo, formatTransferSpeed, freePolicy, generateChallenge, generateDeviceId, generateMessageId, getMessageLength, getMimeType, getPairedDevice, handlePairingMessage, isPaired, pairingAllowed, parseEncryptedFrame, policyFor, reassembleChunks, removePairedDevice, serializeMessage, updateLastConnected, verifyChallengeResponse, verifyChecksum, verifyFileIntegrity }; diff --git a/packages/sync/dist/index.js b/packages/sync/dist/index.js deleted file mode 100644 index 35388963..00000000 --- a/packages/sync/dist/index.js +++ /dev/null @@ -1,1565 +0,0 @@ -"use strict"; -var __create = Object.create; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getProtoOf = Object.getPrototypeOf; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/index.ts -var index_exports = {}; -__export(index_exports, { - APP_NAME: () => APP_NAME, - CHUNK_SIZE: () => CHUNK_SIZE, - DiscoveryOrchestrator: () => DiscoveryOrchestrator, - FRAME_HEADER_LENGTH: () => FRAME_HEADER_LENGTH, - FRAME_KIND_ENCRYPTED: () => FRAME_KIND_ENCRYPTED, - FRAME_KIND_PLAINTEXT: () => FRAME_KIND_PLAINTEXT, - FREE_DEVICE_CAP: () => FREE_DEVICE_CAP, - FrameBuffer: () => FrameBuffer, - HEADER_LENGTH: () => HEADER_LENGTH, - IncrementalChecksum: () => IncrementalChecksum, - MAX_FRAME_SIZE: () => MAX_FRAME_SIZE, - MAX_MESSAGE_SIZE: () => MAX_MESSAGE_SIZE, - MAX_TEXT_LENGTH: () => MAX_TEXT_LENGTH, - MDNS_DOMAIN: () => MDNS_DOMAIN, - MDNS_SERVICE_NAME: () => MDNS_SERVICE_NAME, - MDNS_SERVICE_TYPE: () => MDNS_SERVICE_TYPE, - MESSAGE_CODE_TYPES: () => MESSAGE_CODE_TYPES, - MESSAGE_TYPE_CODES: () => MESSAGE_TYPE_CODES, - MessageBuffer: () => MessageBuffer, - OpLog: () => OpLog, - PROTOCOL_VERSION: () => PROTOCOL_VERSION, - StateSync: () => StateSync, - SyncEngine: () => SyncEngine, - TXT_DEVICE_ID: () => TXT_DEVICE_ID, - TXT_DEVICE_NAME: () => TXT_DEVICE_NAME, - TXT_PLATFORM: () => TXT_PLATFORM, - TXT_VERSION: () => TXT_VERSION, - VERSION: () => VERSION, - calculateChecksum: () => calculateChecksum, - calculateProgress: () => calculateProgress, - chunkFile: () => chunkFile, - createAppMessage: () => createAppMessage, - createChallengeResponse: () => createChallengeResponse, - createDiscoveredDevice: () => createDiscoveredDevice, - createEncryptedFrame: () => createEncryptedFrame, - createEncryptedTextMessage: () => createEncryptedTextMessage, - createErrorMessage: () => createErrorMessage, - createFileAccept: () => createFileAccept, - createFileAcceptHttp: () => createFileAcceptHttp, - createFileAck: () => createFileAck, - createFileChunk: () => createFileChunk, - createFileChunkFromBase64: () => createFileChunkFromBase64, - createFileComplete: () => createFileComplete, - createFileCompleteStreaming: () => createFileCompleteStreaming, - createFileReject: () => createFileReject, - createFileRequest: () => createFileRequest, - createFileRequestHttp: () => createFileRequestHttp, - createFileRequestStreaming: () => createFileRequestStreaming, - createFileTransfer: () => createFileTransfer, - createHello: () => createHello, - createPairChallenge: () => createPairChallenge, - createPairConfirm: () => createPairConfirm, - createPairReject: () => createPairReject, - createPairRequest: () => createPairRequest, - createPairResponse: () => createPairResponse, - createPairedDevice: () => createPairedDevice, - createPairingState: () => createPairingState, - createPingMessage: () => createPingMessage, - createPongMessage: () => createPongMessage, - createTextMessage: () => createTextMessage, - createTextTransfer: () => createTextTransfer, - createTxtRecord: () => createTxtRecord, - decodeBase64: () => import_tweetnacl_util.decodeBase64, - decodeFrameBody: () => decodeFrameBody, - decodeUTF8: () => import_tweetnacl_util.decodeUTF8, - decrypt: () => decrypt, - decryptMessage: () => decryptMessage, - decryptTextMessage: () => decryptTextMessage, - decryptToString: () => decryptToString, - deriveKey: () => deriveKey, - deriveSharedSecret: () => deriveSharedSecret, - deserializeMessage: () => deserializeMessage, - encodeBase64: () => import_tweetnacl_util.encodeBase64, - encodeEncryptedFrame: () => encodeEncryptedFrame, - encodePlaintextFrame: () => encodePlaintextFrame, - encodeUTF8: () => import_tweetnacl_util.encodeUTF8, - encrypt: () => encrypt, - encryptMessage: () => encryptMessage, - filterStaleDevices: () => filterStaleDevices, - formatDuration: () => formatDuration, - formatEta: () => formatEta, - formatFileSize: () => formatFileSize, - formatProgressInfo: () => formatProgressInfo, - formatTransferSpeed: () => formatTransferSpeed, - freePolicy: () => freePolicy, - generateChallenge: () => generateChallenge, - generateDeviceId: () => generateDeviceId, - generateMessageId: () => generateMessageId, - getMessageLength: () => getMessageLength, - getMimeType: () => getMimeType, - getPairedDevice: () => getPairedDevice, - handlePairingMessage: () => handlePairingMessage, - isDeviceStale: () => isDeviceStale, - isPaired: () => isPaired, - pairingAllowed: () => pairingAllowed, - parseEncryptedFrame: () => parseEncryptedFrame, - parseTxtRecord: () => parseTxtRecord, - policyFor: () => policyFor, - reassembleChunks: () => reassembleChunks, - removeDevice: () => removeDevice, - removePairedDevice: () => removePairedDevice, - serializeMessage: () => serializeMessage, - updateDeviceList: () => updateDeviceList, - updateLastConnected: () => updateLastConnected, - verifyChallengeResponse: () => verifyChallengeResponse, - verifyChecksum: () => verifyChecksum, - verifyFileIntegrity: () => verifyFileIntegrity -}); -module.exports = __toCommonJS(index_exports); - -// src/crypto/index.ts -var import_tweetnacl = __toESM(require("tweetnacl")); -var import_tweetnacl_util = require("tweetnacl-util"); -var import_js_sha512 = require("js-sha512"); -var PBKDF2_ITERATIONS = 1e4; -var SALT_LENGTH = 16; -var KEY_LENGTH = 32; -function generateDeviceId() { - const bytes = import_tweetnacl.default.randomBytes(16); - return (0, import_tweetnacl_util.encodeBase64)(bytes).replace( - /[+/=]/g, - (c) => c === "+" ? "-" : c === "/" ? "_" : "" - ); -} -function generateMessageId() { - const bytes = import_tweetnacl.default.randomBytes(8); - return (0, import_tweetnacl_util.encodeBase64)(bytes).replace( - /[+/=]/g, - (c) => c === "+" ? "-" : c === "/" ? "_" : "" - ); -} -function deriveKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) { - const passphraseBytes = (0, import_tweetnacl_util.decodeUTF8)(passphrase); - const combined = new Uint8Array(passphraseBytes.length + salt.length); - combined.set(passphraseBytes); - combined.set(salt, passphraseBytes.length); - let result = import_tweetnacl.default.hash(combined); - for (let i = 1; i < iterations; i++) { - result = import_tweetnacl.default.hash(result); - } - return result.slice(0, KEY_LENGTH); -} -function deriveSharedSecret(passphrase, deviceId1, deviceId2) { - const sortedIds = [deviceId1, deviceId2].sort(); - const saltString = `${sortedIds[0]}:${sortedIds[1]}`; - const salt = import_tweetnacl.default.hash((0, import_tweetnacl_util.decodeUTF8)(saltString)).slice(0, SALT_LENGTH); - const key = deriveKey(passphrase, salt); - return (0, import_tweetnacl_util.encodeBase64)(key); -} -function generateChallenge() { - const bytes = import_tweetnacl.default.randomBytes(32); - return (0, import_tweetnacl_util.encodeBase64)(bytes); -} -function createChallengeResponse(challenge, sharedSecret) { - const challengeBytes = (0, import_tweetnacl_util.decodeBase64)(challenge); - const secretBytes = (0, import_tweetnacl_util.decodeBase64)(sharedSecret); - const combined = new Uint8Array(challengeBytes.length + secretBytes.length); - combined.set(challengeBytes); - combined.set(secretBytes, challengeBytes.length); - const hash = import_tweetnacl.default.hash(combined); - return (0, import_tweetnacl_util.encodeBase64)(hash.slice(0, 32)); -} -function verifyChallengeResponse(challenge, response, sharedSecret) { - const expectedResponse = createChallengeResponse(challenge, sharedSecret); - return response === expectedResponse; -} -function encrypt(data, secretKey) { - const keyBytes = (0, import_tweetnacl_util.decodeBase64)(secretKey); - const dataBytes = typeof data === "string" ? (0, import_tweetnacl_util.decodeUTF8)(data) : data; - const nonce = import_tweetnacl.default.randomBytes(import_tweetnacl.default.secretbox.nonceLength); - const encrypted = import_tweetnacl.default.secretbox(dataBytes, nonce, keyBytes); - return { - encrypted: (0, import_tweetnacl_util.encodeBase64)(encrypted), - nonce: (0, import_tweetnacl_util.encodeBase64)(nonce) - }; -} -function decrypt(encrypted, nonce, secretKey) { - const keyBytes = (0, import_tweetnacl_util.decodeBase64)(secretKey); - const encryptedBytes = (0, import_tweetnacl_util.decodeBase64)(encrypted); - const nonceBytes = (0, import_tweetnacl_util.decodeBase64)(nonce); - const decrypted = import_tweetnacl.default.secretbox.open(encryptedBytes, nonceBytes, keyBytes); - return decrypted; -} -function decryptToString(encrypted, nonce, secretKey) { - const decrypted = decrypt(encrypted, nonce, secretKey); - if (!decrypted) return null; - return (0, import_tweetnacl_util.encodeUTF8)(decrypted); -} -function calculateChecksum(data) { - const hash = import_tweetnacl.default.hash(data); - return (0, import_tweetnacl_util.encodeBase64)(hash.slice(0, 16)); -} -function verifyChecksum(data, checksum) { - const calculated = calculateChecksum(data); - return calculated === checksum; -} -var IncrementalChecksum = class { - hasher; - constructor() { - this.hasher = import_js_sha512.sha512.create(); - } - /** - * Feed a chunk of data into the hash - */ - update(data) { - this.hasher.update(data); - } - /** - * Finalize and return checksum in the same format as calculateChecksum() - * (base64 of first 16 bytes of SHA-512 digest) - */ - digest() { - const hashArray = this.hasher.array(); - const first16 = new Uint8Array(hashArray.slice(0, 16)); - return (0, import_tweetnacl_util.encodeBase64)(first16); - } -}; - -// src/discovery/index.ts -var MDNS_SERVICE_TYPE = "_easyshare._tcp"; -var MDNS_SERVICE_NAME = "EasyShare"; -var MDNS_DOMAIN = "local"; -var TXT_DEVICE_ID = "id"; -var TXT_DEVICE_NAME = "name"; -var TXT_PLATFORM = "platform"; -var TXT_VERSION = "version"; -function createTxtRecord(device) { - return { - [TXT_DEVICE_ID]: device.id, - [TXT_DEVICE_NAME]: device.name, - [TXT_PLATFORM]: device.platform, - [TXT_VERSION]: device.version - }; -} -function parseTxtRecord(txt, host, port) { - const id = txt[TXT_DEVICE_ID]; - const name = txt[TXT_DEVICE_NAME]; - const platform = txt[TXT_PLATFORM]; - const version = txt[TXT_VERSION]; - if (!id || !name || !platform || !version) { - return null; - } - return { - id, - name, - platform, - version, - host, - port - }; -} -function createDiscoveredDevice(device) { - return { - ...device, - lastSeen: Date.now() - }; -} -function isDeviceStale(device, maxAgeMs = 3e4) { - return Date.now() - device.lastSeen > maxAgeMs; -} -function filterStaleDevices(devices, maxAgeMs = 3e4) { - return devices.filter((device) => !isDeviceStale(device, maxAgeMs)); -} -function updateDeviceList(devices, newDevice) { - const existingIndex = devices.findIndex((d) => d.id === newDevice.id); - if (existingIndex >= 0) { - const updated = [...devices]; - updated[existingIndex] = { ...newDevice, lastSeen: Date.now() }; - return updated; - } - return [...devices, newDevice]; -} -function removeDevice(devices, deviceId) { - return devices.filter((d) => d.id !== deviceId); -} - -// src/pairing/index.ts -function createPairingState(localDevice) { - return { - status: "idle", - localDevice - }; -} -function createPairRequest(localDevice) { - return { - type: "pair_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - deviceInfo: localDevice - } - }; -} -function createPairChallenge() { - const challenge = generateChallenge(); - return { - type: "pair_challenge", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - challenge, - timestamp: Date.now() - } - }; -} -function createPairResponse(challenge, sharedSecret, localDevice) { - const response = createChallengeResponse(challenge, sharedSecret); - return { - type: "pair_response", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - response, - deviceInfo: localDevice - } - }; -} -function createPairConfirm(localDevice) { - return { - type: "pair_confirm", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - deviceInfo: localDevice - } - }; -} -function createPairReject(reason) { - return { - type: "pair_reject", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - reason - } - }; -} -function handlePairingMessage(state, message, passphrase) { - switch (message.type) { - case "pair_request": { - const msg = message; - const remoteDevice = msg.payload.deviceInfo; - if (!passphrase) { - return { - newState: { - ...state, - status: "waiting", - remoteDevice - } - }; - } - const sharedSecret = deriveSharedSecret( - passphrase, - state.localDevice.id, - remoteDevice.id - ); - const challengeMsg = createPairChallenge(); - return { - newState: { - ...state, - status: "verifying", - remoteDevice, - passphrase, - sharedSecret, - challenge: challengeMsg.payload.challenge - }, - response: challengeMsg - }; - } - case "pair_challenge": { - const msg = message; - if (!passphrase || !state.remoteDevice) { - return { - newState: { - ...state, - status: "failed", - error: "Missing passphrase or remote device" - } - }; - } - const sharedSecret = deriveSharedSecret( - passphrase, - state.localDevice.id, - state.remoteDevice.id - ); - const responseMsg = createPairResponse( - msg.payload.challenge, - sharedSecret, - state.localDevice - ); - return { - newState: { - ...state, - status: "verifying", - sharedSecret - }, - response: responseMsg - }; - } - case "pair_response": { - const msg = message; - if (!state.sharedSecret || !state.challenge) { - return { - newState: { - ...state, - status: "failed", - error: "Invalid pairing state" - } - }; - } - const isValid = verifyChallengeResponse( - state.challenge, - msg.payload.response, - state.sharedSecret - ); - if (isValid) { - const confirmMsg = createPairConfirm(state.localDevice); - return { - newState: { - ...state, - status: "success", - remoteDevice: msg.payload.deviceInfo - }, - response: confirmMsg - }; - } else { - const rejectMsg = createPairReject("Passphrase mismatch"); - return { - newState: { - ...state, - status: "failed", - error: "Passphrase mismatch" - }, - response: rejectMsg - }; - } - } - case "pair_confirm": { - return { - newState: { - ...state, - status: "success" - } - }; - } - case "pair_reject": { - const msg = message; - return { - newState: { - ...state, - status: "failed", - error: msg.payload.reason - } - }; - } - default: - return { newState: state }; - } -} -function createPairedDevice(state) { - if (state.status !== "success" || !state.remoteDevice || !state.sharedSecret) { - return null; - } - return { - ...state.remoteDevice, - sharedSecret: state.sharedSecret, - pairedAt: Date.now() - }; -} -function isPaired(deviceId, pairedDevices) { - return pairedDevices.some((d) => d.id === deviceId); -} -function getPairedDevice(deviceId, pairedDevices) { - return pairedDevices.find((d) => d.id === deviceId); -} -function updateLastConnected(deviceId, pairedDevices) { - return pairedDevices.map( - (d) => d.id === deviceId ? { ...d, lastConnected: Date.now() } : d - ); -} -function removePairedDevice(deviceId, pairedDevices) { - return pairedDevices.filter((d) => d.id !== deviceId); -} - -// src/transfer/index.ts -var CHUNK_SIZE = 64 * 1024; -var MAX_TEXT_LENGTH = 1024 * 1024; -function createTextTransfer(content, device, direction) { - return { - id: generateMessageId(), - type: "text", - timestamp: Date.now(), - direction, - deviceId: device.id, - deviceName: device.name, - content - }; -} -function createFileTransfer(fileName, fileSize, mimeType, device, direction, durationMs) { - const transfer = { - id: generateMessageId(), - type: "file", - timestamp: Date.now(), - direction, - deviceId: device.id, - deviceName: device.name, - fileName, - fileSize, - mimeType - }; - if (durationMs != null && durationMs > 0) { - transfer.durationMs = durationMs; - transfer.speedBytesPerSec = Math.round(fileSize / durationMs * 1e3); - } - return transfer; -} -function createTextMessage(content) { - return { - type: "text", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - content - } - }; -} -function createEncryptedTextMessage(content, secretKey) { - const { encrypted, nonce } = encrypt(content, secretKey); - return { - message: createTextMessage(encrypted), - nonce - }; -} -function decryptTextMessage(message, nonce, secretKey) { - const decrypted = decrypt(message.payload.content, nonce, secretKey); - if (!decrypted) return null; - return new TextDecoder().decode(decrypted); -} -function createFileRequest(fileName, fileSize, mimeType, fileData) { - return { - type: "file_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum: calculateChecksum(fileData) - } - }; -} -function createFileRequestStreaming(fileName, fileSize, mimeType, checksum) { - return { - type: "file_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum - } - }; -} -function createFileCompleteStreaming(requestId, checksum) { - return { - type: "file_complete", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - checksum - } - }; -} -function createFileRequestHttp(fileName, fileSize, mimeType, checksum, httpUrl) { - return { - type: "file_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum, - httpUrl - } - }; -} -function createFileAccept(requestId) { - return { - type: "file_accept", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId - } - }; -} -function createFileAcceptHttp(requestId, uploadUrl) { - return { - type: "file_accept", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - uploadUrl - } - }; -} -function createFileAck(requestId, success) { - return { - type: "file_ack", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - success - } - }; -} -function createFileReject(requestId, reason) { - return { - type: "file_reject", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - reason - } - }; -} -function createFileChunk(requestId, chunkIndex, totalChunks, data) { - return { - type: "file_chunk", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - chunkIndex, - totalChunks, - data: (0, import_tweetnacl_util.encodeBase64)(data) - } - }; -} -function createFileChunkFromBase64(requestId, chunkIndex, totalChunks, base64Data) { - return { - type: "file_chunk", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - chunkIndex, - totalChunks, - data: base64Data - } - }; -} -function createFileComplete(requestId, fileData) { - return { - type: "file_complete", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - checksum: calculateChecksum(fileData) - } - }; -} -function* chunkFile(data, chunkSize = CHUNK_SIZE) { - const totalChunks = Math.ceil(data.length / chunkSize); - for (let i = 0; i < totalChunks; i++) { - const start = i * chunkSize; - const end = Math.min(start + chunkSize, data.length); - yield { - chunk: data.slice(start, end), - index: i, - total: totalChunks - }; - } -} -function reassembleChunks(chunks, totalChunks) { - if (chunks.size !== totalChunks) { - return null; - } - let totalSize = 0; - for (let i = 0; i < totalChunks; i++) { - const chunk = chunks.get(i); - if (!chunk) return null; - totalSize += chunk.length; - } - const result = new Uint8Array(totalSize); - let offset = 0; - for (let i = 0; i < totalChunks; i++) { - const chunk = chunks.get(i); - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} -function calculateProgress(transferId, bytesTransferred, totalBytes, currentFile, startTime) { - const clampedBytes = Math.min(bytesTransferred, totalBytes); - const result = { - transferId, - bytesTransferred: clampedBytes, - totalBytes, - percentage: totalBytes > 0 ? Math.min(100, Math.round(clampedBytes / totalBytes * 100)) : 0, - currentFile - }; - if (startTime && startTime > 0) { - const elapsedMs = Date.now() - startTime; - result.elapsedMs = elapsedMs; - if (elapsedMs > 500 && clampedBytes > 0) { - result.speedBytesPerSec = Math.round(clampedBytes / elapsedMs * 1e3); - if (result.speedBytesPerSec > 0 && clampedBytes < totalBytes) { - const remainingBytes = totalBytes - clampedBytes; - result.etaSeconds = Math.round(remainingBytes / result.speedBytesPerSec); - } - } - } - return result; -} -function verifyFileIntegrity(data, expectedChecksum) { - return verifyChecksum(data, expectedChecksum); -} -function formatFileSize(bytes) { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; -} -function formatTransferSpeed(bytesPerSec) { - if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; - if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; - if (bytesPerSec < 1024 * 1024 * 1024) return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; - return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)} GB/s`; -} -function formatDuration(ms) { - if (ms < 1e3) return `${ms}ms`; - const seconds = ms / 1e3; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds.toFixed(0)}s`; -} -function formatEta(seconds) { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} -function formatProgressInfo(progress) { - const parts = []; - if (progress.speedBytesPerSec != null && progress.speedBytesPerSec > 0) { - parts.push(formatTransferSpeed(progress.speedBytesPerSec)); - } - if (progress.elapsedMs != null && progress.elapsedMs >= 1e3) { - parts.push(formatDuration(progress.elapsedMs) + " elapsed"); - } - if (progress.etaSeconds != null && progress.etaSeconds > 0) { - parts.push("~" + formatEta(progress.etaSeconds) + " left"); - } - return parts.join(" \xB7 "); -} -function getMimeType(fileName) { - const ext = fileName.split(".").pop()?.toLowerCase() || ""; - const mimeTypes = { - txt: "text/plain", - html: "text/html", - css: "text/css", - js: "application/javascript", - json: "application/json", - xml: "application/xml", - pdf: "application/pdf", - zip: "application/zip", - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - svg: "image/svg+xml", - webp: "image/webp", - mp3: "audio/mpeg", - wav: "audio/wav", - mp4: "video/mp4", - webm: "video/webm", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" - }; - return mimeTypes[ext] || "application/octet-stream"; -} - -// src/protocol/index.ts -var PROTOCOL_VERSION = "1.0.0"; -var HEADER_LENGTH = 5; -var MAX_MESSAGE_SIZE = 10 * 1024 * 1024; -var MESSAGE_TYPE_CODES = { - ping: 1, - pong: 2, - pair_request: 16, - pair_challenge: 17, - pair_response: 18, - pair_confirm: 19, - pair_reject: 20, - hello: 21, - text: 32, - file_request: 48, - file_accept: 49, - file_reject: 50, - file_chunk: 51, - file_complete: 52, - file_ack: 53, - app: 64, - error: 255 -}; -function createHello(deviceInfo) { - return { - type: "hello", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { deviceInfo } - }; -} -function createAppMessage(channel, data) { - return { - type: "app", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { channel, data } - }; -} -var MESSAGE_CODE_TYPES = Object.fromEntries( - Object.entries(MESSAGE_TYPE_CODES).map(([k, v]) => [v, k]) -); -function serializeMessage(message) { - const jsonPayload = JSON.stringify(message); - const payloadBytes = new TextEncoder().encode(jsonPayload); - const typeCode = MESSAGE_TYPE_CODES[message.type] || 255; - const buffer = new Uint8Array(HEADER_LENGTH + payloadBytes.length); - const view = new DataView(buffer.buffer); - view.setUint32(0, payloadBytes.length, false); - buffer[4] = typeCode; - buffer.set(payloadBytes, HEADER_LENGTH); - return buffer; -} -function deserializeMessage(buffer) { - if (buffer.length < HEADER_LENGTH) { - return null; - } - const view = new DataView(buffer.buffer, buffer.byteOffset); - const payloadLength = view.getUint32(0, false); - if (buffer.length < HEADER_LENGTH + payloadLength) { - return null; - } - const payloadBytes = buffer.slice(HEADER_LENGTH, HEADER_LENGTH + payloadLength); - const jsonPayload = new TextDecoder().decode(payloadBytes); - try { - return JSON.parse(jsonPayload); - } catch { - return null; - } -} -function getMessageLength(header) { - if (header.length < 4) { - return null; - } - const view = new DataView(header.buffer, header.byteOffset); - const length = view.getUint32(0, false); - if (length > MAX_MESSAGE_SIZE) { - return null; - } - return HEADER_LENGTH + length; -} -function encryptMessage(message, secretKey) { - const serialized = serializeMessage(message); - const { encrypted, nonce } = encrypt(serialized, secretKey); - return { - encrypted: (0, import_tweetnacl_util.decodeBase64)(encrypted), - nonce - }; -} -function decryptMessage(encrypted, nonce, secretKey) { - const decrypted = decrypt((0, import_tweetnacl_util.encodeBase64)(encrypted), nonce, secretKey); - if (!decrypted) return null; - return deserializeMessage(decrypted); -} -function createEncryptedFrame(encrypted, nonce) { - const nonceBytes = (0, import_tweetnacl_util.decodeBase64)(nonce); - const frame2 = new Uint8Array(1 + nonceBytes.length + encrypted.length); - frame2[0] = nonceBytes.length; - frame2.set(nonceBytes, 1); - frame2.set(encrypted, 1 + nonceBytes.length); - return frame2; -} -function parseEncryptedFrame(frame2) { - if (frame2.length < 2) return null; - const nonceLength = frame2[0]; - if (frame2.length < 1 + nonceLength) return null; - const nonceBytes = frame2.slice(1, 1 + nonceLength); - const encrypted = frame2.slice(1 + nonceLength); - return { - encrypted, - nonce: (0, import_tweetnacl_util.encodeBase64)(nonceBytes) - }; -} -var MessageBuffer = class { - buffer = new Uint8Array(0); - /** - * Add data to the buffer - */ - append(data) { - const newBuffer = new Uint8Array(this.buffer.length + data.length); - newBuffer.set(this.buffer); - newBuffer.set(data, this.buffer.length); - this.buffer = newBuffer; - } - /** - * Try to extract a complete message from the buffer - */ - extractMessage() { - const length = getMessageLength(this.buffer); - if (length === null || this.buffer.length < length) { - return null; - } - const messageBytes = this.buffer.slice(0, length); - this.buffer = this.buffer.slice(length); - return deserializeMessage(messageBytes); - } - /** - * Extract all complete messages from the buffer - */ - extractAllMessages() { - const messages = []; - let message; - while ((message = this.extractMessage()) !== null) { - messages.push(message); - } - return messages; - } - /** - * Get current buffer size - */ - get size() { - return this.buffer.length; - } - /** - * Clear the buffer - */ - clear() { - this.buffer = new Uint8Array(0); - } -}; -function createPingMessage() { - return { - type: "ping", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now() - }; -} -function createPongMessage(pingId) { - return { - type: "pong", - id: pingId, - timestamp: Date.now() - }; -} -function createErrorMessage(code, errorMessage, originalMessageId) { - return { - type: "error", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { - code, - message: errorMessage, - originalMessageId - } - }; -} - -// src/wire.ts -var FRAME_HEADER_LENGTH = 4; -var MAX_FRAME_SIZE = 16 * 1024 * 1024; -var FRAME_KIND_PLAINTEXT = 0; -var FRAME_KIND_ENCRYPTED = 1; -function frame(body) { - const out = new Uint8Array(FRAME_HEADER_LENGTH + body.length); - new DataView(out.buffer).setUint32(0, body.length, false); - out.set(body, FRAME_HEADER_LENGTH); - return out; -} -function encodePlaintextFrame(message) { - const json = new TextEncoder().encode(JSON.stringify(message)); - const body = new Uint8Array(1 + json.length); - body[0] = FRAME_KIND_PLAINTEXT; - body.set(json, 1); - return frame(body); -} -function encodeEncryptedFrame(message, secretKey) { - const { encrypted, nonce } = encryptMessage(message, secretKey); - const nonceBytes = (0, import_tweetnacl_util.decodeBase64)(nonce); - const body = new Uint8Array(1 + 1 + nonceBytes.length + encrypted.length); - body[0] = FRAME_KIND_ENCRYPTED; - body[1] = nonceBytes.length; - body.set(nonceBytes, 2); - body.set(encrypted, 2 + nonceBytes.length); - return frame(body); -} -function decodeFrameBody(body, secretKey) { - if (body.length < 1) return null; - const kind = body[0]; - const payload = body.subarray(1); - if (kind === FRAME_KIND_PLAINTEXT) { - try { - const message = JSON.parse(new TextDecoder().decode(payload)); - return { kind: "plaintext", message }; - } catch { - return null; - } - } - if (kind === FRAME_KIND_ENCRYPTED) { - if (!secretKey || payload.length < 1) return null; - const nonceLen = payload[0]; - if (payload.length < 1 + nonceLen) return null; - const nonce = (0, import_tweetnacl_util.encodeBase64)(payload.subarray(1, 1 + nonceLen)); - const encrypted = payload.subarray(1 + nonceLen); - const message = decryptMessage(encrypted, nonce, secretKey); - return message ? { kind: "encrypted", message } : null; - } - return null; -} -var FrameBuffer = class { - buffer = new Uint8Array(0); - append(data) { - const next = new Uint8Array(this.buffer.length + data.length); - next.set(this.buffer); - next.set(data, this.buffer.length); - this.buffer = next; - } - /** Pull the next complete frame body, or null if none is fully buffered. */ - nextBody() { - if (this.buffer.length < FRAME_HEADER_LENGTH) return null; - const len = new DataView( - this.buffer.buffer, - this.buffer.byteOffset - ).getUint32(0, false); - if (len > MAX_FRAME_SIZE) { - this.buffer = new Uint8Array(0); - return null; - } - if (this.buffer.length < FRAME_HEADER_LENGTH + len) return null; - const body = this.buffer.slice(FRAME_HEADER_LENGTH, FRAME_HEADER_LENGTH + len); - this.buffer = this.buffer.slice(FRAME_HEADER_LENGTH + len); - return body; - } - /** Decode all complete frames currently buffered. */ - drain(secretKey) { - const out = []; - let body; - while ((body = this.nextBody()) !== null) { - const decoded = decodeFrameBody(body, secretKey); - if (decoded) out.push(decoded); - } - return out; - } -}; - -// src/cap.ts -var FREE_DEVICE_CAP = 2; -var freePolicy = { limit: () => FREE_DEVICE_CAP }; -function policyFor(isPro, proLimit = Infinity) { - return { limit: () => isPro ? proLimit : FREE_DEVICE_CAP }; -} -function pairingAllowed(cap, deviceId) { - if (!cap) return true; - if (cap.isKnown(deviceId)) return true; - return cap.pairedCount() < cap.policy.limit(); -} - -// src/engine.ts -var PAIRING_TYPES = /* @__PURE__ */ new Set([ - "pair_request", - "pair_challenge", - "pair_response", - "pair_confirm", - "pair_reject" -]); -var PeerSession = class { - constructor(conn, engine, opts, initiateWith, resumeWith) { - this.conn = conn; - this.engine = engine; - this.opts = opts; - this.pairing = createPairingState(opts.localDevice); - if (initiateWith) { - this.passphrase = initiateWith.passphrase; - this.remoteDevice = initiateWith.remote; - this.pairing = { ...this.pairing, remoteDevice: initiateWith.remote, passphrase: initiateWith.passphrase }; - } else if (resumeWith) { - this.remoteDevice = resumeWith.remote; - this.resumeSecret = resumeWith.sharedSecret; - } - conn.onData((data) => this.onData(data)); - conn.onClose(() => this.engine._removeSession(this)); - if (initiateWith) { - this.sendPlain(createPairRequest(opts.localDevice)); - } else if (resumeWith) { - this.sendPlain(createHello(opts.localDevice)); - this.helloSent = true; - } - } - conn; - engine; - opts; - buffer = new FrameBuffer(); - pairing; - sharedSecret; - passphrase; - resumeSecret; - helloSent = false; - queue = Promise.resolve(); - remoteDevice; - get pairedSecret() { - return this.sharedSecret; - } - /** Send an application message to this peer (must be paired). */ - sendMessage(message) { - if (!this.sharedSecret) return false; - this.conn.send(encodeEncryptedFrame(message, this.sharedSecret)); - return true; - } - sendPlain(message) { - this.conn.send(encodePlaintextFrame(message)); - } - onData(data) { - this.buffer.append(data); - const frames = this.buffer.drain(this.sharedSecret); - for (const f of frames) { - this.queue = this.queue.then(() => this.route(f.message)); - } - } - async route(message) { - if (message.type === "hello") { - this.handleHello(message); - return; - } - if (PAIRING_TYPES.has(message.type)) { - await this.handlePairing(message); - return; - } - if (this.sharedSecret && this.remoteDevice) { - if (message.type === "app") { - const p = message.payload; - this.opts.onAppMessage?.(this.remoteDevice.id, p.channel, p.data); - } else { - this.opts.onMessage?.(this.remoteDevice.id, message); - } - } - } - /** Resume an already-paired device using the stored secret (no handshake). */ - handleHello(message) { - const remote = message.payload.deviceInfo; - this.remoteDevice = remote; - const secret = this.resumeSecret ?? this.opts.getSharedSecret?.(remote.id); - if (!secret) { - this.opts.onPairingFailed?.(remote, "unknown_device"); - this.conn.close(); - return; - } - this.sharedSecret = secret; - if (!this.helloSent) { - this.sendPlain(createHello(this.opts.localDevice)); - this.helloSent = true; - } - const paired = { ...remote, sharedSecret: secret, pairedAt: Date.now() }; - this.engine._registerPaired(remote.id, this); - this.opts.onPaired?.(paired); - } - async handlePairing(message) { - if (message.type === "pair_request" && this.passphrase == null) { - const remote = message.payload.deviceInfo; - this.remoteDevice = remote; - if (!pairingAllowed(this.opts.cap, remote.id)) { - this.sendPlain(createPairReject("device limit reached")); - this.opts.onPairingFailed?.(remote, "device_cap_reached"); - this.conn.close(); - return; - } - const pass = await this.opts.getPassphrase?.(remote); - if (pass == null) { - this.opts.onPairingFailed?.(remote, "pairing refused"); - this.conn.close(); - return; - } - this.passphrase = pass; - } - const { newState, response } = handlePairingMessage(this.pairing, message, this.passphrase); - this.pairing = newState; - if (newState.sharedSecret) this.sharedSecret = newState.sharedSecret; - if (newState.remoteDevice) this.remoteDevice = newState.remoteDevice; - if (response) this.sendPlain(response); - if (newState.status === "success") { - const paired = createPairedDevice(newState); - if (paired) { - this.engine._registerPaired(paired.id, this); - this.opts.onPaired?.(paired); - } - } else if (newState.status === "failed") { - this.opts.onPairingFailed?.(this.remoteDevice, newState.error ?? "pairing failed"); - } - } -}; -var SyncEngine = class { - constructor(opts) { - this.opts = opts; - } - opts; - sessions = /* @__PURE__ */ new Set(); - paired = /* @__PURE__ */ new Map(); - /** Start accepting inbound connections on `port`. */ - async start(port) { - await this.opts.transport.listen(port, (conn) => { - this.sessions.add(new PeerSession(conn, this, this.opts)); - }); - } - /** Dial a discovered device and begin pairing with `passphrase`. Refuses if - * pairing a new device would exceed the device cap. */ - async pair(device, passphrase) { - if (!pairingAllowed(this.opts.cap, device.id)) { - this.opts.onPairingFailed?.(device, "device_cap_reached"); - return; - } - const conn = await this.opts.transport.connect(device.host, device.port); - const session = new PeerSession(conn, this, this.opts, { remote: device, passphrase }); - this.sessions.add(session); - } - /** Reconnect to an already-paired device using its stored shared secret, - * skipping the pairing handshake. Used for auto-reconnect on discovery. */ - async reconnect(device, sharedSecret) { - const conn = await this.opts.transport.connect(device.host, device.port); - const session = new PeerSession(conn, this, this.opts, void 0, { remote: device, sharedSecret }); - this.sessions.add(session); - } - /** Send an application message to an already-paired device. */ - send(deviceId, message) { - return this.paired.get(deviceId)?.sendMessage(message) ?? false; - } - /** Send a generic app-channel message (encrypted) to a paired device. */ - sendApp(deviceId, channel, data) { - return this.send(deviceId, createAppMessage(channel, data)); - } - isPaired(deviceId) { - return this.paired.has(deviceId); - } - async stop() { - for (const s of this.sessions) s.conn.close(); - this.sessions.clear(); - this.paired.clear(); - await this.opts.transport.stop(); - } - /** @internal */ - _registerPaired(deviceId, session) { - this.paired.set(deviceId, session); - } - /** @internal */ - _removeSession(session) { - this.sessions.delete(session); - for (const [id, s] of this.paired) { - if (s === session) this.paired.delete(id); - } - } -}; - -// src/orchestrator.ts -var DiscoveryOrchestrator = class { - constructor(opts) { - this.opts = opts; - } - opts; - connecting = /* @__PURE__ */ new Set(); - async start() { - this.opts.discovery.onDeviceFound((d) => this.handleFound(d)); - this.opts.discovery.onDeviceLost((id) => { - this.connecting.delete(id); - this.opts.onLost?.(id); - }); - await this.opts.discovery.start(); - await this.opts.discovery.advertise(this.opts.localDevice); - } - async stop() { - await this.opts.discovery.stop(); - } - handleFound(device) { - if (device.id === this.opts.localDevice.id) return; - if (this.opts.engine.isPaired(device.id)) return; - if (this.connecting.has(device.id)) return; - const secret = this.opts.getSharedSecret(device.id); - if (secret) { - this.connecting.add(device.id); - this.opts.engine.reconnect(device, secret).catch(() => void 0).finally(() => this.connecting.delete(device.id)); - } else { - this.opts.onDiscovered?.(device); - } - } -}; - -// src/oplog.ts -function wins(a, b) { - if (a.lamport !== b.lamport) return a.lamport > b.lamport; - if (a.deviceId !== b.deviceId) return a.deviceId > b.deviceId; - return a.opId > b.opId; -} -var OpLog = class { - constructor(opts) { - this.opts = opts; - for (const op of opts.persisted ?? []) { - this.ops.set(op.opId, op); - if (op.lamport > this.clock) this.clock = op.lamport; - } - } - opts; - ops = /* @__PURE__ */ new Map(); - // opId -> op - clock = 0; - /** Per-device highest lamport — what we tell a peer we already have. */ - versionVector() { - const vv = {}; - for (const op of this.ops.values()) { - if (!(op.deviceId in vv) || op.lamport > vv[op.deviceId]) vv[op.deviceId] = op.lamport; - } - return vv; - } - /** Ops the peer (described by their version vector) hasn't seen yet. */ - opsSince(peerVV) { - const out = []; - for (const op of this.ops.values()) { - if (op.lamport > (peerVV[op.deviceId] ?? 0)) out.push(op); - } - return out.sort((a, b) => a.lamport - b.lamport); - } - /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ - record(entity, entityId, kind, fields) { - const op = { - opId: this.opts.uuid(), - entity, - entityId, - kind, - fields: kind === "put" ? fields : void 0, - lamport: ++this.clock, - deviceId: this.opts.deviceId, - ts: this.opts.now() - }; - this.ops.set(op.opId, op); - this.opts.persist?.(op); - this.rematerialize(entity, entityId); - return op; - } - /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ - ingest(incoming) { - const accepted = []; - const touched = /* @__PURE__ */ new Set(); - for (const op of incoming) { - if (this.ops.has(op.opId)) continue; - this.ops.set(op.opId, op); - if (op.lamport > this.clock) this.clock = op.lamport; - this.opts.persist?.(op); - accepted.push(op); - touched.add(`${op.entity}\0${op.entityId}`); - } - for (const key of touched) { - const [entity, entityId] = key.split("\0"); - this.rematerialize(entity, entityId); - } - return accepted; - } - /** Recompute the winning op for one record and push it to the materializer. */ - rematerialize(entity, entityId) { - let winner; - for (const op of this.ops.values()) { - if (op.entity !== entity || op.entityId !== entityId) continue; - if (!winner || wins(op, winner)) winner = op; - } - if (!winner) return; - if (winner.kind === "delete") this.opts.materializer.remove(entity, entityId); - else this.opts.materializer.put(entity, entityId, winner.fields ?? {}); - } - /** Total ops held (diagnostics). */ - size() { - return this.ops.size; - } -}; - -// src/state-sync.ts -var StateSync = class { - constructor(opts) { - this.opts = opts; - } - opts; - /** A peer connected: advertise our version vector so it can backfill us; we - * backfill it when its own `have` arrives. */ - onConnect(deviceId) { - this.opts.send(deviceId, { t: "have", vv: this.opts.oplog.versionVector() }); - } - /** Inbound message on the 'state' channel from a paired peer. */ - onMessage(deviceId, data) { - const msg = data; - if (!msg || typeof msg !== "object" || !("t" in msg)) return; - if (msg.t === "have") { - const missing = this.opts.oplog.opsSince(msg.vv); - if (missing.length) this.opts.send(deviceId, { t: "ops", ops: missing }); - } else if (msg.t === "ops" && Array.isArray(msg.ops)) { - this.opts.oplog.ingest(msg.ops); - } - } -}; - -// src/index.ts -var VERSION = "0.0.1"; -var APP_NAME = "Off Grid Sync"; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - APP_NAME, - CHUNK_SIZE, - DiscoveryOrchestrator, - FRAME_HEADER_LENGTH, - FRAME_KIND_ENCRYPTED, - FRAME_KIND_PLAINTEXT, - FREE_DEVICE_CAP, - FrameBuffer, - HEADER_LENGTH, - IncrementalChecksum, - MAX_FRAME_SIZE, - MAX_MESSAGE_SIZE, - MAX_TEXT_LENGTH, - MDNS_DOMAIN, - MDNS_SERVICE_NAME, - MDNS_SERVICE_TYPE, - MESSAGE_CODE_TYPES, - MESSAGE_TYPE_CODES, - MessageBuffer, - OpLog, - PROTOCOL_VERSION, - StateSync, - SyncEngine, - TXT_DEVICE_ID, - TXT_DEVICE_NAME, - TXT_PLATFORM, - TXT_VERSION, - VERSION, - calculateChecksum, - calculateProgress, - chunkFile, - createAppMessage, - createChallengeResponse, - createDiscoveredDevice, - createEncryptedFrame, - createEncryptedTextMessage, - createErrorMessage, - createFileAccept, - createFileAcceptHttp, - createFileAck, - createFileChunk, - createFileChunkFromBase64, - createFileComplete, - createFileCompleteStreaming, - createFileReject, - createFileRequest, - createFileRequestHttp, - createFileRequestStreaming, - createFileTransfer, - createHello, - createPairChallenge, - createPairConfirm, - createPairReject, - createPairRequest, - createPairResponse, - createPairedDevice, - createPairingState, - createPingMessage, - createPongMessage, - createTextMessage, - createTextTransfer, - createTxtRecord, - decodeBase64, - decodeFrameBody, - decodeUTF8, - decrypt, - decryptMessage, - decryptTextMessage, - decryptToString, - deriveKey, - deriveSharedSecret, - deserializeMessage, - encodeBase64, - encodeEncryptedFrame, - encodePlaintextFrame, - encodeUTF8, - encrypt, - encryptMessage, - filterStaleDevices, - formatDuration, - formatEta, - formatFileSize, - formatProgressInfo, - formatTransferSpeed, - freePolicy, - generateChallenge, - generateDeviceId, - generateMessageId, - getMessageLength, - getMimeType, - getPairedDevice, - handlePairingMessage, - isDeviceStale, - isPaired, - pairingAllowed, - parseEncryptedFrame, - parseTxtRecord, - policyFor, - reassembleChunks, - removeDevice, - removePairedDevice, - serializeMessage, - updateDeviceList, - updateLastConnected, - verifyChallengeResponse, - verifyChecksum, - verifyFileIntegrity -}); diff --git a/packages/sync/dist/index.mjs b/packages/sync/dist/index.mjs deleted file mode 100644 index 1086c5ad..00000000 --- a/packages/sync/dist/index.mjs +++ /dev/null @@ -1,1381 +0,0 @@ -import { - MDNS_DOMAIN, - MDNS_SERVICE_NAME, - MDNS_SERVICE_TYPE, - TXT_DEVICE_ID, - TXT_DEVICE_NAME, - TXT_PLATFORM, - TXT_VERSION, - createDiscoveredDevice, - createTxtRecord, - filterStaleDevices, - isDeviceStale, - parseTxtRecord, - removeDevice, - updateDeviceList -} from "./chunk-UMHRNOI2.mjs"; - -// src/crypto/index.ts -import nacl from "tweetnacl"; -import { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } from "tweetnacl-util"; -import { sha512 } from "js-sha512"; -var PBKDF2_ITERATIONS = 1e4; -var SALT_LENGTH = 16; -var KEY_LENGTH = 32; -function generateDeviceId() { - const bytes = nacl.randomBytes(16); - return encodeBase64(bytes).replace( - /[+/=]/g, - (c) => c === "+" ? "-" : c === "/" ? "_" : "" - ); -} -function generateMessageId() { - const bytes = nacl.randomBytes(8); - return encodeBase64(bytes).replace( - /[+/=]/g, - (c) => c === "+" ? "-" : c === "/" ? "_" : "" - ); -} -function deriveKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) { - const passphraseBytes = decodeUTF8(passphrase); - const combined = new Uint8Array(passphraseBytes.length + salt.length); - combined.set(passphraseBytes); - combined.set(salt, passphraseBytes.length); - let result = nacl.hash(combined); - for (let i = 1; i < iterations; i++) { - result = nacl.hash(result); - } - return result.slice(0, KEY_LENGTH); -} -function deriveSharedSecret(passphrase, deviceId1, deviceId2) { - const sortedIds = [deviceId1, deviceId2].sort(); - const saltString = `${sortedIds[0]}:${sortedIds[1]}`; - const salt = nacl.hash(decodeUTF8(saltString)).slice(0, SALT_LENGTH); - const key = deriveKey(passphrase, salt); - return encodeBase64(key); -} -function generateChallenge() { - const bytes = nacl.randomBytes(32); - return encodeBase64(bytes); -} -function createChallengeResponse(challenge, sharedSecret) { - const challengeBytes = decodeBase64(challenge); - const secretBytes = decodeBase64(sharedSecret); - const combined = new Uint8Array(challengeBytes.length + secretBytes.length); - combined.set(challengeBytes); - combined.set(secretBytes, challengeBytes.length); - const hash = nacl.hash(combined); - return encodeBase64(hash.slice(0, 32)); -} -function verifyChallengeResponse(challenge, response, sharedSecret) { - const expectedResponse = createChallengeResponse(challenge, sharedSecret); - return response === expectedResponse; -} -function encrypt(data, secretKey) { - const keyBytes = decodeBase64(secretKey); - const dataBytes = typeof data === "string" ? decodeUTF8(data) : data; - const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); - const encrypted = nacl.secretbox(dataBytes, nonce, keyBytes); - return { - encrypted: encodeBase64(encrypted), - nonce: encodeBase64(nonce) - }; -} -function decrypt(encrypted, nonce, secretKey) { - const keyBytes = decodeBase64(secretKey); - const encryptedBytes = decodeBase64(encrypted); - const nonceBytes = decodeBase64(nonce); - const decrypted = nacl.secretbox.open(encryptedBytes, nonceBytes, keyBytes); - return decrypted; -} -function decryptToString(encrypted, nonce, secretKey) { - const decrypted = decrypt(encrypted, nonce, secretKey); - if (!decrypted) return null; - return encodeUTF8(decrypted); -} -function calculateChecksum(data) { - const hash = nacl.hash(data); - return encodeBase64(hash.slice(0, 16)); -} -function verifyChecksum(data, checksum) { - const calculated = calculateChecksum(data); - return calculated === checksum; -} -var IncrementalChecksum = class { - hasher; - constructor() { - this.hasher = sha512.create(); - } - /** - * Feed a chunk of data into the hash - */ - update(data) { - this.hasher.update(data); - } - /** - * Finalize and return checksum in the same format as calculateChecksum() - * (base64 of first 16 bytes of SHA-512 digest) - */ - digest() { - const hashArray = this.hasher.array(); - const first16 = new Uint8Array(hashArray.slice(0, 16)); - return encodeBase64(first16); - } -}; - -// src/pairing/index.ts -function createPairingState(localDevice) { - return { - status: "idle", - localDevice - }; -} -function createPairRequest(localDevice) { - return { - type: "pair_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - deviceInfo: localDevice - } - }; -} -function createPairChallenge() { - const challenge = generateChallenge(); - return { - type: "pair_challenge", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - challenge, - timestamp: Date.now() - } - }; -} -function createPairResponse(challenge, sharedSecret, localDevice) { - const response = createChallengeResponse(challenge, sharedSecret); - return { - type: "pair_response", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - response, - deviceInfo: localDevice - } - }; -} -function createPairConfirm(localDevice) { - return { - type: "pair_confirm", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - deviceInfo: localDevice - } - }; -} -function createPairReject(reason) { - return { - type: "pair_reject", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - reason - } - }; -} -function handlePairingMessage(state, message, passphrase) { - switch (message.type) { - case "pair_request": { - const msg = message; - const remoteDevice = msg.payload.deviceInfo; - if (!passphrase) { - return { - newState: { - ...state, - status: "waiting", - remoteDevice - } - }; - } - const sharedSecret = deriveSharedSecret( - passphrase, - state.localDevice.id, - remoteDevice.id - ); - const challengeMsg = createPairChallenge(); - return { - newState: { - ...state, - status: "verifying", - remoteDevice, - passphrase, - sharedSecret, - challenge: challengeMsg.payload.challenge - }, - response: challengeMsg - }; - } - case "pair_challenge": { - const msg = message; - if (!passphrase || !state.remoteDevice) { - return { - newState: { - ...state, - status: "failed", - error: "Missing passphrase or remote device" - } - }; - } - const sharedSecret = deriveSharedSecret( - passphrase, - state.localDevice.id, - state.remoteDevice.id - ); - const responseMsg = createPairResponse( - msg.payload.challenge, - sharedSecret, - state.localDevice - ); - return { - newState: { - ...state, - status: "verifying", - sharedSecret - }, - response: responseMsg - }; - } - case "pair_response": { - const msg = message; - if (!state.sharedSecret || !state.challenge) { - return { - newState: { - ...state, - status: "failed", - error: "Invalid pairing state" - } - }; - } - const isValid = verifyChallengeResponse( - state.challenge, - msg.payload.response, - state.sharedSecret - ); - if (isValid) { - const confirmMsg = createPairConfirm(state.localDevice); - return { - newState: { - ...state, - status: "success", - remoteDevice: msg.payload.deviceInfo - }, - response: confirmMsg - }; - } else { - const rejectMsg = createPairReject("Passphrase mismatch"); - return { - newState: { - ...state, - status: "failed", - error: "Passphrase mismatch" - }, - response: rejectMsg - }; - } - } - case "pair_confirm": { - return { - newState: { - ...state, - status: "success" - } - }; - } - case "pair_reject": { - const msg = message; - return { - newState: { - ...state, - status: "failed", - error: msg.payload.reason - } - }; - } - default: - return { newState: state }; - } -} -function createPairedDevice(state) { - if (state.status !== "success" || !state.remoteDevice || !state.sharedSecret) { - return null; - } - return { - ...state.remoteDevice, - sharedSecret: state.sharedSecret, - pairedAt: Date.now() - }; -} -function isPaired(deviceId, pairedDevices) { - return pairedDevices.some((d) => d.id === deviceId); -} -function getPairedDevice(deviceId, pairedDevices) { - return pairedDevices.find((d) => d.id === deviceId); -} -function updateLastConnected(deviceId, pairedDevices) { - return pairedDevices.map( - (d) => d.id === deviceId ? { ...d, lastConnected: Date.now() } : d - ); -} -function removePairedDevice(deviceId, pairedDevices) { - return pairedDevices.filter((d) => d.id !== deviceId); -} - -// src/transfer/index.ts -var CHUNK_SIZE = 64 * 1024; -var MAX_TEXT_LENGTH = 1024 * 1024; -function createTextTransfer(content, device, direction) { - return { - id: generateMessageId(), - type: "text", - timestamp: Date.now(), - direction, - deviceId: device.id, - deviceName: device.name, - content - }; -} -function createFileTransfer(fileName, fileSize, mimeType, device, direction, durationMs) { - const transfer = { - id: generateMessageId(), - type: "file", - timestamp: Date.now(), - direction, - deviceId: device.id, - deviceName: device.name, - fileName, - fileSize, - mimeType - }; - if (durationMs != null && durationMs > 0) { - transfer.durationMs = durationMs; - transfer.speedBytesPerSec = Math.round(fileSize / durationMs * 1e3); - } - return transfer; -} -function createTextMessage(content) { - return { - type: "text", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - content - } - }; -} -function createEncryptedTextMessage(content, secretKey) { - const { encrypted, nonce } = encrypt(content, secretKey); - return { - message: createTextMessage(encrypted), - nonce - }; -} -function decryptTextMessage(message, nonce, secretKey) { - const decrypted = decrypt(message.payload.content, nonce, secretKey); - if (!decrypted) return null; - return new TextDecoder().decode(decrypted); -} -function createFileRequest(fileName, fileSize, mimeType, fileData) { - return { - type: "file_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum: calculateChecksum(fileData) - } - }; -} -function createFileRequestStreaming(fileName, fileSize, mimeType, checksum) { - return { - type: "file_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum - } - }; -} -function createFileCompleteStreaming(requestId, checksum) { - return { - type: "file_complete", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - checksum - } - }; -} -function createFileRequestHttp(fileName, fileSize, mimeType, checksum, httpUrl) { - return { - type: "file_request", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum, - httpUrl - } - }; -} -function createFileAccept(requestId) { - return { - type: "file_accept", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId - } - }; -} -function createFileAcceptHttp(requestId, uploadUrl) { - return { - type: "file_accept", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - uploadUrl - } - }; -} -function createFileAck(requestId, success) { - return { - type: "file_ack", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - success - } - }; -} -function createFileReject(requestId, reason) { - return { - type: "file_reject", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - reason - } - }; -} -function createFileChunk(requestId, chunkIndex, totalChunks, data) { - return { - type: "file_chunk", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - chunkIndex, - totalChunks, - data: encodeBase64(data) - } - }; -} -function createFileChunkFromBase64(requestId, chunkIndex, totalChunks, base64Data) { - return { - type: "file_chunk", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - chunkIndex, - totalChunks, - data: base64Data - } - }; -} -function createFileComplete(requestId, fileData) { - return { - type: "file_complete", - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - checksum: calculateChecksum(fileData) - } - }; -} -function* chunkFile(data, chunkSize = CHUNK_SIZE) { - const totalChunks = Math.ceil(data.length / chunkSize); - for (let i = 0; i < totalChunks; i++) { - const start = i * chunkSize; - const end = Math.min(start + chunkSize, data.length); - yield { - chunk: data.slice(start, end), - index: i, - total: totalChunks - }; - } -} -function reassembleChunks(chunks, totalChunks) { - if (chunks.size !== totalChunks) { - return null; - } - let totalSize = 0; - for (let i = 0; i < totalChunks; i++) { - const chunk = chunks.get(i); - if (!chunk) return null; - totalSize += chunk.length; - } - const result = new Uint8Array(totalSize); - let offset = 0; - for (let i = 0; i < totalChunks; i++) { - const chunk = chunks.get(i); - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} -function calculateProgress(transferId, bytesTransferred, totalBytes, currentFile, startTime) { - const clampedBytes = Math.min(bytesTransferred, totalBytes); - const result = { - transferId, - bytesTransferred: clampedBytes, - totalBytes, - percentage: totalBytes > 0 ? Math.min(100, Math.round(clampedBytes / totalBytes * 100)) : 0, - currentFile - }; - if (startTime && startTime > 0) { - const elapsedMs = Date.now() - startTime; - result.elapsedMs = elapsedMs; - if (elapsedMs > 500 && clampedBytes > 0) { - result.speedBytesPerSec = Math.round(clampedBytes / elapsedMs * 1e3); - if (result.speedBytesPerSec > 0 && clampedBytes < totalBytes) { - const remainingBytes = totalBytes - clampedBytes; - result.etaSeconds = Math.round(remainingBytes / result.speedBytesPerSec); - } - } - } - return result; -} -function verifyFileIntegrity(data, expectedChecksum) { - return verifyChecksum(data, expectedChecksum); -} -function formatFileSize(bytes) { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; -} -function formatTransferSpeed(bytesPerSec) { - if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; - if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; - if (bytesPerSec < 1024 * 1024 * 1024) return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; - return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)} GB/s`; -} -function formatDuration(ms) { - if (ms < 1e3) return `${ms}ms`; - const seconds = ms / 1e3; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds.toFixed(0)}s`; -} -function formatEta(seconds) { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} -function formatProgressInfo(progress) { - const parts = []; - if (progress.speedBytesPerSec != null && progress.speedBytesPerSec > 0) { - parts.push(formatTransferSpeed(progress.speedBytesPerSec)); - } - if (progress.elapsedMs != null && progress.elapsedMs >= 1e3) { - parts.push(formatDuration(progress.elapsedMs) + " elapsed"); - } - if (progress.etaSeconds != null && progress.etaSeconds > 0) { - parts.push("~" + formatEta(progress.etaSeconds) + " left"); - } - return parts.join(" \xB7 "); -} -function getMimeType(fileName) { - const ext = fileName.split(".").pop()?.toLowerCase() || ""; - const mimeTypes = { - txt: "text/plain", - html: "text/html", - css: "text/css", - js: "application/javascript", - json: "application/json", - xml: "application/xml", - pdf: "application/pdf", - zip: "application/zip", - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - svg: "image/svg+xml", - webp: "image/webp", - mp3: "audio/mpeg", - wav: "audio/wav", - mp4: "video/mp4", - webm: "video/webm", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" - }; - return mimeTypes[ext] || "application/octet-stream"; -} - -// src/protocol/index.ts -var PROTOCOL_VERSION = "1.0.0"; -var HEADER_LENGTH = 5; -var MAX_MESSAGE_SIZE = 10 * 1024 * 1024; -var MESSAGE_TYPE_CODES = { - ping: 1, - pong: 2, - pair_request: 16, - pair_challenge: 17, - pair_response: 18, - pair_confirm: 19, - pair_reject: 20, - hello: 21, - text: 32, - file_request: 48, - file_accept: 49, - file_reject: 50, - file_chunk: 51, - file_complete: 52, - file_ack: 53, - app: 64, - error: 255 -}; -function createHello(deviceInfo) { - return { - type: "hello", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { deviceInfo } - }; -} -function createAppMessage(channel, data) { - return { - type: "app", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { channel, data } - }; -} -var MESSAGE_CODE_TYPES = Object.fromEntries( - Object.entries(MESSAGE_TYPE_CODES).map(([k, v]) => [v, k]) -); -function serializeMessage(message) { - const jsonPayload = JSON.stringify(message); - const payloadBytes = new TextEncoder().encode(jsonPayload); - const typeCode = MESSAGE_TYPE_CODES[message.type] || 255; - const buffer = new Uint8Array(HEADER_LENGTH + payloadBytes.length); - const view = new DataView(buffer.buffer); - view.setUint32(0, payloadBytes.length, false); - buffer[4] = typeCode; - buffer.set(payloadBytes, HEADER_LENGTH); - return buffer; -} -function deserializeMessage(buffer) { - if (buffer.length < HEADER_LENGTH) { - return null; - } - const view = new DataView(buffer.buffer, buffer.byteOffset); - const payloadLength = view.getUint32(0, false); - if (buffer.length < HEADER_LENGTH + payloadLength) { - return null; - } - const payloadBytes = buffer.slice(HEADER_LENGTH, HEADER_LENGTH + payloadLength); - const jsonPayload = new TextDecoder().decode(payloadBytes); - try { - return JSON.parse(jsonPayload); - } catch { - return null; - } -} -function getMessageLength(header) { - if (header.length < 4) { - return null; - } - const view = new DataView(header.buffer, header.byteOffset); - const length = view.getUint32(0, false); - if (length > MAX_MESSAGE_SIZE) { - return null; - } - return HEADER_LENGTH + length; -} -function encryptMessage(message, secretKey) { - const serialized = serializeMessage(message); - const { encrypted, nonce } = encrypt(serialized, secretKey); - return { - encrypted: decodeBase64(encrypted), - nonce - }; -} -function decryptMessage(encrypted, nonce, secretKey) { - const decrypted = decrypt(encodeBase64(encrypted), nonce, secretKey); - if (!decrypted) return null; - return deserializeMessage(decrypted); -} -function createEncryptedFrame(encrypted, nonce) { - const nonceBytes = decodeBase64(nonce); - const frame2 = new Uint8Array(1 + nonceBytes.length + encrypted.length); - frame2[0] = nonceBytes.length; - frame2.set(nonceBytes, 1); - frame2.set(encrypted, 1 + nonceBytes.length); - return frame2; -} -function parseEncryptedFrame(frame2) { - if (frame2.length < 2) return null; - const nonceLength = frame2[0]; - if (frame2.length < 1 + nonceLength) return null; - const nonceBytes = frame2.slice(1, 1 + nonceLength); - const encrypted = frame2.slice(1 + nonceLength); - return { - encrypted, - nonce: encodeBase64(nonceBytes) - }; -} -var MessageBuffer = class { - buffer = new Uint8Array(0); - /** - * Add data to the buffer - */ - append(data) { - const newBuffer = new Uint8Array(this.buffer.length + data.length); - newBuffer.set(this.buffer); - newBuffer.set(data, this.buffer.length); - this.buffer = newBuffer; - } - /** - * Try to extract a complete message from the buffer - */ - extractMessage() { - const length = getMessageLength(this.buffer); - if (length === null || this.buffer.length < length) { - return null; - } - const messageBytes = this.buffer.slice(0, length); - this.buffer = this.buffer.slice(length); - return deserializeMessage(messageBytes); - } - /** - * Extract all complete messages from the buffer - */ - extractAllMessages() { - const messages = []; - let message; - while ((message = this.extractMessage()) !== null) { - messages.push(message); - } - return messages; - } - /** - * Get current buffer size - */ - get size() { - return this.buffer.length; - } - /** - * Clear the buffer - */ - clear() { - this.buffer = new Uint8Array(0); - } -}; -function createPingMessage() { - return { - type: "ping", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now() - }; -} -function createPongMessage(pingId) { - return { - type: "pong", - id: pingId, - timestamp: Date.now() - }; -} -function createErrorMessage(code, errorMessage, originalMessageId) { - return { - type: "error", - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { - code, - message: errorMessage, - originalMessageId - } - }; -} - -// src/wire.ts -var FRAME_HEADER_LENGTH = 4; -var MAX_FRAME_SIZE = 16 * 1024 * 1024; -var FRAME_KIND_PLAINTEXT = 0; -var FRAME_KIND_ENCRYPTED = 1; -function frame(body) { - const out = new Uint8Array(FRAME_HEADER_LENGTH + body.length); - new DataView(out.buffer).setUint32(0, body.length, false); - out.set(body, FRAME_HEADER_LENGTH); - return out; -} -function encodePlaintextFrame(message) { - const json = new TextEncoder().encode(JSON.stringify(message)); - const body = new Uint8Array(1 + json.length); - body[0] = FRAME_KIND_PLAINTEXT; - body.set(json, 1); - return frame(body); -} -function encodeEncryptedFrame(message, secretKey) { - const { encrypted, nonce } = encryptMessage(message, secretKey); - const nonceBytes = decodeBase64(nonce); - const body = new Uint8Array(1 + 1 + nonceBytes.length + encrypted.length); - body[0] = FRAME_KIND_ENCRYPTED; - body[1] = nonceBytes.length; - body.set(nonceBytes, 2); - body.set(encrypted, 2 + nonceBytes.length); - return frame(body); -} -function decodeFrameBody(body, secretKey) { - if (body.length < 1) return null; - const kind = body[0]; - const payload = body.subarray(1); - if (kind === FRAME_KIND_PLAINTEXT) { - try { - const message = JSON.parse(new TextDecoder().decode(payload)); - return { kind: "plaintext", message }; - } catch { - return null; - } - } - if (kind === FRAME_KIND_ENCRYPTED) { - if (!secretKey || payload.length < 1) return null; - const nonceLen = payload[0]; - if (payload.length < 1 + nonceLen) return null; - const nonce = encodeBase64(payload.subarray(1, 1 + nonceLen)); - const encrypted = payload.subarray(1 + nonceLen); - const message = decryptMessage(encrypted, nonce, secretKey); - return message ? { kind: "encrypted", message } : null; - } - return null; -} -var FrameBuffer = class { - buffer = new Uint8Array(0); - append(data) { - const next = new Uint8Array(this.buffer.length + data.length); - next.set(this.buffer); - next.set(data, this.buffer.length); - this.buffer = next; - } - /** Pull the next complete frame body, or null if none is fully buffered. */ - nextBody() { - if (this.buffer.length < FRAME_HEADER_LENGTH) return null; - const len = new DataView( - this.buffer.buffer, - this.buffer.byteOffset - ).getUint32(0, false); - if (len > MAX_FRAME_SIZE) { - this.buffer = new Uint8Array(0); - return null; - } - if (this.buffer.length < FRAME_HEADER_LENGTH + len) return null; - const body = this.buffer.slice(FRAME_HEADER_LENGTH, FRAME_HEADER_LENGTH + len); - this.buffer = this.buffer.slice(FRAME_HEADER_LENGTH + len); - return body; - } - /** Decode all complete frames currently buffered. */ - drain(secretKey) { - const out = []; - let body; - while ((body = this.nextBody()) !== null) { - const decoded = decodeFrameBody(body, secretKey); - if (decoded) out.push(decoded); - } - return out; - } -}; - -// src/cap.ts -var FREE_DEVICE_CAP = 2; -var freePolicy = { limit: () => FREE_DEVICE_CAP }; -function policyFor(isPro, proLimit = Infinity) { - return { limit: () => isPro ? proLimit : FREE_DEVICE_CAP }; -} -function pairingAllowed(cap, deviceId) { - if (!cap) return true; - if (cap.isKnown(deviceId)) return true; - return cap.pairedCount() < cap.policy.limit(); -} - -// src/engine.ts -var PAIRING_TYPES = /* @__PURE__ */ new Set([ - "pair_request", - "pair_challenge", - "pair_response", - "pair_confirm", - "pair_reject" -]); -var PeerSession = class { - constructor(conn, engine, opts, initiateWith, resumeWith) { - this.conn = conn; - this.engine = engine; - this.opts = opts; - this.pairing = createPairingState(opts.localDevice); - if (initiateWith) { - this.passphrase = initiateWith.passphrase; - this.remoteDevice = initiateWith.remote; - this.pairing = { ...this.pairing, remoteDevice: initiateWith.remote, passphrase: initiateWith.passphrase }; - } else if (resumeWith) { - this.remoteDevice = resumeWith.remote; - this.resumeSecret = resumeWith.sharedSecret; - } - conn.onData((data) => this.onData(data)); - conn.onClose(() => this.engine._removeSession(this)); - if (initiateWith) { - this.sendPlain(createPairRequest(opts.localDevice)); - } else if (resumeWith) { - this.sendPlain(createHello(opts.localDevice)); - this.helloSent = true; - } - } - conn; - engine; - opts; - buffer = new FrameBuffer(); - pairing; - sharedSecret; - passphrase; - resumeSecret; - helloSent = false; - queue = Promise.resolve(); - remoteDevice; - get pairedSecret() { - return this.sharedSecret; - } - /** Send an application message to this peer (must be paired). */ - sendMessage(message) { - if (!this.sharedSecret) return false; - this.conn.send(encodeEncryptedFrame(message, this.sharedSecret)); - return true; - } - sendPlain(message) { - this.conn.send(encodePlaintextFrame(message)); - } - onData(data) { - this.buffer.append(data); - const frames = this.buffer.drain(this.sharedSecret); - for (const f of frames) { - this.queue = this.queue.then(() => this.route(f.message)); - } - } - async route(message) { - if (message.type === "hello") { - this.handleHello(message); - return; - } - if (PAIRING_TYPES.has(message.type)) { - await this.handlePairing(message); - return; - } - if (this.sharedSecret && this.remoteDevice) { - if (message.type === "app") { - const p = message.payload; - this.opts.onAppMessage?.(this.remoteDevice.id, p.channel, p.data); - } else { - this.opts.onMessage?.(this.remoteDevice.id, message); - } - } - } - /** Resume an already-paired device using the stored secret (no handshake). */ - handleHello(message) { - const remote = message.payload.deviceInfo; - this.remoteDevice = remote; - const secret = this.resumeSecret ?? this.opts.getSharedSecret?.(remote.id); - if (!secret) { - this.opts.onPairingFailed?.(remote, "unknown_device"); - this.conn.close(); - return; - } - this.sharedSecret = secret; - if (!this.helloSent) { - this.sendPlain(createHello(this.opts.localDevice)); - this.helloSent = true; - } - const paired = { ...remote, sharedSecret: secret, pairedAt: Date.now() }; - this.engine._registerPaired(remote.id, this); - this.opts.onPaired?.(paired); - } - async handlePairing(message) { - if (message.type === "pair_request" && this.passphrase == null) { - const remote = message.payload.deviceInfo; - this.remoteDevice = remote; - if (!pairingAllowed(this.opts.cap, remote.id)) { - this.sendPlain(createPairReject("device limit reached")); - this.opts.onPairingFailed?.(remote, "device_cap_reached"); - this.conn.close(); - return; - } - const pass = await this.opts.getPassphrase?.(remote); - if (pass == null) { - this.opts.onPairingFailed?.(remote, "pairing refused"); - this.conn.close(); - return; - } - this.passphrase = pass; - } - const { newState, response } = handlePairingMessage(this.pairing, message, this.passphrase); - this.pairing = newState; - if (newState.sharedSecret) this.sharedSecret = newState.sharedSecret; - if (newState.remoteDevice) this.remoteDevice = newState.remoteDevice; - if (response) this.sendPlain(response); - if (newState.status === "success") { - const paired = createPairedDevice(newState); - if (paired) { - this.engine._registerPaired(paired.id, this); - this.opts.onPaired?.(paired); - } - } else if (newState.status === "failed") { - this.opts.onPairingFailed?.(this.remoteDevice, newState.error ?? "pairing failed"); - } - } -}; -var SyncEngine = class { - constructor(opts) { - this.opts = opts; - } - opts; - sessions = /* @__PURE__ */ new Set(); - paired = /* @__PURE__ */ new Map(); - /** Start accepting inbound connections on `port`. */ - async start(port) { - await this.opts.transport.listen(port, (conn) => { - this.sessions.add(new PeerSession(conn, this, this.opts)); - }); - } - /** Dial a discovered device and begin pairing with `passphrase`. Refuses if - * pairing a new device would exceed the device cap. */ - async pair(device, passphrase) { - if (!pairingAllowed(this.opts.cap, device.id)) { - this.opts.onPairingFailed?.(device, "device_cap_reached"); - return; - } - const conn = await this.opts.transport.connect(device.host, device.port); - const session = new PeerSession(conn, this, this.opts, { remote: device, passphrase }); - this.sessions.add(session); - } - /** Reconnect to an already-paired device using its stored shared secret, - * skipping the pairing handshake. Used for auto-reconnect on discovery. */ - async reconnect(device, sharedSecret) { - const conn = await this.opts.transport.connect(device.host, device.port); - const session = new PeerSession(conn, this, this.opts, void 0, { remote: device, sharedSecret }); - this.sessions.add(session); - } - /** Send an application message to an already-paired device. */ - send(deviceId, message) { - return this.paired.get(deviceId)?.sendMessage(message) ?? false; - } - /** Send a generic app-channel message (encrypted) to a paired device. */ - sendApp(deviceId, channel, data) { - return this.send(deviceId, createAppMessage(channel, data)); - } - isPaired(deviceId) { - return this.paired.has(deviceId); - } - async stop() { - for (const s of this.sessions) s.conn.close(); - this.sessions.clear(); - this.paired.clear(); - await this.opts.transport.stop(); - } - /** @internal */ - _registerPaired(deviceId, session) { - this.paired.set(deviceId, session); - } - /** @internal */ - _removeSession(session) { - this.sessions.delete(session); - for (const [id, s] of this.paired) { - if (s === session) this.paired.delete(id); - } - } -}; - -// src/orchestrator.ts -var DiscoveryOrchestrator = class { - constructor(opts) { - this.opts = opts; - } - opts; - connecting = /* @__PURE__ */ new Set(); - async start() { - this.opts.discovery.onDeviceFound((d) => this.handleFound(d)); - this.opts.discovery.onDeviceLost((id) => { - this.connecting.delete(id); - this.opts.onLost?.(id); - }); - await this.opts.discovery.start(); - await this.opts.discovery.advertise(this.opts.localDevice); - } - async stop() { - await this.opts.discovery.stop(); - } - handleFound(device) { - if (device.id === this.opts.localDevice.id) return; - if (this.opts.engine.isPaired(device.id)) return; - if (this.connecting.has(device.id)) return; - const secret = this.opts.getSharedSecret(device.id); - if (secret) { - this.connecting.add(device.id); - this.opts.engine.reconnect(device, secret).catch(() => void 0).finally(() => this.connecting.delete(device.id)); - } else { - this.opts.onDiscovered?.(device); - } - } -}; - -// src/oplog.ts -function wins(a, b) { - if (a.lamport !== b.lamport) return a.lamport > b.lamport; - if (a.deviceId !== b.deviceId) return a.deviceId > b.deviceId; - return a.opId > b.opId; -} -var OpLog = class { - constructor(opts) { - this.opts = opts; - for (const op of opts.persisted ?? []) { - this.ops.set(op.opId, op); - if (op.lamport > this.clock) this.clock = op.lamport; - } - } - opts; - ops = /* @__PURE__ */ new Map(); - // opId -> op - clock = 0; - /** Per-device highest lamport — what we tell a peer we already have. */ - versionVector() { - const vv = {}; - for (const op of this.ops.values()) { - if (!(op.deviceId in vv) || op.lamport > vv[op.deviceId]) vv[op.deviceId] = op.lamport; - } - return vv; - } - /** Ops the peer (described by their version vector) hasn't seen yet. */ - opsSince(peerVV) { - const out = []; - for (const op of this.ops.values()) { - if (op.lamport > (peerVV[op.deviceId] ?? 0)) out.push(op); - } - return out.sort((a, b) => a.lamport - b.lamport); - } - /** Record a LOCAL change. Returns the new op (caller broadcasts it to peers). */ - record(entity, entityId, kind, fields) { - const op = { - opId: this.opts.uuid(), - entity, - entityId, - kind, - fields: kind === "put" ? fields : void 0, - lamport: ++this.clock, - deviceId: this.opts.deviceId, - ts: this.opts.now() - }; - this.ops.set(op.opId, op); - this.opts.persist?.(op); - this.rematerialize(entity, entityId); - return op; - } - /** Merge REMOTE ops. Returns those newly accepted (unseen), for chaining. */ - ingest(incoming) { - const accepted = []; - const touched = /* @__PURE__ */ new Set(); - for (const op of incoming) { - if (this.ops.has(op.opId)) continue; - this.ops.set(op.opId, op); - if (op.lamport > this.clock) this.clock = op.lamport; - this.opts.persist?.(op); - accepted.push(op); - touched.add(`${op.entity}\0${op.entityId}`); - } - for (const key of touched) { - const [entity, entityId] = key.split("\0"); - this.rematerialize(entity, entityId); - } - return accepted; - } - /** Recompute the winning op for one record and push it to the materializer. */ - rematerialize(entity, entityId) { - let winner; - for (const op of this.ops.values()) { - if (op.entity !== entity || op.entityId !== entityId) continue; - if (!winner || wins(op, winner)) winner = op; - } - if (!winner) return; - if (winner.kind === "delete") this.opts.materializer.remove(entity, entityId); - else this.opts.materializer.put(entity, entityId, winner.fields ?? {}); - } - /** Total ops held (diagnostics). */ - size() { - return this.ops.size; - } -}; - -// src/state-sync.ts -var StateSync = class { - constructor(opts) { - this.opts = opts; - } - opts; - /** A peer connected: advertise our version vector so it can backfill us; we - * backfill it when its own `have` arrives. */ - onConnect(deviceId) { - this.opts.send(deviceId, { t: "have", vv: this.opts.oplog.versionVector() }); - } - /** Inbound message on the 'state' channel from a paired peer. */ - onMessage(deviceId, data) { - const msg = data; - if (!msg || typeof msg !== "object" || !("t" in msg)) return; - if (msg.t === "have") { - const missing = this.opts.oplog.opsSince(msg.vv); - if (missing.length) this.opts.send(deviceId, { t: "ops", ops: missing }); - } else if (msg.t === "ops" && Array.isArray(msg.ops)) { - this.opts.oplog.ingest(msg.ops); - } - } -}; - -// src/index.ts -var VERSION = "0.0.1"; -var APP_NAME = "Off Grid Sync"; -export { - APP_NAME, - CHUNK_SIZE, - DiscoveryOrchestrator, - FRAME_HEADER_LENGTH, - FRAME_KIND_ENCRYPTED, - FRAME_KIND_PLAINTEXT, - FREE_DEVICE_CAP, - FrameBuffer, - HEADER_LENGTH, - IncrementalChecksum, - MAX_FRAME_SIZE, - MAX_MESSAGE_SIZE, - MAX_TEXT_LENGTH, - MDNS_DOMAIN, - MDNS_SERVICE_NAME, - MDNS_SERVICE_TYPE, - MESSAGE_CODE_TYPES, - MESSAGE_TYPE_CODES, - MessageBuffer, - OpLog, - PROTOCOL_VERSION, - StateSync, - SyncEngine, - TXT_DEVICE_ID, - TXT_DEVICE_NAME, - TXT_PLATFORM, - TXT_VERSION, - VERSION, - calculateChecksum, - calculateProgress, - chunkFile, - createAppMessage, - createChallengeResponse, - createDiscoveredDevice, - createEncryptedFrame, - createEncryptedTextMessage, - createErrorMessage, - createFileAccept, - createFileAcceptHttp, - createFileAck, - createFileChunk, - createFileChunkFromBase64, - createFileComplete, - createFileCompleteStreaming, - createFileReject, - createFileRequest, - createFileRequestHttp, - createFileRequestStreaming, - createFileTransfer, - createHello, - createPairChallenge, - createPairConfirm, - createPairReject, - createPairRequest, - createPairResponse, - createPairedDevice, - createPairingState, - createPingMessage, - createPongMessage, - createTextMessage, - createTextTransfer, - createTxtRecord, - decodeBase64, - decodeFrameBody, - decodeUTF8, - decrypt, - decryptMessage, - decryptTextMessage, - decryptToString, - deriveKey, - deriveSharedSecret, - deserializeMessage, - encodeBase64, - encodeEncryptedFrame, - encodePlaintextFrame, - encodeUTF8, - encrypt, - encryptMessage, - filterStaleDevices, - formatDuration, - formatEta, - formatFileSize, - formatProgressInfo, - formatTransferSpeed, - freePolicy, - generateChallenge, - generateDeviceId, - generateMessageId, - getMessageLength, - getMimeType, - getPairedDevice, - handlePairingMessage, - isDeviceStale, - isPaired, - pairingAllowed, - parseEncryptedFrame, - parseTxtRecord, - policyFor, - reassembleChunks, - removeDevice, - removePairedDevice, - serializeMessage, - updateDeviceList, - updateLastConnected, - verifyChallengeResponse, - verifyChecksum, - verifyFileIntegrity -}; diff --git a/packages/sync/dist/portable/index.d.mts b/packages/sync/dist/portable/index.d.mts deleted file mode 100644 index d7dba766..00000000 --- a/packages/sync/dist/portable/index.d.mts +++ /dev/null @@ -1,165 +0,0 @@ -/** Stable format discriminator. A file whose `format` differs is rejected. */ -declare const BUNDLE_FORMAT: "offgrid-backup"; -/** Envelope version. Bump when the envelope shape changes incompatibly. */ -declare const BUNDLE_VERSION: 1; -/** Anything mergeable by stable id (projects, conversations, images, ...). */ -interface HasId { - id: string; -} -interface MergeResult { - /** existing followed by the newly-added items, in incoming order. */ - merged: T[]; - /** ids that were actually added (not already present). */ - addedIds: string[]; -} -/** - * A portable bundle: a stable header plus an app-defined `data` payload. - * `T` is the app's payload shape. The header is identical across apps so a - * bundle produced by one surface is recognizable by another. - */ -interface PortableBundle { - format: typeof BUNDLE_FORMAT; - version: number; - /** ISO timestamp of export. */ - exportedAt: string; - data: T; -} -/** Thrown when a file is not a valid/compatible bundle. Message is user-facing. */ -declare class BundleError extends Error { - constructor(message: string); -} - -/** - * Additive, non-destructive merge by id — the single import rule for every - * record type. Incoming items whose id is not already present (and not - * duplicated within the incoming batch itself) are appended; existing ids are - * left untouched. It NEVER deletes or overwrites, so importing a backup can - * only ever add what is missing. Defined once here and reused by every store's - * import path so the semantics can never drift between record types. - */ -declare function mergeById(existing: T[], incoming: T[]): MergeResult; - -interface CreateBundleInput { - data: T; - /** ISO timestamp — the caller supplies the clock; this code takes none. */ - exportedAt: string; - /** Defaults to the current BUNDLE_VERSION. */ - version?: number; -} -/** Assemble a versioned bundle around an app payload. Pure. */ -declare function createBundle(input: CreateBundleInput): PortableBundle; -/** Serialize a bundle to the JSON text written to a file / sent over the wire. */ -declare function serializeBundle(bundle: PortableBundle): string; -interface ParseBundleOptions { - /** Expected envelope version; a mismatch throws a user-facing BundleError. */ - expectedVersion?: number; - /** - * App payload validator. Receives the raw `data` and returns the typed - * payload (or throws a BundleError with a user-facing message). The shared - * core validates only the envelope; payload shape is the app's business. - */ - validateData: (data: unknown) => T; -} -/** - * Parse + validate bundle JSON. Checks the format discriminator and version — - * the shared envelope contract — then hands the payload to the app-supplied - * validator. Throws BundleError with a user-facing message on any problem. - */ -declare function parseBundle(raw: string, opts: ParseBundleOptions): PortableBundle; - -/** A file the payload points at, paired with the bundle-relative key it travels under. */ -interface FileRef { - /** Path INSIDE the bundle, e.g. "files/img-0.png". */ - key: string; - /** On-device absolute path/uri to read from on export (or read back on import). */ - sourcePath: string; -} -/** - * Pure mapping between a payload's on-device file paths and bundle-relative keys. - * The app implements this because only it knows which fields carry file paths; - * it stays pure (no I/O) so it is unit-testable. `extract` (export) lists the - * files and returns a copy of the payload with paths replaced by keys; `listKeys` - * reads the keys back out of a keyed payload (import); `restore` swaps keys for - * the real restored paths. - */ -interface FileMapper { - extract(data: T): { - files: FileRef[]; - keyed: T; - }; - listKeys(keyed: T): string[]; - restore(keyed: T, keyToPath: Record): T; -} -/** - * Host filesystem + archive I/O. All the platform-specific, on-device work of - * assembling a zip and reading it back. Absolute paths throughout. - */ -interface ArchivePort { - /** A fresh empty directory to assemble a bundle in. */ - stageDir(): Promise; - writeText(absPath: string, text: string): Promise; - readText(absPath: string): Promise; - /** Copy a source file to an absolute dest path, creating parent dirs. */ - copyInto(srcPath: string, destAbsPath: string): Promise; - /** Zip the CONTENTS of stageDir into an archive; return its path. */ - pack(stageDir: string, suggestedName: string): Promise; - /** Unzip an archive into a fresh dir; return that dir. */ - unpack(archivePath: string): Promise; - /** The permanent on-device path a restored file with this key should live at. */ - restorePathFor(key: string): string; - join(...parts: string[]): string; -} -/** - * Host access to the app's data. Every store / SQLite read and every additive - * write lives behind this port. `T` = the app's payload shape; `S` = its restore - * summary. - */ -interface BackupDataPort { - collectAll(): Promise; - collectProject(projectId: string): Promise; - collectConversation(conversationId: string): Promise; - validate(data: unknown): T; - apply(data: T): Promise; -} -/** Host sink: how the finished bundle FILE leaves the device and how one is picked back. */ -interface BackupSink { - /** Hand a finished bundle file (already written at absPath) to the user. */ - deliverFile(absPath: string, suggestedName: string): Promise; - /** Pick a bundle file; return a readable local path to it, or null if cancelled. */ - pickFile(): Promise; -} -/** Turn an ISO timestamp into a filename-safe stamp. Pure. */ -declare const fileStamp: (iso: string) => string; -/** The name of the envelope entry inside every bundle zip. */ -declare const ENVELOPE_ENTRY = "backup.json"; -/** - * The engine. Constructed with the four ports + an injected clock (`now`) so the - * core stays free of `Date`. Export assembles a zip (envelope + files) and - * delivers it; import unpacks a zip, restores files, and applies additively. - */ -declare class BackupEngine { - private readonly data; - private readonly files; - private readonly archive; - private readonly sink; - private readonly now; - constructor(data: BackupDataPort, files: FileMapper, archive: ArchivePort, sink: BackupSink, now: () => string); - private exportBundle; - /** Export everything. */ - exportAll: () => Promise; - /** Export one project (its chats + knowledge base). Null if the project is gone. */ - exportProject: (projectId: string) => Promise; - /** Export one conversation, self-contained. Null if the conversation is gone. */ - exportConversation: (conversationId: string) => Promise; - /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ - import(): Promise; - /** - * Restore + apply a bundle at a known local path, WITHOUT the picker. This is - * the receiver side of device-to-device sharing: a peer pushes a bundle file, - * the transport saves it locally, and this applies it — the same unpack → - * restore-files → rewrite → apply flow `import()` uses after picking. - */ - importPath(archivePath: string): Promise; -} - -export { type ArchivePort, BUNDLE_FORMAT, BUNDLE_VERSION, type BackupDataPort, BackupEngine, type BackupSink, BundleError, type CreateBundleInput, ENVELOPE_ENTRY, type FileMapper, type FileRef, type HasId, type MergeResult, type ParseBundleOptions, type PortableBundle, createBundle, fileStamp, mergeById, parseBundle, serializeBundle }; diff --git a/packages/sync/dist/portable/index.d.ts b/packages/sync/dist/portable/index.d.ts deleted file mode 100644 index d7dba766..00000000 --- a/packages/sync/dist/portable/index.d.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** Stable format discriminator. A file whose `format` differs is rejected. */ -declare const BUNDLE_FORMAT: "offgrid-backup"; -/** Envelope version. Bump when the envelope shape changes incompatibly. */ -declare const BUNDLE_VERSION: 1; -/** Anything mergeable by stable id (projects, conversations, images, ...). */ -interface HasId { - id: string; -} -interface MergeResult { - /** existing followed by the newly-added items, in incoming order. */ - merged: T[]; - /** ids that were actually added (not already present). */ - addedIds: string[]; -} -/** - * A portable bundle: a stable header plus an app-defined `data` payload. - * `T` is the app's payload shape. The header is identical across apps so a - * bundle produced by one surface is recognizable by another. - */ -interface PortableBundle { - format: typeof BUNDLE_FORMAT; - version: number; - /** ISO timestamp of export. */ - exportedAt: string; - data: T; -} -/** Thrown when a file is not a valid/compatible bundle. Message is user-facing. */ -declare class BundleError extends Error { - constructor(message: string); -} - -/** - * Additive, non-destructive merge by id — the single import rule for every - * record type. Incoming items whose id is not already present (and not - * duplicated within the incoming batch itself) are appended; existing ids are - * left untouched. It NEVER deletes or overwrites, so importing a backup can - * only ever add what is missing. Defined once here and reused by every store's - * import path so the semantics can never drift between record types. - */ -declare function mergeById(existing: T[], incoming: T[]): MergeResult; - -interface CreateBundleInput { - data: T; - /** ISO timestamp — the caller supplies the clock; this code takes none. */ - exportedAt: string; - /** Defaults to the current BUNDLE_VERSION. */ - version?: number; -} -/** Assemble a versioned bundle around an app payload. Pure. */ -declare function createBundle(input: CreateBundleInput): PortableBundle; -/** Serialize a bundle to the JSON text written to a file / sent over the wire. */ -declare function serializeBundle(bundle: PortableBundle): string; -interface ParseBundleOptions { - /** Expected envelope version; a mismatch throws a user-facing BundleError. */ - expectedVersion?: number; - /** - * App payload validator. Receives the raw `data` and returns the typed - * payload (or throws a BundleError with a user-facing message). The shared - * core validates only the envelope; payload shape is the app's business. - */ - validateData: (data: unknown) => T; -} -/** - * Parse + validate bundle JSON. Checks the format discriminator and version — - * the shared envelope contract — then hands the payload to the app-supplied - * validator. Throws BundleError with a user-facing message on any problem. - */ -declare function parseBundle(raw: string, opts: ParseBundleOptions): PortableBundle; - -/** A file the payload points at, paired with the bundle-relative key it travels under. */ -interface FileRef { - /** Path INSIDE the bundle, e.g. "files/img-0.png". */ - key: string; - /** On-device absolute path/uri to read from on export (or read back on import). */ - sourcePath: string; -} -/** - * Pure mapping between a payload's on-device file paths and bundle-relative keys. - * The app implements this because only it knows which fields carry file paths; - * it stays pure (no I/O) so it is unit-testable. `extract` (export) lists the - * files and returns a copy of the payload with paths replaced by keys; `listKeys` - * reads the keys back out of a keyed payload (import); `restore` swaps keys for - * the real restored paths. - */ -interface FileMapper { - extract(data: T): { - files: FileRef[]; - keyed: T; - }; - listKeys(keyed: T): string[]; - restore(keyed: T, keyToPath: Record): T; -} -/** - * Host filesystem + archive I/O. All the platform-specific, on-device work of - * assembling a zip and reading it back. Absolute paths throughout. - */ -interface ArchivePort { - /** A fresh empty directory to assemble a bundle in. */ - stageDir(): Promise; - writeText(absPath: string, text: string): Promise; - readText(absPath: string): Promise; - /** Copy a source file to an absolute dest path, creating parent dirs. */ - copyInto(srcPath: string, destAbsPath: string): Promise; - /** Zip the CONTENTS of stageDir into an archive; return its path. */ - pack(stageDir: string, suggestedName: string): Promise; - /** Unzip an archive into a fresh dir; return that dir. */ - unpack(archivePath: string): Promise; - /** The permanent on-device path a restored file with this key should live at. */ - restorePathFor(key: string): string; - join(...parts: string[]): string; -} -/** - * Host access to the app's data. Every store / SQLite read and every additive - * write lives behind this port. `T` = the app's payload shape; `S` = its restore - * summary. - */ -interface BackupDataPort { - collectAll(): Promise; - collectProject(projectId: string): Promise; - collectConversation(conversationId: string): Promise; - validate(data: unknown): T; - apply(data: T): Promise; -} -/** Host sink: how the finished bundle FILE leaves the device and how one is picked back. */ -interface BackupSink { - /** Hand a finished bundle file (already written at absPath) to the user. */ - deliverFile(absPath: string, suggestedName: string): Promise; - /** Pick a bundle file; return a readable local path to it, or null if cancelled. */ - pickFile(): Promise; -} -/** Turn an ISO timestamp into a filename-safe stamp. Pure. */ -declare const fileStamp: (iso: string) => string; -/** The name of the envelope entry inside every bundle zip. */ -declare const ENVELOPE_ENTRY = "backup.json"; -/** - * The engine. Constructed with the four ports + an injected clock (`now`) so the - * core stays free of `Date`. Export assembles a zip (envelope + files) and - * delivers it; import unpacks a zip, restores files, and applies additively. - */ -declare class BackupEngine { - private readonly data; - private readonly files; - private readonly archive; - private readonly sink; - private readonly now; - constructor(data: BackupDataPort, files: FileMapper, archive: ArchivePort, sink: BackupSink, now: () => string); - private exportBundle; - /** Export everything. */ - exportAll: () => Promise; - /** Export one project (its chats + knowledge base). Null if the project is gone. */ - exportProject: (projectId: string) => Promise; - /** Export one conversation, self-contained. Null if the conversation is gone. */ - exportConversation: (conversationId: string) => Promise; - /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ - import(): Promise; - /** - * Restore + apply a bundle at a known local path, WITHOUT the picker. This is - * the receiver side of device-to-device sharing: a peer pushes a bundle file, - * the transport saves it locally, and this applies it — the same unpack → - * restore-files → rewrite → apply flow `import()` uses after picking. - */ - importPath(archivePath: string): Promise; -} - -export { type ArchivePort, BUNDLE_FORMAT, BUNDLE_VERSION, type BackupDataPort, BackupEngine, type BackupSink, BundleError, type CreateBundleInput, ENVELOPE_ENTRY, type FileMapper, type FileRef, type HasId, type MergeResult, type ParseBundleOptions, type PortableBundle, createBundle, fileStamp, mergeById, parseBundle, serializeBundle }; diff --git a/packages/sync/dist/portable/index.js b/packages/sync/dist/portable/index.js deleted file mode 100644 index d511b083..00000000 --- a/packages/sync/dist/portable/index.js +++ /dev/null @@ -1,181 +0,0 @@ -"use strict"; -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/portable/index.ts -var portable_exports = {}; -__export(portable_exports, { - BUNDLE_FORMAT: () => BUNDLE_FORMAT, - BUNDLE_VERSION: () => BUNDLE_VERSION, - BackupEngine: () => BackupEngine, - BundleError: () => BundleError, - ENVELOPE_ENTRY: () => ENVELOPE_ENTRY, - createBundle: () => createBundle, - fileStamp: () => fileStamp, - mergeById: () => mergeById, - parseBundle: () => parseBundle, - serializeBundle: () => serializeBundle -}); -module.exports = __toCommonJS(portable_exports); - -// src/portable/types.ts -var BUNDLE_FORMAT = "offgrid-backup"; -var BUNDLE_VERSION = 1; -var BundleError = class extends Error { - constructor(message) { - super(message); - this.name = "BundleError"; - } -}; - -// src/portable/merge.ts -function mergeById(existing, incoming) { - const existingIds = new Set(existing.map((item) => item.id)); - const additions = []; - const addedIds = []; - const seenIncoming = /* @__PURE__ */ new Set(); - for (const item of incoming) { - if (existingIds.has(item.id) || seenIncoming.has(item.id)) continue; - seenIncoming.add(item.id); - additions.push(item); - addedIds.push(item.id); - } - return { merged: [...existing, ...additions], addedIds }; -} - -// src/portable/bundle.ts -function createBundle(input) { - return { - format: BUNDLE_FORMAT, - version: input.version ?? BUNDLE_VERSION, - exportedAt: input.exportedAt, - data: input.data - }; -} -function serializeBundle(bundle) { - return JSON.stringify(bundle, null, 2); -} -function isObject(value) { - return typeof value === "object" && value !== null; -} -function parseBundle(raw, opts) { - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - throw new BundleError("This file is not a valid backup (could not read it as JSON)."); - } - if (!isObject(parsed)) { - throw new BundleError("This file is not a valid Off Grid backup."); - } - if (parsed.format !== BUNDLE_FORMAT) { - throw new BundleError("This file is not an Off Grid backup."); - } - const expected = opts.expectedVersion ?? BUNDLE_VERSION; - if (parsed.version !== expected) { - throw new BundleError( - `This backup was made by a different app version (backup v${String(parsed.version)}, expected v${expected}).` - ); - } - const data = opts.validateData(parsed.data); - return { - format: BUNDLE_FORMAT, - version: expected, - exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "", - data - }; -} - -// src/portable/engine.ts -var fileStamp = (iso) => iso.replaceAll(/[:.]/g, "-"); -var ENVELOPE_ENTRY = "backup.json"; -var BackupEngine = class { - constructor(data, files, archive, sink, now) { - this.data = data; - this.files = files; - this.archive = archive; - this.sink = sink; - this.now = now; - } - data; - files; - archive; - sink; - now; - async exportBundle(prefix, payload) { - if (payload == null) return null; - const { files: refs, keyed } = this.files.extract(payload); - const exportedAt = this.now(); - const stage = await this.archive.stageDir(); - await this.archive.writeText( - this.archive.join(stage, ENVELOPE_ENTRY), - serializeBundle(createBundle({ data: keyed, exportedAt })) - ); - for (const ref of refs) { - await this.archive.copyInto(ref.sourcePath, this.archive.join(stage, ref.key)); - } - const name = `${prefix}-${fileStamp(exportedAt)}.zip`; - const zipPath = await this.archive.pack(stage, name); - return this.sink.deliverFile(zipPath, name); - } - /** Export everything. */ - exportAll = () => this.data.collectAll().then((d) => this.exportBundle("offgrid-backup", d)); - /** Export one project (its chats + knowledge base). Null if the project is gone. */ - exportProject = (projectId) => this.data.collectProject(projectId).then((d) => this.exportBundle("offgrid-project", d)); - /** Export one conversation, self-contained. Null if the conversation is gone. */ - exportConversation = (conversationId) => this.data.collectConversation(conversationId).then((d) => this.exportBundle("offgrid-chat", d)); - /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ - async import() { - const picked = await this.sink.pickFile(); - if (picked == null) return null; - return this.importPath(picked); - } - /** - * Restore + apply a bundle at a known local path, WITHOUT the picker. This is - * the receiver side of device-to-device sharing: a peer pushes a bundle file, - * the transport saves it locally, and this applies it — the same unpack → - * restore-files → rewrite → apply flow `import()` uses after picking. - */ - async importPath(archivePath) { - const dir = await this.archive.unpack(archivePath); - const raw = await this.archive.readText(this.archive.join(dir, ENVELOPE_ENTRY)); - const bundle = parseBundle(raw, { validateData: (d) => this.data.validate(d) }); - const keyed = bundle.data; - const keyToPath = {}; - for (const key of this.files.listKeys(keyed)) { - const dest = this.archive.restorePathFor(key); - await this.archive.copyInto(this.archive.join(dir, key), dest); - keyToPath[key] = dest; - } - const restored = this.files.restore(keyed, keyToPath); - return this.data.apply(restored); - } -}; -// Annotate the CommonJS export names for ESM import in node: -0 && (module.exports = { - BUNDLE_FORMAT, - BUNDLE_VERSION, - BackupEngine, - BundleError, - ENVELOPE_ENTRY, - createBundle, - fileStamp, - mergeById, - parseBundle, - serializeBundle -}); diff --git a/packages/sync/dist/portable/index.mjs b/packages/sync/dist/portable/index.mjs deleted file mode 100644 index 77206fc1..00000000 --- a/packages/sync/dist/portable/index.mjs +++ /dev/null @@ -1,145 +0,0 @@ -// src/portable/types.ts -var BUNDLE_FORMAT = "offgrid-backup"; -var BUNDLE_VERSION = 1; -var BundleError = class extends Error { - constructor(message) { - super(message); - this.name = "BundleError"; - } -}; - -// src/portable/merge.ts -function mergeById(existing, incoming) { - const existingIds = new Set(existing.map((item) => item.id)); - const additions = []; - const addedIds = []; - const seenIncoming = /* @__PURE__ */ new Set(); - for (const item of incoming) { - if (existingIds.has(item.id) || seenIncoming.has(item.id)) continue; - seenIncoming.add(item.id); - additions.push(item); - addedIds.push(item.id); - } - return { merged: [...existing, ...additions], addedIds }; -} - -// src/portable/bundle.ts -function createBundle(input) { - return { - format: BUNDLE_FORMAT, - version: input.version ?? BUNDLE_VERSION, - exportedAt: input.exportedAt, - data: input.data - }; -} -function serializeBundle(bundle) { - return JSON.stringify(bundle, null, 2); -} -function isObject(value) { - return typeof value === "object" && value !== null; -} -function parseBundle(raw, opts) { - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - throw new BundleError("This file is not a valid backup (could not read it as JSON)."); - } - if (!isObject(parsed)) { - throw new BundleError("This file is not a valid Off Grid backup."); - } - if (parsed.format !== BUNDLE_FORMAT) { - throw new BundleError("This file is not an Off Grid backup."); - } - const expected = opts.expectedVersion ?? BUNDLE_VERSION; - if (parsed.version !== expected) { - throw new BundleError( - `This backup was made by a different app version (backup v${String(parsed.version)}, expected v${expected}).` - ); - } - const data = opts.validateData(parsed.data); - return { - format: BUNDLE_FORMAT, - version: expected, - exportedAt: typeof parsed.exportedAt === "string" ? parsed.exportedAt : "", - data - }; -} - -// src/portable/engine.ts -var fileStamp = (iso) => iso.replaceAll(/[:.]/g, "-"); -var ENVELOPE_ENTRY = "backup.json"; -var BackupEngine = class { - constructor(data, files, archive, sink, now) { - this.data = data; - this.files = files; - this.archive = archive; - this.sink = sink; - this.now = now; - } - data; - files; - archive; - sink; - now; - async exportBundle(prefix, payload) { - if (payload == null) return null; - const { files: refs, keyed } = this.files.extract(payload); - const exportedAt = this.now(); - const stage = await this.archive.stageDir(); - await this.archive.writeText( - this.archive.join(stage, ENVELOPE_ENTRY), - serializeBundle(createBundle({ data: keyed, exportedAt })) - ); - for (const ref of refs) { - await this.archive.copyInto(ref.sourcePath, this.archive.join(stage, ref.key)); - } - const name = `${prefix}-${fileStamp(exportedAt)}.zip`; - const zipPath = await this.archive.pack(stage, name); - return this.sink.deliverFile(zipPath, name); - } - /** Export everything. */ - exportAll = () => this.data.collectAll().then((d) => this.exportBundle("offgrid-backup", d)); - /** Export one project (its chats + knowledge base). Null if the project is gone. */ - exportProject = (projectId) => this.data.collectProject(projectId).then((d) => this.exportBundle("offgrid-project", d)); - /** Export one conversation, self-contained. Null if the conversation is gone. */ - exportConversation = (conversationId) => this.data.collectConversation(conversationId).then((d) => this.exportBundle("offgrid-chat", d)); - /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ - async import() { - const picked = await this.sink.pickFile(); - if (picked == null) return null; - return this.importPath(picked); - } - /** - * Restore + apply a bundle at a known local path, WITHOUT the picker. This is - * the receiver side of device-to-device sharing: a peer pushes a bundle file, - * the transport saves it locally, and this applies it — the same unpack → - * restore-files → rewrite → apply flow `import()` uses after picking. - */ - async importPath(archivePath) { - const dir = await this.archive.unpack(archivePath); - const raw = await this.archive.readText(this.archive.join(dir, ENVELOPE_ENTRY)); - const bundle = parseBundle(raw, { validateData: (d) => this.data.validate(d) }); - const keyed = bundle.data; - const keyToPath = {}; - for (const key of this.files.listKeys(keyed)) { - const dest = this.archive.restorePathFor(key); - await this.archive.copyInto(this.archive.join(dir, key), dest); - keyToPath[key] = dest; - } - const restored = this.files.restore(keyed, keyToPath); - return this.data.apply(restored); - } -}; -export { - BUNDLE_FORMAT, - BUNDLE_VERSION, - BackupEngine, - BundleError, - ENVELOPE_ENTRY, - createBundle, - fileStamp, - mergeById, - parseBundle, - serializeBundle -}; diff --git a/packages/sync/dist/transport-1cXLtrs5.d.mts b/packages/sync/dist/transport-1cXLtrs5.d.mts deleted file mode 100644 index 010b357c..00000000 --- a/packages/sync/dist/transport-1cXLtrs5.d.mts +++ /dev/null @@ -1,22 +0,0 @@ -/** A duplex, ordered, reliable byte stream to one remote peer. */ -interface SyncConnection { - /** Stable id for this connection (host:port or a socket id). */ - readonly id: string; - /** Remote host/address, when the transport knows it. */ - readonly remoteHost?: string; - send(data: Uint8Array): void; - onData(cb: (data: Uint8Array) => void): void; - onClose(cb: () => void): void; - close(): void; -} -/** Listens for inbound connections and dials outbound ones. */ -interface TransportBridge { - /** Start accepting inbound connections on `port`. */ - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; - /** Dial a remote peer and resolve once the byte stream is open. */ - connect(host: string, port: number): Promise; - /** Stop listening and release resources. */ - stop(): Promise; -} - -export type { SyncConnection as S, TransportBridge as T }; diff --git a/packages/sync/dist/transport-1cXLtrs5.d.ts b/packages/sync/dist/transport-1cXLtrs5.d.ts deleted file mode 100644 index 010b357c..00000000 --- a/packages/sync/dist/transport-1cXLtrs5.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** A duplex, ordered, reliable byte stream to one remote peer. */ -interface SyncConnection { - /** Stable id for this connection (host:port or a socket id). */ - readonly id: string; - /** Remote host/address, when the transport knows it. */ - readonly remoteHost?: string; - send(data: Uint8Array): void; - onData(cb: (data: Uint8Array) => void): void; - onClose(cb: () => void): void; - close(): void; -} -/** Listens for inbound connections and dials outbound ones. */ -interface TransportBridge { - /** Start accepting inbound connections on `port`. */ - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; - /** Dial a remote peer and resolve once the byte stream is open. */ - connect(host: string, port: number): Promise; - /** Stop listening and release resources. */ - stop(): Promise; -} - -export type { SyncConnection as S, TransportBridge as T }; diff --git a/packages/sync/package.json b/packages/sync/package.json deleted file mode 100644 index 67a2d3a5..00000000 --- a/packages/sync/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "@offgrid/sync", - "version": "0.0.1", - "private": true, - "description": "Off Grid sync engine: device pairing, discovery, encrypted framed messaging, and transfer. Platform-agnostic; embeddable in desktop and mobile via a TransportBridge. Extracted from EasyShare.", - "license": "AGPL-3.0-only", - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" - }, - "./node": { - "types": "./dist/adapters/node-tcp.d.ts", - "import": "./dist/adapters/node-tcp.mjs", - "require": "./dist/adapters/node-tcp.js" - }, - "./node-discovery": { - "types": "./dist/adapters/node-discovery.d.ts", - "import": "./dist/adapters/node-discovery.mjs", - "require": "./dist/adapters/node-discovery.js" - }, - "./rn": { - "types": "./dist/adapters/rn-tcp.d.ts", - "import": "./dist/adapters/rn-tcp.mjs", - "require": "./dist/adapters/rn-tcp.js" - }, - "./rn-discovery": { - "types": "./dist/adapters/rn-discovery.d.ts", - "import": "./dist/adapters/rn-discovery.mjs", - "require": "./dist/adapters/rn-discovery.js" - }, - "./portable": { - "types": "./dist/portable/index.d.ts", - "import": "./dist/portable/index.mjs", - "require": "./dist/portable/index.js" - } - }, - "scripts": { - "build": "tsup src/index.ts src/adapters/node-tcp.ts src/adapters/node-discovery.ts src/adapters/rn-tcp.ts src/adapters/rn-discovery.ts src/portable/index.ts --format esm,cjs --dts", - "dev": "tsup src/index.ts src/adapters/node-tcp.ts src/adapters/node-discovery.ts src/adapters/rn-tcp.ts src/adapters/rn-discovery.ts src/portable/index.ts --format esm,cjs --dts --watch", - "typecheck": "tsc --noEmit", - "prepare": "npm run build", - "test": "npm run build && node --test 'test/**/*.test.mjs'" - }, - "dependencies": { - "bonjour-service": "^1.2.1", - "js-sha512": "^0.9.0", - "tweetnacl": "^1.0.3", - "tweetnacl-util": "^0.15.1" - }, - "offgridVendoredFrom": { - "repo": "off-grid-ai/shared", - "path": "packages/sync", - "commit": "9b671b5", - "vendoredAt": "2026-07-26" - } -} diff --git a/packages/sync/src/adapters/node-discovery.ts b/packages/sync/src/adapters/node-discovery.ts deleted file mode 100644 index 2a039898..00000000 --- a/packages/sync/src/adapters/node-discovery.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Node mDNS discovery for @offgrid/sync (desktop). Implements DiscoveryService -// over bonjour-service (pure-JS multicast DNS, no native build) so devices find -// each other on the LAN without manual host/port. React Native uses a native -// NSD/Bonjour module instead; this Node adapter is at @offgrid/sync/node-discovery. - -import { Bonjour, type Browser, type Service } from 'bonjour-service'; -import type { DeviceInfo, DiscoveredDevice } from '../types'; -import type { DiscoveryService } from '../discovery'; -import { createTxtRecord, parseTxtRecord, createDiscoveredDevice } from '../discovery'; - -// bonjour-service takes the bare service type and forms _._tcp.local. -const SERVICE_TYPE = 'offgrid'; - -export class NodeDiscovery implements DiscoveryService { - private bonjour = new Bonjour(); - private browser?: Browser; - private published?: Service; - private foundCb?: (device: DiscoveredDevice) => void; - private lostCb?: (deviceId: string) => void; - - async start(): Promise { - this.browser = this.bonjour.find({ type: SERVICE_TYPE }); - this.browser.on('up', (service: Service) => { - const txt = (service.txt ?? {}) as Record; - const host = - service.addresses?.find((a) => a.includes('.')) ?? service.host ?? ''; - const info = parseTxtRecord(txt, host, service.port); - if (info) this.foundCb?.(createDiscoveredDevice(info)); - }); - this.browser.on('down', (service: Service) => { - const txt = (service.txt ?? {}) as Record; - this.lostCb?.(txt.id || service.name); - }); - } - - async advertise(device: DeviceInfo): Promise { - this.published = this.bonjour.publish({ - name: `OffGrid-${device.id}`, - type: SERVICE_TYPE, - port: device.port, - txt: createTxtRecord(device), - }); - } - - async stopAdvertising(): Promise { - await new Promise((resolve) => { - if (!this.published) return resolve(); - this.published.stop?.(() => resolve()); - this.published = undefined; - // stop() may not invoke the callback on all versions; resolve soon anyway. - setTimeout(resolve, 50); - }); - } - - onDeviceFound(callback: (device: DiscoveredDevice) => void): void { - this.foundCb = callback; - } - - onDeviceLost(callback: (deviceId: string) => void): void { - this.lostCb = callback; - } - - async stop(): Promise { - this.browser?.stop(); - await this.stopAdvertising(); - this.bonjour.destroy(); - } -} diff --git a/packages/sync/src/adapters/node-tcp.ts b/packages/sync/src/adapters/node-tcp.ts deleted file mode 100644 index 5e3c45a9..00000000 --- a/packages/sync/src/adapters/node-tcp.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Node TCP transport for @offgrid/sync (desktop / Electron main process). -// Implements TransportBridge over node:net. The engine handles framing and -// encryption; this adapter just moves bytes. React Native supplies its own -// transport, so this Node-only adapter lives at the @offgrid/sync/node subpath -// and is never imported by the platform-agnostic core. - -import net from 'net'; -import type { SyncConnection, TransportBridge } from '../transport'; - -function wrap(socket: net.Socket): SyncConnection { - const id = `${socket.remoteAddress ?? '?'}:${socket.remotePort ?? '?'}`; - // Avoid uncaught 'error' events tearing down the process; surface as close. - socket.on('error', () => socket.destroy()); - return { - id, - remoteHost: socket.remoteAddress ?? undefined, - send: (data) => socket.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength)), - onData: (cb) => socket.on('data', (d: Buffer) => cb(new Uint8Array(d.buffer, d.byteOffset, d.byteLength))), - onClose: (cb) => socket.on('close', () => cb()), - close: () => socket.destroy(), - }; -} - -export class NodeTcpTransport implements TransportBridge { - private server?: net.Server; - /** The port actually bound after listen() (useful when listening on 0). */ - boundPort?: number; - - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer((socket) => onConnection(wrap(socket))); - server.once('error', reject); - server.listen(port, () => { - const addr = server.address(); - if (addr && typeof addr === 'object') this.boundPort = addr.port; - this.server = server; - resolve(); - }); - }); - } - - connect(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const socket = net.createConnection({ host, port }, () => resolve(wrap(socket))); - socket.once('error', reject); - }); - } - - stop(): Promise { - return new Promise((resolve) => { - if (!this.server) return resolve(); - this.server.close(() => resolve()); - this.server = undefined; - }); - } -} diff --git a/packages/sync/src/adapters/rn-discovery.ts b/packages/sync/src/adapters/rn-discovery.ts deleted file mode 100644 index 78062c6d..00000000 --- a/packages/sync/src/adapters/rn-discovery.ts +++ /dev/null @@ -1,103 +0,0 @@ -// React Native mDNS discovery for @offgrid/sync (mobile). Implements -// DiscoveryService over react-native-zeroconf (Android NSD / iOS Bonjour). -// Mirrors node-discovery.ts. The Zeroconf instance is INJECTED by the host so -// this package never imports react-native-zeroconf directly. -// -// Service type 'offgrid' resolves to _offgrid._tcp.local — identical to the -// desktop Node adapter, so phone and laptop find each other. - -import type { DeviceInfo, DiscoveredDevice } from '../types'; -import type { DiscoveryService } from '../discovery'; -import { createTxtRecord, parseTxtRecord, createDiscoveredDevice } from '../discovery'; - -const SERVICE_TYPE = 'offgrid'; -const PROTOCOL = 'tcp'; -const DOMAIN = 'local.'; - -/** Minimal shape of a react-native-zeroconf resolved service. */ -export interface RnZeroconfService { - txt?: Record; - addresses?: string[]; - host?: string; - port: number; - name: string; -} - -/** Minimal shape of the react-native-zeroconf instance we use. Publish methods - * are optional — not every RN zeroconf build can advertise; discovery still - * works one-way (we browse; a peer that can advertise gets found and dialed). */ -export interface RnZeroconf { - on(event: 'resolved', cb: (service: RnZeroconfService) => void): void; - on(event: 'remove', cb: (name: string) => void): void; - on(event: 'error', cb: (err: unknown) => void): void; - scan(type: string, protocol: string, domain: string): void; - stop(): void; - removeDeviceListeners?(): void; - publishService?( - type: string, - protocol: string, - domain: string, - name: string, - port: number, - txt?: Record - ): void; - unpublishService?(name: string): void; -} - -export class RnDiscovery implements DiscoveryService { - private foundCb?: (device: DiscoveredDevice) => void; - private lostCb?: (deviceId: string) => void; - private publishedName?: string; - - constructor(private readonly zeroconf: RnZeroconf) {} - - async start(): Promise { - this.zeroconf.on('resolved', (svc) => { - const txt = svc.txt ?? {}; - const ipv4 = svc.addresses?.find((a) => a.includes('.')); - const host = ipv4 ?? svc.host ?? svc.addresses?.[0] ?? ''; - const info = parseTxtRecord(txt, host, svc.port); - if (info) this.foundCb?.(createDiscoveredDevice(info)); - }); - this.zeroconf.on('remove', (name) => { - // name like "OffGrid-._offgrid._tcp.local." — recover our device id. - const m = /OffGrid-([^.]+)/.exec(name); - this.lostCb?.(m ? m[1] : name); - }); - this.zeroconf.on('error', () => { - /* swallowed; rescans recover */ - }); - this.zeroconf.scan(SERVICE_TYPE, PROTOCOL, DOMAIN); - } - - async advertise(device: DeviceInfo): Promise { - const name = `OffGrid-${device.id}`; - this.publishedName = name; - if (typeof this.zeroconf.publishService === 'function') { - this.zeroconf.publishService(SERVICE_TYPE, PROTOCOL, DOMAIN, name, device.port, createTxtRecord(device)); - } else { - console.warn('[sync] zeroconf.publishService unavailable — browse-only on this device'); - } - } - - async stopAdvertising(): Promise { - if (this.publishedName && typeof this.zeroconf.unpublishService === 'function') { - this.zeroconf.unpublishService(this.publishedName); - } - this.publishedName = undefined; - } - - onDeviceFound(callback: (device: DiscoveredDevice) => void): void { - this.foundCb = callback; - } - - onDeviceLost(callback: (deviceId: string) => void): void { - this.lostCb = callback; - } - - async stop(): Promise { - await this.stopAdvertising(); - this.zeroconf.stop(); - this.zeroconf.removeDeviceListeners?.(); - } -} diff --git a/packages/sync/src/adapters/rn-tcp.ts b/packages/sync/src/adapters/rn-tcp.ts deleted file mode 100644 index a28a24fd..00000000 --- a/packages/sync/src/adapters/rn-tcp.ts +++ /dev/null @@ -1,93 +0,0 @@ -// React Native TCP transport for @offgrid/sync (mobile). Implements -// TransportBridge over react-native-tcp-socket. Mirrors node-tcp.ts; the engine -// handles framing + encryption, this just moves bytes. -// -// The RN socket module and a byte codec are INJECTED by the host (the mobile app -// passes `TcpSocket` and a Buffer-backed codec), so this package never imports -// react-native-tcp-socket directly and stays installable/buildable without RN. - -import type { SyncConnection, TransportBridge } from '../transport'; - -/** Minimal shape of a react-native-tcp-socket socket we use. */ -export interface RnSocket { - remoteAddress?: string; - on(event: 'data', cb: (data: unknown) => void): void; - on(event: 'close', cb: () => void): void; - on(event: 'error', cb: (err: unknown) => void): void; - write(data: unknown): void; - destroy(): void; -} - -/** Minimal shape of a react-native-tcp-socket server we use. */ -export interface RnTcpServer { - listen(opts: { port: number; host?: string }, cb?: () => void): void; - address(): { port: number } | string | null; - on(event: 'error', cb: (err: unknown) => void): void; - close(): void; -} - -/** Minimal shape of the react-native-tcp-socket module we use. */ -export interface RnTcpModule { - createServer(onConnection: (socket: RnSocket) => void): RnTcpServer; - createConnection(opts: { host: string; port: number }, cb?: () => void): RnSocket; -} - -/** Bytes <-> wire conversion. Injected because RN needs its Buffer polyfill and - * react-native-tcp-socket may deliver 'data' as a (base64) string on Android. */ -export interface ByteCodec { - /** Normalize an inbound 'data' payload (Buffer or string) to raw bytes. */ - toBytes(data: unknown): Uint8Array; - /** Convert raw bytes into what socket.write() expects (a Buffer). */ - fromBytes(bytes: Uint8Array): unknown; -} - -function wrap(socket: RnSocket, codec: ByteCodec): SyncConnection { - socket.on('error', () => socket.destroy()); // surface errors as close, not crash - return { - id: socket.remoteAddress ?? 'rn-peer', - remoteHost: socket.remoteAddress, - send: (data) => socket.write(codec.fromBytes(data)), - onData: (cb) => socket.on('data', (d) => cb(codec.toBytes(d))), - onClose: (cb) => socket.on('close', cb), - close: () => socket.destroy(), - }; -} - -export class RnTcpTransport implements TransportBridge { - private server?: RnTcpServer; - /** Port actually bound after listen() (we listen on 0 and advertise this). */ - boundPort?: number; - - constructor( - private readonly tcp: RnTcpModule, - private readonly codec: ByteCodec - ) {} - - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise { - return new Promise((resolve, reject) => { - const server = this.tcp.createServer((socket) => onConnection(wrap(socket, this.codec))); - server.on('error', reject); - server.listen({ port, host: '0.0.0.0' }, () => { - const addr = server.address(); - if (addr && typeof addr === 'object') this.boundPort = addr.port; - this.server = server; - resolve(); - }); - }); - } - - connect(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - const socket = this.tcp.createConnection({ host, port }, () => resolve(wrap(socket, this.codec))); - socket.on('error', reject); - }); - } - - stop(): Promise { - return new Promise((resolve) => { - this.server?.close(); - this.server = undefined; - resolve(); - }); - } -} diff --git a/packages/sync/src/cap.ts b/packages/sync/src/cap.ts deleted file mode 100644 index e29a6186..00000000 --- a/packages/sync/src/cap.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Device cap (open-core monetization lever). -// -// The COUNT CHECK lives here in the open core: free tier pairs up to -// FREE_DEVICE_CAP devices; beyond that requires a paid entitlement. The -// ENTITLEMENT itself (what the limit is) is injected by the host's private pro -// layer via DeviceCapPolicy.limit() - this package never verifies billing. - -export const FREE_DEVICE_CAP = 2; - -export interface DeviceCapPolicy { - /** Max distinct paired devices allowed. Free returns FREE_DEVICE_CAP; a pro - * entitlement returns a higher number or Infinity. */ - limit(): number; -} - -/** A fixed free-tier policy. */ -export const freePolicy: DeviceCapPolicy = { limit: () => FREE_DEVICE_CAP }; - -/** Build a policy from a pro flag supplied by the host's entitlement check. */ -export function policyFor(isPro: boolean, proLimit: number = Infinity): DeviceCapPolicy { - return { limit: () => (isPro ? proLimit : FREE_DEVICE_CAP) }; -} - -export interface DeviceCap { - policy: DeviceCapPolicy; - /** How many distinct devices are already paired (from the host's store). */ - pairedCount: () => number; - /** Whether this device id is already paired (re-pairing does not count). */ - isKnown: (deviceId: string) => boolean; -} - -/** True if pairing with `deviceId` is allowed under the cap. */ -export function pairingAllowed(cap: DeviceCap | undefined, deviceId: string): boolean { - if (!cap) return true; - if (cap.isKnown(deviceId)) return true; // re-pair an existing device - return cap.pairedCount() < cap.policy.limit(); -} diff --git a/packages/sync/src/crypto/index.ts b/packages/sync/src/crypto/index.ts deleted file mode 100644 index b962a587..00000000 --- a/packages/sync/src/crypto/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -import nacl from 'tweetnacl'; -import { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } from 'tweetnacl-util'; -import { sha512 } from 'js-sha512'; - -// Constants -// Note: 10,000 iterations is still secure for a passphrase-based key while being fast enough for mobile -// 100,000 was causing 5-10 second delays on mobile devices -const PBKDF2_ITERATIONS = 10000; -const SALT_LENGTH = 16; -const KEY_LENGTH = 32; // 256 bits for NaCl secretbox - -/** - * Generate a random device ID - */ -export function generateDeviceId(): string { - const bytes = nacl.randomBytes(16); - return encodeBase64(bytes).replace(/[+/=]/g, (c) => - c === '+' ? '-' : c === '/' ? '_' : '' - ); -} - -/** - * Generate a random message ID - */ -export function generateMessageId(): string { - const bytes = nacl.randomBytes(8); - return encodeBase64(bytes).replace(/[+/=]/g, (c) => - c === '+' ? '-' : c === '/' ? '_' : '' - ); -} - -/** - * Simple PBKDF2-like key derivation using iterated hashing - * Note: This is a simplified implementation using NaCl primitives - */ -export function deriveKey( - passphrase: string, - salt: Uint8Array, - iterations: number = PBKDF2_ITERATIONS -): Uint8Array { - const passphraseBytes = decodeUTF8(passphrase); - - // Combine passphrase and salt - const combined = new Uint8Array(passphraseBytes.length + salt.length); - combined.set(passphraseBytes); - combined.set(salt, passphraseBytes.length); - - // Iteratively hash - let result = nacl.hash(combined); - for (let i = 1; i < iterations; i++) { - result = nacl.hash(result); - } - - // Take first KEY_LENGTH bytes - return result.slice(0, KEY_LENGTH); -} - -/** - * Derive a shared secret from a passphrase and two device IDs - * This ensures both devices derive the same key - */ -export function deriveSharedSecret( - passphrase: string, - deviceId1: string, - deviceId2: string -): string { - // Sort device IDs to ensure consistent ordering - const sortedIds = [deviceId1, deviceId2].sort(); - const saltString = `${sortedIds[0]}:${sortedIds[1]}`; - const salt = nacl.hash(decodeUTF8(saltString)).slice(0, SALT_LENGTH); - - const key = deriveKey(passphrase, salt); - return encodeBase64(key); -} - -/** - * Generate a random challenge for pairing verification - */ -export function generateChallenge(): string { - const bytes = nacl.randomBytes(32); - return encodeBase64(bytes); -} - -/** - * Create an HMAC-like response to a challenge using the shared secret - */ -export function createChallengeResponse( - challenge: string, - sharedSecret: string -): string { - const challengeBytes = decodeBase64(challenge); - const secretBytes = decodeBase64(sharedSecret); - - // Combine challenge and secret, then hash - const combined = new Uint8Array(challengeBytes.length + secretBytes.length); - combined.set(challengeBytes); - combined.set(secretBytes, challengeBytes.length); - - const hash = nacl.hash(combined); - return encodeBase64(hash.slice(0, 32)); -} - -/** - * Verify a challenge response - */ -export function verifyChallengeResponse( - challenge: string, - response: string, - sharedSecret: string -): boolean { - const expectedResponse = createChallengeResponse(challenge, sharedSecret); - return response === expectedResponse; -} - -/** - * Encrypt data using NaCl secretbox (XSalsa20-Poly1305) - */ -export function encrypt( - data: string | Uint8Array, - secretKey: string -): { encrypted: string; nonce: string } { - const keyBytes = decodeBase64(secretKey); - const dataBytes = typeof data === 'string' ? decodeUTF8(data) : data; - const nonce = nacl.randomBytes(nacl.secretbox.nonceLength); - - const encrypted = nacl.secretbox(dataBytes, nonce, keyBytes); - - return { - encrypted: encodeBase64(encrypted), - nonce: encodeBase64(nonce), - }; -} - -/** - * Decrypt data using NaCl secretbox - */ -export function decrypt( - encrypted: string, - nonce: string, - secretKey: string -): Uint8Array | null { - const keyBytes = decodeBase64(secretKey); - const encryptedBytes = decodeBase64(encrypted); - const nonceBytes = decodeBase64(nonce); - - const decrypted = nacl.secretbox.open(encryptedBytes, nonceBytes, keyBytes); - return decrypted; -} - -/** - * Decrypt data and return as string - */ -export function decryptToString( - encrypted: string, - nonce: string, - secretKey: string -): string | null { - const decrypted = decrypt(encrypted, nonce, secretKey); - if (!decrypted) return null; - return encodeUTF8(decrypted); -} - -/** - * Calculate a checksum for file integrity verification - */ -export function calculateChecksum(data: Uint8Array): string { - const hash = nacl.hash(data); - return encodeBase64(hash.slice(0, 16)); -} - -/** - * Verify a checksum - */ -export function verifyChecksum(data: Uint8Array, checksum: string): boolean { - const calculated = calculateChecksum(data); - return calculated === checksum; -} - -/** - * Incremental/streaming checksum calculator using SHA-512. - * Produces the same output format as calculateChecksum() (base64 of first 16 bytes of SHA-512) - * but allows feeding data in chunks to avoid loading entire files into memory. - */ -export class IncrementalChecksum { - private hasher: ReturnType; - - constructor() { - this.hasher = sha512.create(); - } - - /** - * Feed a chunk of data into the hash - */ - update(data: Uint8Array): void { - this.hasher.update(data); - } - - /** - * Finalize and return checksum in the same format as calculateChecksum() - * (base64 of first 16 bytes of SHA-512 digest) - */ - digest(): string { - const hashArray = this.hasher.array(); - const first16 = new Uint8Array(hashArray.slice(0, 16)); - return encodeBase64(first16); - } -} - -// Re-export utilities -export { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 }; diff --git a/packages/sync/src/discovery/index.ts b/packages/sync/src/discovery/index.ts deleted file mode 100644 index 25839360..00000000 --- a/packages/sync/src/discovery/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { DeviceInfo, DiscoveredDevice } from '../types'; - -// mDNS Service Configuration -export const MDNS_SERVICE_TYPE = '_easyshare._tcp'; -export const MDNS_SERVICE_NAME = 'EasyShare'; -export const MDNS_DOMAIN = 'local'; - -// TXT Record Keys -export const TXT_DEVICE_ID = 'id'; -export const TXT_DEVICE_NAME = 'name'; -export const TXT_PLATFORM = 'platform'; -export const TXT_VERSION = 'version'; - -/** - * Create TXT record data for mDNS advertisement - */ -export function createTxtRecord(device: DeviceInfo): Record { - return { - [TXT_DEVICE_ID]: device.id, - [TXT_DEVICE_NAME]: device.name, - [TXT_PLATFORM]: device.platform, - [TXT_VERSION]: device.version, - }; -} - -/** - * Parse TXT record data from mDNS discovery - */ -export function parseTxtRecord( - txt: Record, - host: string, - port: number -): DeviceInfo | null { - const id = txt[TXT_DEVICE_ID]; - const name = txt[TXT_DEVICE_NAME]; - const platform = txt[TXT_PLATFORM] as DeviceInfo['platform']; - const version = txt[TXT_VERSION]; - - if (!id || !name || !platform || !version) { - return null; - } - - return { - id, - name, - platform, - version, - host, - port, - }; -} - -/** - * Create a DiscoveredDevice from DeviceInfo - */ -export function createDiscoveredDevice(device: DeviceInfo): DiscoveredDevice { - return { - ...device, - lastSeen: Date.now(), - }; -} - -/** - * Check if a discovered device is stale (not seen recently) - */ -export function isDeviceStale(device: DiscoveredDevice, maxAgeMs: number = 30000): boolean { - return Date.now() - device.lastSeen > maxAgeMs; -} - -/** - * Filter out stale devices from a list - */ -export function filterStaleDevices( - devices: DiscoveredDevice[], - maxAgeMs: number = 30000 -): DiscoveredDevice[] { - return devices.filter((device) => !isDeviceStale(device, maxAgeMs)); -} - -/** - * Update or add a device to a list of discovered devices - */ -export function updateDeviceList( - devices: DiscoveredDevice[], - newDevice: DiscoveredDevice -): DiscoveredDevice[] { - const existingIndex = devices.findIndex((d) => d.id === newDevice.id); - - if (existingIndex >= 0) { - // Update existing device - const updated = [...devices]; - updated[existingIndex] = { ...newDevice, lastSeen: Date.now() }; - return updated; - } - - // Add new device - return [...devices, newDevice]; -} - -/** - * Remove a device from the list by ID - */ -export function removeDevice(devices: DiscoveredDevice[], deviceId: string): DiscoveredDevice[] { - return devices.filter((d) => d.id !== deviceId); -} - -// Platform-specific discovery interfaces (implemented in desktop/mobile packages) -export interface DiscoveryService { - start(): Promise; - stop(): Promise; - advertise(device: DeviceInfo): Promise; - stopAdvertising(): Promise; - onDeviceFound(callback: (device: DiscoveredDevice) => void): void; - onDeviceLost(callback: (deviceId: string) => void): void; -} diff --git a/packages/sync/src/engine.ts b/packages/sync/src/engine.ts deleted file mode 100644 index 21395c74..00000000 --- a/packages/sync/src/engine.ts +++ /dev/null @@ -1,256 +0,0 @@ -// SyncEngine: ties pairing and encrypted messaging together over a -// TransportBridge. Host-agnostic - give it a transport and a local device and -// it manages the handshake and routes application messages to paired peers. - -import type { DeviceInfo, PairedDevice, Message, MessageType } from './types'; -import { - createPairingState, - handlePairingMessage, - createPairRequest, - createPairedDevice, - type PairingState, -} from './pairing'; -import { FrameBuffer, encodePlaintextFrame, encodeEncryptedFrame } from './wire'; -import { createAppMessage, createHello } from './protocol'; -import type { SyncConnection, TransportBridge } from './transport'; -import { pairingAllowed, type DeviceCap } from './cap'; -import { createPairReject } from './pairing'; - -const PAIRING_TYPES: ReadonlySet = new Set([ - 'pair_request', - 'pair_challenge', - 'pair_response', - 'pair_confirm', - 'pair_reject', -]); - -export interface SyncEngineOptions { - localDevice: DeviceInfo; - transport: TransportBridge; - /** Supply the passphrase for an incoming pairing (e.g. a UI prompt). Return - * null/undefined to refuse. Not needed on the side that calls connect(). */ - getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; - /** Application message from a paired peer (pairing traffic is handled internally). */ - onMessage?: (deviceId: string, message: Message) => void; - /** Generic app-channel message from a paired peer (type 'app'). Used by - * features like memory/clipboard sync that ride the paired channel. */ - onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; - /** Look up the stored shared secret for an already-paired device, so an - * inbound reconnect (hello) can resume without re-running the handshake. */ - getSharedSecret?: (deviceId: string) => string | undefined; - /** A pairing handshake completed. */ - onPaired?: (device: PairedDevice) => void; - /** A pairing attempt failed. */ - onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; - /** Optional device cap (open-core 2 free / 3+ paid). When set, pairing a new - * device beyond the limit is refused on both the dialing and accepting side. */ - cap?: DeviceCap; -} - -/** One peer connection: owns its frame buffer, pairing state, and shared secret. */ -class PeerSession { - private buffer = new FrameBuffer(); - private pairing: PairingState; - private sharedSecret?: string; - private passphrase?: string; - private resumeSecret?: string; - private helloSent = false; - private queue: Promise = Promise.resolve(); - remoteDevice?: DeviceInfo; - - constructor( - readonly conn: SyncConnection, - private readonly engine: SyncEngine, - private readonly opts: SyncEngineOptions, - initiateWith?: { remote: DeviceInfo; passphrase: string }, - resumeWith?: { remote: DeviceInfo; sharedSecret: string } - ) { - this.pairing = createPairingState(opts.localDevice); - if (initiateWith) { - this.passphrase = initiateWith.passphrase; - this.remoteDevice = initiateWith.remote; - this.pairing = { ...this.pairing, remoteDevice: initiateWith.remote, passphrase: initiateWith.passphrase }; - } else if (resumeWith) { - this.remoteDevice = resumeWith.remote; - this.resumeSecret = resumeWith.sharedSecret; - } - conn.onData((data) => this.onData(data)); - conn.onClose(() => this.engine._removeSession(this)); - if (initiateWith) { - this.sendPlain(createPairRequest(opts.localDevice)); - } else if (resumeWith) { - // Reconnect: greet with a plaintext hello so the peer resumes with the - // stored secret. The secret only goes "live" once hello round-trips. - this.sendPlain(createHello(opts.localDevice)); - this.helloSent = true; - } - } - - get pairedSecret(): string | undefined { - return this.sharedSecret; - } - - /** Send an application message to this peer (must be paired). */ - sendMessage(message: Message): boolean { - if (!this.sharedSecret) return false; - this.conn.send(encodeEncryptedFrame(message, this.sharedSecret)); - return true; - } - - private sendPlain(message: Message): void { - this.conn.send(encodePlaintextFrame(message)); - } - - private onData(data: Uint8Array): void { - this.buffer.append(data); - const frames = this.buffer.drain(this.sharedSecret); - // Serialize handling so async passphrase prompts keep handshake order. - for (const f of frames) { - this.queue = this.queue.then(() => this.route(f.message)); - } - } - - private async route(message: Message): Promise { - if (message.type === 'hello') { - this.handleHello(message); - return; - } - if (PAIRING_TYPES.has(message.type)) { - await this.handlePairing(message); - return; - } - if (this.sharedSecret && this.remoteDevice) { - if (message.type === 'app') { - const p = message.payload as { channel: string; data: unknown }; - this.opts.onAppMessage?.(this.remoteDevice.id, p.channel, p.data); - } else { - this.opts.onMessage?.(this.remoteDevice.id, message); - } - } - } - - /** Resume an already-paired device using the stored secret (no handshake). */ - private handleHello(message: Message): void { - const remote = (message as { payload: { deviceInfo: DeviceInfo } }).payload.deviceInfo; - this.remoteDevice = remote; - const secret = this.resumeSecret ?? this.opts.getSharedSecret?.(remote.id); - if (!secret) { - this.opts.onPairingFailed?.(remote, 'unknown_device'); - this.conn.close(); - return; - } - this.sharedSecret = secret; - if (!this.helloSent) { - this.sendPlain(createHello(this.opts.localDevice)); - this.helloSent = true; - } - const paired: PairedDevice = { ...remote, sharedSecret: secret, pairedAt: Date.now() }; - this.engine._registerPaired(remote.id, this); - this.opts.onPaired?.(paired); - } - - private async handlePairing(message: Message): Promise { - // First inbound pair_request: enforce the device cap, then ask for the passphrase. - if (message.type === 'pair_request' && this.passphrase == null) { - const remote = (message as { payload: { deviceInfo: DeviceInfo } }).payload.deviceInfo; - this.remoteDevice = remote; - if (!pairingAllowed(this.opts.cap, remote.id)) { - this.sendPlain(createPairReject('device limit reached')); - this.opts.onPairingFailed?.(remote, 'device_cap_reached'); - this.conn.close(); - return; - } - const pass = await this.opts.getPassphrase?.(remote); - if (pass == null) { - this.opts.onPairingFailed?.(remote, 'pairing refused'); - this.conn.close(); - return; - } - this.passphrase = pass; - } - - const { newState, response } = handlePairingMessage(this.pairing, message, this.passphrase); - this.pairing = newState; - if (newState.sharedSecret) this.sharedSecret = newState.sharedSecret; - if (newState.remoteDevice) this.remoteDevice = newState.remoteDevice; - if (response) this.sendPlain(response); - - if (newState.status === 'success') { - const paired = createPairedDevice(newState); - if (paired) { - this.engine._registerPaired(paired.id, this); - this.opts.onPaired?.(paired); - } - } else if (newState.status === 'failed') { - this.opts.onPairingFailed?.(this.remoteDevice, newState.error ?? 'pairing failed'); - } - } -} - -export class SyncEngine { - private sessions = new Set(); - private paired = new Map(); - - constructor(private readonly opts: SyncEngineOptions) {} - - /** Start accepting inbound connections on `port`. */ - async start(port: number): Promise { - await this.opts.transport.listen(port, (conn) => { - this.sessions.add(new PeerSession(conn, this, this.opts)); - }); - } - - /** Dial a discovered device and begin pairing with `passphrase`. Refuses if - * pairing a new device would exceed the device cap. */ - async pair(device: DeviceInfo, passphrase: string): Promise { - if (!pairingAllowed(this.opts.cap, device.id)) { - this.opts.onPairingFailed?.(device, 'device_cap_reached'); - return; - } - const conn = await this.opts.transport.connect(device.host, device.port); - const session = new PeerSession(conn, this, this.opts, { remote: device, passphrase }); - this.sessions.add(session); - } - - /** Reconnect to an already-paired device using its stored shared secret, - * skipping the pairing handshake. Used for auto-reconnect on discovery. */ - async reconnect(device: DeviceInfo, sharedSecret: string): Promise { - const conn = await this.opts.transport.connect(device.host, device.port); - const session = new PeerSession(conn, this, this.opts, undefined, { remote: device, sharedSecret }); - this.sessions.add(session); - } - - /** Send an application message to an already-paired device. */ - send(deviceId: string, message: Message): boolean { - return this.paired.get(deviceId)?.sendMessage(message) ?? false; - } - - /** Send a generic app-channel message (encrypted) to a paired device. */ - sendApp(deviceId: string, channel: string, data: unknown): boolean { - return this.send(deviceId, createAppMessage(channel, data)); - } - - isPaired(deviceId: string): boolean { - return this.paired.has(deviceId); - } - - async stop(): Promise { - for (const s of this.sessions) s.conn.close(); - this.sessions.clear(); - this.paired.clear(); - await this.opts.transport.stop(); - } - - /** @internal */ - _registerPaired(deviceId: string, session: PeerSession): void { - this.paired.set(deviceId, session); - } - - /** @internal */ - _removeSession(session: PeerSession): void { - this.sessions.delete(session); - for (const [id, s] of this.paired) { - if (s === session) this.paired.delete(id); - } - } -} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts deleted file mode 100644 index 46c847e2..00000000 --- a/packages/sync/src/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -// @offgrid/sync - platform-agnostic device-to-device sync engine. -// Extracted from EasyShare. Pairing, discovery contracts, encrypted framed -// messaging, and transfer live here; the actual sockets and mDNS are provided -// by the host through TransportBridge / DiscoveryService so this package is -// embeddable in Off Grid Desktop (Node) and Off Grid Mobile (React Native). - -// Types -export * from './types'; - -// Crypto utilities -export * from './crypto'; - -// Discovery protocol + DiscoveryService interface -export * from './discovery'; - -// Pairing protocol / state machine -export * from './pairing'; - -// Transfer protocol -export * from './transfer'; - -// Message protocol (serialization, framing, encryption) -export * from './protocol'; - -// Wire codec (length-prefixed plaintext/encrypted frames) -export * from './wire'; - -// Transport abstraction (sockets injected by the host) -export * from './transport'; - -// High-level engine that ties pairing + messaging over a transport -export * from './engine'; - -// Device cap (open-core 2 free / 3+ paid) -export * from './cap'; - -// Discovery orchestrator (auto-reconnect known devices on discovery) -export * from './orchestrator'; - -// Op-log replication: chats / projects / memory converge across devices -// (Lamport + last-writer-wins). Pure; reused by desktop and mobile. -export * from './oplog'; -export * from './state-sync'; - -export const VERSION = '0.0.1'; -export const APP_NAME = 'Off Grid Sync'; diff --git a/packages/sync/src/oplog.ts b/packages/sync/src/oplog.ts deleted file mode 100644 index aa54442411ff19a4ec7dce53104acb3d38dac11e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5390 zcmbVQU31&U70t7L#m!7-2s$K`^pUEhs=Hf50sTd0~cr6v9ExBt?cx7X)0 zYD!_(N>_8Ssg7KZSj%d%KWEvzi}wANCRTGM$cGpjN2 zA>I7*vak{t*HUDbUSq+Xq@rq+wuX!%yOliO&DqsCt&37pCTgk_zf@aAe^u-CMi=>^ z+1D8*O80jvSt-4rlBjc9No=S!5?6bkZn<95D-5G6wZfz+;+3?;W-#l&5S*b#YEu|1 z>r7H2$Q1RalylY4y+**=Pb+vXyg8S(E$p5u>{Az_EPj>xh~!S{z1?CY$yOcUT zIFSEg#*qb)0l-fMICD&srnM9LL=%9Bs3%9=d*GhbYXNR78+te(I(+ehUX^Mk%5pz% z>*C)nnBE`?O4_!-<{iUwnYRrABodich{S_qQhITSNmO%@FUVL8${dABFV)8FBB0Iz z&Pb-4lqQ}E#?`0`v%==YM!M@pD<<+JOzjE!bn+XA4Mzz`827jeS1-b^S_?)SN~#v6 zfg%$0_U*;X8P&>?Xss&hOv?(BV}ouBex30)?ttbZpc=RJ_qHtKYU@Ilxgkgj=J%K< zBdT4#e~RbegI#qY(8gjM|vr;EVbzjz2|)Yf;ky01^-sL*KS-g*vl^Txlup$W~cf?TM6`U-YVWs8ZD>&lSm?^|6I^l9dbwRYCOU#TAKwmdk1F3Wsgj!3+ zECtV@1+g2n~;T-t}Czo6m1 zB8T31F9Ex~jUegwkO`4xvSCJ}(h^tF7q4&5uWvn;Y-m42=OPn=A%WzHYPg)HbbQk7 z*6EL|ozr3+)*MQPhPI$g?;ER94L?*BmoZ34uVD}~3}{rjhrL1&Yr1|N*W_uio?B@w zOU!d`V&Hj77tS@Y74O)-V*oMsDC=PQFOl#R)kp~x#pzC51vld&PihagAWASP-*a3j zCRip+U(4-2=bpk*le-mI2j$c8Zm&B7Lf|r}o$|Yz)D+R|y-9LbkSTqr7 zEX{9^k7*TuO|d20YWQ%xLnr-HrjZ{U=|_Ha{5cpNZ@KaDhcAb)0Go_^$`?ljPqxZN zC*xo5T%M#W)WjI5*WN26nc8yZ;h0M;1+_0%1Gu2TOO(!qQ z<|LWAWnCci6TFB0HDQpK&SUx>`a|68fOakSMoD~w>EP5;H7t(95}msB$;F?zv7A@e zE?ARxl%Ug7`tUKnOdwHVQbe1gxj=2<@*Jmb zyQfc&>EY1_$9^+{sRII?Q7cU{?J;5qzGV<1ryx?a+fC`yr@^MdtKIIy;4Ps1)8Nc!qOvjn2Fy5W!2iWY0VY@2huTdI!ySnL3^4c z0@C8txEG3fFBEQU2c7qIFzIsn= z9O|6``QGq%4RuDtelI%qmwPh@{up{^Zy<^%a_ox(J5kAOOUZQRAfnJDjDMkk!&43> zUdQHv@6j;?*f*P}p{t;UbAWYG%Wg>56@K@fTi+FZoG;eGB1#Bii}&I?tWkaBu(}v{ zD8jDho)@2fX!QTICy9HAKBsS`LU#JjDbOhtmug1Eb;ZXzx<0>J-kzh1;p06y6b`Je zO~Fwmv?)njb6HK1Qz!?{k4246HB=a`VCxBrOjWq{wewtI#pyn)E?;+6wb>R0?w#XC z+Ta|~HGcWigYOC-zlm2D`QtAx+n)(qjApE39pumomNeFT6d~5PMcqngPD9m2F$6Mo zBcD&!S2_iIu*i3UKQ*Wp$nv*=@ru2DoeuI(U2dMJ2RzNgn2J1402y}Kqt^Xk;6{2g z0FV!TQ&~rQ&Ts+tHJB)Y0X+B~Eal&^2?jq56 zGoSITfO|-%AYW}69$MGpg-h?T{s)>jZvNtr0c`_goKyRd*qmKgs?d`9Ti|(k#?6Y4 z(c?ZC7yC*7m>>8tX)m1Ni%4jHxD`uB2#-!*J&1u7?sHf#ZKIL>Ij|oVqff)90@&um uqnoR9yIbGHqFvbHgvclF_;s=~FoVx>+%5S-Mo3^##=f%glIPsx!T$kom)*_) diff --git a/packages/sync/src/orchestrator.ts b/packages/sync/src/orchestrator.ts deleted file mode 100644 index 24d6cc4d..00000000 --- a/packages/sync/src/orchestrator.ts +++ /dev/null @@ -1,61 +0,0 @@ -// DiscoveryOrchestrator: ties a DiscoveryService to the SyncEngine. Advertises -// this device, browses for peers, and on finding one either auto-reconnects (if -// already paired, using the stored secret) or surfaces it for the UI to pair. - -import type { DeviceInfo, DiscoveredDevice } from './types'; -import type { DiscoveryService } from './discovery'; - -/** The slice of SyncEngine the orchestrator drives. */ -export interface ReconnectingEngine { - isPaired(deviceId: string): boolean; - reconnect(device: DeviceInfo, sharedSecret: string): Promise; -} - -export interface DiscoveryOrchestratorOptions { - engine: ReconnectingEngine; - discovery: DiscoveryService; - localDevice: DeviceInfo; - /** Stored shared secret for a device, or undefined if not yet paired. */ - getSharedSecret: (deviceId: string) => string | undefined; - /** A discovered device we have no secret for - surface it so the UI can pair. */ - onDiscovered?: (device: DiscoveredDevice) => void; - /** A previously discovered device went away. */ - onLost?: (deviceId: string) => void; -} - -export class DiscoveryOrchestrator { - private connecting = new Set(); - - constructor(private readonly opts: DiscoveryOrchestratorOptions) {} - - async start(): Promise { - this.opts.discovery.onDeviceFound((d) => this.handleFound(d)); - this.opts.discovery.onDeviceLost((id) => { - this.connecting.delete(id); - this.opts.onLost?.(id); - }); - await this.opts.discovery.start(); - await this.opts.discovery.advertise(this.opts.localDevice); - } - - async stop(): Promise { - await this.opts.discovery.stop(); - } - - private handleFound(device: DiscoveredDevice): void { - if (device.id === this.opts.localDevice.id) return; // ignore self - if (this.opts.engine.isPaired(device.id)) return; // already connected - if (this.connecting.has(device.id)) return; // in-flight - - const secret = this.opts.getSharedSecret(device.id); - if (secret) { - this.connecting.add(device.id); - this.opts.engine - .reconnect(device, secret) - .catch(() => undefined) - .finally(() => this.connecting.delete(device.id)); - } else { - this.opts.onDiscovered?.(device); - } - } -} diff --git a/packages/sync/src/pairing/index.ts b/packages/sync/src/pairing/index.ts deleted file mode 100644 index 8c0cd600..00000000 --- a/packages/sync/src/pairing/index.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { - DeviceInfo, - PairedDevice, - PairingStatus, - Message, - PairRequestMessage, - PairChallengeMessage, - PairResponseMessage, - PairConfirmMessage, - PairRejectMessage, -} from '../types'; -import { - deriveSharedSecret, - generateChallenge, - createChallengeResponse, - verifyChallengeResponse, - generateMessageId, -} from '../crypto'; - -/** - * Pairing state machine for managing the pairing handshake - */ -export interface PairingState { - status: PairingStatus; - localDevice: DeviceInfo; - remoteDevice?: DeviceInfo; - passphrase?: string; - sharedSecret?: string; - challenge?: string; - error?: string; -} - -/** - * Create initial pairing state - */ -export function createPairingState(localDevice: DeviceInfo): PairingState { - return { - status: 'idle', - localDevice, - }; -} - -/** - * Create a pair request message - */ -export function createPairRequest(localDevice: DeviceInfo): PairRequestMessage { - return { - type: 'pair_request', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - deviceInfo: localDevice, - }, - }; -} - -/** - * Create a pair challenge message - */ -export function createPairChallenge(): PairChallengeMessage { - const challenge = generateChallenge(); - return { - type: 'pair_challenge', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - challenge, - timestamp: Date.now(), - }, - }; -} - -/** - * Create a pair response message - */ -export function createPairResponse( - challenge: string, - sharedSecret: string, - localDevice: DeviceInfo -): PairResponseMessage { - const response = createChallengeResponse(challenge, sharedSecret); - return { - type: 'pair_response', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - response, - deviceInfo: localDevice, - }, - }; -} - -/** - * Create a pair confirm message - */ -export function createPairConfirm(localDevice: DeviceInfo): PairConfirmMessage { - return { - type: 'pair_confirm', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - deviceInfo: localDevice, - }, - }; -} - -/** - * Create a pair reject message - */ -export function createPairReject(reason: string): PairRejectMessage { - return { - type: 'pair_reject', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - reason, - }, - }; -} - -/** - * Handle pairing state transitions - */ -export function handlePairingMessage( - state: PairingState, - message: Message, - passphrase?: string -): { newState: PairingState; response?: Message } { - switch (message.type) { - case 'pair_request': { - const msg = message as PairRequestMessage; - const remoteDevice = msg.payload.deviceInfo; - - if (!passphrase) { - // Waiting for user to enter passphrase - return { - newState: { - ...state, - status: 'waiting', - remoteDevice, - }, - }; - } - - // Generate shared secret and challenge - const sharedSecret = deriveSharedSecret( - passphrase, - state.localDevice.id, - remoteDevice.id - ); - const challengeMsg = createPairChallenge(); - - return { - newState: { - ...state, - status: 'verifying', - remoteDevice, - passphrase, - sharedSecret, - challenge: challengeMsg.payload.challenge, - }, - response: challengeMsg, - }; - } - - case 'pair_challenge': { - const msg = message as PairChallengeMessage; - - if (!passphrase || !state.remoteDevice) { - return { - newState: { - ...state, - status: 'failed', - error: 'Missing passphrase or remote device', - }, - }; - } - - const sharedSecret = deriveSharedSecret( - passphrase, - state.localDevice.id, - state.remoteDevice.id - ); - const responseMsg = createPairResponse( - msg.payload.challenge, - sharedSecret, - state.localDevice - ); - - return { - newState: { - ...state, - status: 'verifying', - sharedSecret, - }, - response: responseMsg, - }; - } - - case 'pair_response': { - const msg = message as PairResponseMessage; - - if (!state.sharedSecret || !state.challenge) { - return { - newState: { - ...state, - status: 'failed', - error: 'Invalid pairing state', - }, - }; - } - - const isValid = verifyChallengeResponse( - state.challenge, - msg.payload.response, - state.sharedSecret - ); - - if (isValid) { - const confirmMsg = createPairConfirm(state.localDevice); - return { - newState: { - ...state, - status: 'success', - remoteDevice: msg.payload.deviceInfo, - }, - response: confirmMsg, - }; - } else { - const rejectMsg = createPairReject('Passphrase mismatch'); - return { - newState: { - ...state, - status: 'failed', - error: 'Passphrase mismatch', - }, - response: rejectMsg, - }; - } - } - - case 'pair_confirm': { - return { - newState: { - ...state, - status: 'success', - }, - }; - } - - case 'pair_reject': { - const msg = message as PairRejectMessage; - return { - newState: { - ...state, - status: 'failed', - error: msg.payload.reason, - }, - }; - } - - default: - return { newState: state }; - } -} - -/** - * Create a PairedDevice from successful pairing - */ -export function createPairedDevice(state: PairingState): PairedDevice | null { - if (state.status !== 'success' || !state.remoteDevice || !state.sharedSecret) { - return null; - } - - return { - ...state.remoteDevice, - sharedSecret: state.sharedSecret, - pairedAt: Date.now(), - }; -} - -/** - * Check if a device is already paired - */ -export function isPaired(deviceId: string, pairedDevices: PairedDevice[]): boolean { - return pairedDevices.some((d) => d.id === deviceId); -} - -/** - * Get a paired device by ID - */ -export function getPairedDevice( - deviceId: string, - pairedDevices: PairedDevice[] -): PairedDevice | undefined { - return pairedDevices.find((d) => d.id === deviceId); -} - -/** - * Update last connected time for a paired device - */ -export function updateLastConnected( - deviceId: string, - pairedDevices: PairedDevice[] -): PairedDevice[] { - return pairedDevices.map((d) => - d.id === deviceId ? { ...d, lastConnected: Date.now() } : d - ); -} - -/** - * Remove a paired device - */ -export function removePairedDevice( - deviceId: string, - pairedDevices: PairedDevice[] -): PairedDevice[] { - return pairedDevices.filter((d) => d.id !== deviceId); -} diff --git a/packages/sync/src/portable/bundle.ts b/packages/sync/src/portable/bundle.ts deleted file mode 100644 index 7005aa00..00000000 --- a/packages/sync/src/portable/bundle.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { BUNDLE_FORMAT, BUNDLE_VERSION, BundleError } from './types'; -import type { PortableBundle } from './types'; - -export interface CreateBundleInput { - data: T; - /** ISO timestamp — the caller supplies the clock; this code takes none. */ - exportedAt: string; - /** Defaults to the current BUNDLE_VERSION. */ - version?: number; -} - -/** Assemble a versioned bundle around an app payload. Pure. */ -export function createBundle(input: CreateBundleInput): PortableBundle { - return { - format: BUNDLE_FORMAT, - version: input.version ?? BUNDLE_VERSION, - exportedAt: input.exportedAt, - data: input.data, - }; -} - -/** Serialize a bundle to the JSON text written to a file / sent over the wire. */ -export function serializeBundle(bundle: PortableBundle): string { - return JSON.stringify(bundle, null, 2); -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export interface ParseBundleOptions { - /** Expected envelope version; a mismatch throws a user-facing BundleError. */ - expectedVersion?: number; - /** - * App payload validator. Receives the raw `data` and returns the typed - * payload (or throws a BundleError with a user-facing message). The shared - * core validates only the envelope; payload shape is the app's business. - */ - validateData: (data: unknown) => T; -} - -/** - * Parse + validate bundle JSON. Checks the format discriminator and version — - * the shared envelope contract — then hands the payload to the app-supplied - * validator. Throws BundleError with a user-facing message on any problem. - */ -export function parseBundle(raw: string, opts: ParseBundleOptions): PortableBundle { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new BundleError('This file is not a valid backup (could not read it as JSON).'); - } - if (!isObject(parsed)) { - throw new BundleError('This file is not a valid Off Grid backup.'); - } - if (parsed.format !== BUNDLE_FORMAT) { - throw new BundleError('This file is not an Off Grid backup.'); - } - const expected = opts.expectedVersion ?? BUNDLE_VERSION; - if (parsed.version !== expected) { - throw new BundleError( - `This backup was made by a different app version (backup v${String(parsed.version)}, expected v${expected}).`, - ); - } - const data = opts.validateData((parsed as { data: unknown }).data); - return { - format: BUNDLE_FORMAT, - version: expected, - exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : '', - data, - }; -} diff --git a/packages/sync/src/portable/engine.ts b/packages/sync/src/portable/engine.ts deleted file mode 100644 index 4c0c48a0..00000000 --- a/packages/sync/src/portable/engine.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { createBundle, serializeBundle, parseBundle } from './bundle'; - -// The shared export / import ENGINE. It owns the flow every Off Grid app needs — -// collect the app's data, move the files it points at into a zip alongside a -// backup.json envelope, hand the zip to a sink; and on restore, unpack the zip, -// copy the files back onto the device, rewrite the payload's paths, and apply it -// additively. Zero platform code: store/DB access, file/zip I/O, path rewriting, -// and the clock are all injected through the ports below, so the flow (and its -// correctness) is written and tested once here and inherited by mobile + desktop. - -/** A file the payload points at, paired with the bundle-relative key it travels under. */ -export interface FileRef { - /** Path INSIDE the bundle, e.g. "files/img-0.png". */ - key: string; - /** On-device absolute path/uri to read from on export (or read back on import). */ - sourcePath: string; -} - -/** - * Pure mapping between a payload's on-device file paths and bundle-relative keys. - * The app implements this because only it knows which fields carry file paths; - * it stays pure (no I/O) so it is unit-testable. `extract` (export) lists the - * files and returns a copy of the payload with paths replaced by keys; `listKeys` - * reads the keys back out of a keyed payload (import); `restore` swaps keys for - * the real restored paths. - */ -export interface FileMapper { - extract(data: T): { files: FileRef[]; keyed: T }; - listKeys(keyed: T): string[]; - restore(keyed: T, keyToPath: Record): T; -} - -/** - * Host filesystem + archive I/O. All the platform-specific, on-device work of - * assembling a zip and reading it back. Absolute paths throughout. - */ -export interface ArchivePort { - /** A fresh empty directory to assemble a bundle in. */ - stageDir(): Promise; - writeText(absPath: string, text: string): Promise; - readText(absPath: string): Promise; - /** Copy a source file to an absolute dest path, creating parent dirs. */ - copyInto(srcPath: string, destAbsPath: string): Promise; - /** Zip the CONTENTS of stageDir into an archive; return its path. */ - pack(stageDir: string, suggestedName: string): Promise; - /** Unzip an archive into a fresh dir; return that dir. */ - unpack(archivePath: string): Promise; - /** The permanent on-device path a restored file with this key should live at. */ - restorePathFor(key: string): string; - join(...parts: string[]): string; -} - -/** - * Host access to the app's data. Every store / SQLite read and every additive - * write lives behind this port. `T` = the app's payload shape; `S` = its restore - * summary. - */ -export interface BackupDataPort { - collectAll(): Promise; - collectProject(projectId: string): Promise; - collectConversation(conversationId: string): Promise; - validate(data: unknown): T; - apply(data: T): Promise; -} - -/** Host sink: how the finished bundle FILE leaves the device and how one is picked back. */ -export interface BackupSink { - /** Hand a finished bundle file (already written at absPath) to the user. */ - deliverFile(absPath: string, suggestedName: string): Promise; - /** Pick a bundle file; return a readable local path to it, or null if cancelled. */ - pickFile(): Promise; -} - -/** Turn an ISO timestamp into a filename-safe stamp. Pure. */ -export const fileStamp = (iso: string): string => iso.replaceAll(/[:.]/g, '-'); - -/** The name of the envelope entry inside every bundle zip. */ -export const ENVELOPE_ENTRY = 'backup.json'; - -/** - * The engine. Constructed with the four ports + an injected clock (`now`) so the - * core stays free of `Date`. Export assembles a zip (envelope + files) and - * delivers it; import unpacks a zip, restores files, and applies additively. - */ -export class BackupEngine { - constructor( - private readonly data: BackupDataPort, - private readonly files: FileMapper, - private readonly archive: ArchivePort, - private readonly sink: BackupSink, - private readonly now: () => string, - ) {} - - private async exportBundle(prefix: string, payload: T | null): Promise { - if (payload == null) return null; - const { files: refs, keyed } = this.files.extract(payload); - const exportedAt = this.now(); - const stage = await this.archive.stageDir(); - await this.archive.writeText( - this.archive.join(stage, ENVELOPE_ENTRY), - serializeBundle(createBundle({ data: keyed, exportedAt })), - ); - for (const ref of refs) { - await this.archive.copyInto(ref.sourcePath, this.archive.join(stage, ref.key)); - } - const name = `${prefix}-${fileStamp(exportedAt)}.zip`; - const zipPath = await this.archive.pack(stage, name); - return this.sink.deliverFile(zipPath, name); - } - - /** Export everything. */ - exportAll = (): Promise => - this.data.collectAll().then((d) => this.exportBundle('offgrid-backup', d)); - - /** Export one project (its chats + knowledge base). Null if the project is gone. */ - exportProject = (projectId: string): Promise => - this.data.collectProject(projectId).then((d) => this.exportBundle('offgrid-project', d)); - - /** Export one conversation, self-contained. Null if the conversation is gone. */ - exportConversation = (conversationId: string): Promise => - this.data.collectConversation(conversationId).then((d) => this.exportBundle('offgrid-chat', d)); - - /** Pick a bundle, restore its files, and apply it additively. Null if cancelled. */ - async import(): Promise { - const picked = await this.sink.pickFile(); - if (picked == null) return null; - return this.importPath(picked); - } - - /** - * Restore + apply a bundle at a known local path, WITHOUT the picker. This is - * the receiver side of device-to-device sharing: a peer pushes a bundle file, - * the transport saves it locally, and this applies it — the same unpack → - * restore-files → rewrite → apply flow `import()` uses after picking. - */ - async importPath(archivePath: string): Promise { - const dir = await this.archive.unpack(archivePath); - const raw = await this.archive.readText(this.archive.join(dir, ENVELOPE_ENTRY)); - const bundle = parseBundle(raw, { validateData: (d) => this.data.validate(d) }); - const keyed = bundle.data; - - // Copy every bundled file back onto the device, then rewrite the payload's - // keys to the real restored paths so apply() writes valid on-device paths. - const keyToPath: Record = {}; - for (const key of this.files.listKeys(keyed)) { - const dest = this.archive.restorePathFor(key); - await this.archive.copyInto(this.archive.join(dir, key), dest); - keyToPath[key] = dest; - } - const restored = this.files.restore(keyed, keyToPath); - return this.data.apply(restored); - } -} diff --git a/packages/sync/src/portable/index.ts b/packages/sync/src/portable/index.ts deleted file mode 100644 index 33853204..00000000 --- a/packages/sync/src/portable/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// @offgrid/sync/portable — the portable-bundle foundation: a versioned, -// app-agnostic envelope, the additive-merge import rule, and (de)serialization. -// Pure logic, zero I/O. Consumed by Off Grid Mobile and Desktop; file I/O and -// compression are the host app's job (injected adapters), never this module's. - -export * from './types'; -export * from './merge'; -export * from './bundle'; -export * from './engine'; diff --git a/packages/sync/src/portable/merge.ts b/packages/sync/src/portable/merge.ts deleted file mode 100644 index 39c8b5b3..00000000 --- a/packages/sync/src/portable/merge.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { HasId, MergeResult } from './types'; - -/** - * Additive, non-destructive merge by id — the single import rule for every - * record type. Incoming items whose id is not already present (and not - * duplicated within the incoming batch itself) are appended; existing ids are - * left untouched. It NEVER deletes or overwrites, so importing a backup can - * only ever add what is missing. Defined once here and reused by every store's - * import path so the semantics can never drift between record types. - */ -export function mergeById(existing: T[], incoming: T[]): MergeResult { - const existingIds = new Set(existing.map((item) => item.id)); - const additions: T[] = []; - const addedIds: string[] = []; - const seenIncoming = new Set(); - for (const item of incoming) { - if (existingIds.has(item.id) || seenIncoming.has(item.id)) continue; - seenIncoming.add(item.id); - additions.push(item); - addedIds.push(item.id); - } - return { merged: [...existing, ...additions], addedIds }; -} diff --git a/packages/sync/src/portable/types.ts b/packages/sync/src/portable/types.ts deleted file mode 100644 index 6f008bb3..00000000 --- a/packages/sync/src/portable/types.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Portable bundle — the versioned, app-agnostic envelope + merge contract that -// backs export / import today and, later, device-to-device transfer of the SAME -// bundle over this package's transport (export -> transfer -> import). -// -// The envelope machinery lives here so Off Grid Desktop and Off Grid Mobile -// share one on-disk/on-wire format and can recognize each other's bundles. -// Each app's *payload* differs (their Project / Conversation shapes are not the -// same, and one may carry workspaces the other lacks), so a bundle is GENERIC -// over its `data`. This package owns format + version + merge + (de)serialize; -// the app owns its payload section types and their validation. - -/** Stable format discriminator. A file whose `format` differs is rejected. */ -export const BUNDLE_FORMAT = 'offgrid-backup' as const; - -/** Envelope version. Bump when the envelope shape changes incompatibly. */ -export const BUNDLE_VERSION = 1 as const; - -/** Anything mergeable by stable id (projects, conversations, images, ...). */ -export interface HasId { - id: string; -} - -export interface MergeResult { - /** existing followed by the newly-added items, in incoming order. */ - merged: T[]; - /** ids that were actually added (not already present). */ - addedIds: string[]; -} - -/** - * A portable bundle: a stable header plus an app-defined `data` payload. - * `T` is the app's payload shape. The header is identical across apps so a - * bundle produced by one surface is recognizable by another. - */ -export interface PortableBundle { - format: typeof BUNDLE_FORMAT; - version: number; - /** ISO timestamp of export. */ - exportedAt: string; - data: T; -} - -/** Thrown when a file is not a valid/compatible bundle. Message is user-facing. */ -export class BundleError extends Error { - constructor(message: string) { - super(message); - this.name = 'BundleError'; - } -} diff --git a/packages/sync/src/protocol/index.ts b/packages/sync/src/protocol/index.ts deleted file mode 100644 index c00a62e3..00000000 --- a/packages/sync/src/protocol/index.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { Message } from '../types'; -import { encrypt, decrypt, encodeBase64, decodeBase64 } from '../crypto'; - -// Protocol version for compatibility checking -export const PROTOCOL_VERSION = '1.0.0'; - -// Message header format: [length (4 bytes)] [type (1 byte)] [payload] -export const HEADER_LENGTH = 5; -export const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB max message - -// Message type byte codes -export const MESSAGE_TYPE_CODES: Record = { - ping: 0x01, - pong: 0x02, - pair_request: 0x10, - pair_challenge: 0x11, - pair_response: 0x12, - pair_confirm: 0x13, - pair_reject: 0x14, - hello: 0x15, - text: 0x20, - file_request: 0x30, - file_accept: 0x31, - file_reject: 0x32, - file_chunk: 0x33, - file_complete: 0x34, - file_ack: 0x35, - app: 0x40, - error: 0xff, -}; - -/** Build a reconnect hello identifying the local device. */ -export function createHello(deviceInfo: unknown): Message { - return { - type: 'hello', - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { deviceInfo }, - }; -} - -/** Build a generic encrypted application message for a named channel. */ -export function createAppMessage(channel: string, data: unknown): Message { - return { - type: 'app', - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { channel, data }, - }; -} - -// Reverse lookup -export const MESSAGE_CODE_TYPES: Record = Object.fromEntries( - Object.entries(MESSAGE_TYPE_CODES).map(([k, v]) => [v, k]) -); - -/** - * Serialize a message to a buffer for transmission - */ -export function serializeMessage(message: Message): Uint8Array { - const jsonPayload = JSON.stringify(message); - const payloadBytes = new TextEncoder().encode(jsonPayload); - const typeCode = MESSAGE_TYPE_CODES[message.type] || 0xff; - - // Create buffer: 4 bytes length + 1 byte type + payload - const buffer = new Uint8Array(HEADER_LENGTH + payloadBytes.length); - const view = new DataView(buffer.buffer); - - // Write length (big-endian) - view.setUint32(0, payloadBytes.length, false); - - // Write type code - buffer[4] = typeCode; - - // Write payload - buffer.set(payloadBytes, HEADER_LENGTH); - - return buffer; -} - -/** - * Deserialize a message from a buffer - */ -export function deserializeMessage(buffer: Uint8Array): Message | null { - if (buffer.length < HEADER_LENGTH) { - return null; - } - - const view = new DataView(buffer.buffer, buffer.byteOffset); - const payloadLength = view.getUint32(0, false); - - if (buffer.length < HEADER_LENGTH + payloadLength) { - return null; - } - - const payloadBytes = buffer.slice(HEADER_LENGTH, HEADER_LENGTH + payloadLength); - const jsonPayload = new TextDecoder().decode(payloadBytes); - - try { - return JSON.parse(jsonPayload) as Message; - } catch { - return null; - } -} - -/** - * Get the expected message length from a header - */ -export function getMessageLength(header: Uint8Array): number | null { - if (header.length < 4) { - return null; - } - - const view = new DataView(header.buffer, header.byteOffset); - const length = view.getUint32(0, false); - - if (length > MAX_MESSAGE_SIZE) { - return null; // Message too large - } - - return HEADER_LENGTH + length; -} - -/** - * Encrypt a message for transmission over an established connection - */ -export function encryptMessage( - message: Message, - secretKey: string -): { encrypted: Uint8Array; nonce: string } { - const serialized = serializeMessage(message); - const { encrypted, nonce } = encrypt(serialized, secretKey); - return { - encrypted: decodeBase64(encrypted), - nonce, - }; -} - -/** - * Decrypt a received encrypted message - */ -export function decryptMessage( - encrypted: Uint8Array, - nonce: string, - secretKey: string -): Message | null { - const decrypted = decrypt(encodeBase64(encrypted), nonce, secretKey); - if (!decrypted) return null; - return deserializeMessage(decrypted); -} - -/** - * Message frame for encrypted transmission - * Format: [nonce length (1 byte)] [nonce] [encrypted data] - */ -export function createEncryptedFrame(encrypted: Uint8Array, nonce: string): Uint8Array { - const nonceBytes = decodeBase64(nonce); - const frame = new Uint8Array(1 + nonceBytes.length + encrypted.length); - - frame[0] = nonceBytes.length; - frame.set(nonceBytes, 1); - frame.set(encrypted, 1 + nonceBytes.length); - - return frame; -} - -/** - * Parse an encrypted frame - */ -export function parseEncryptedFrame( - frame: Uint8Array -): { encrypted: Uint8Array; nonce: string } | null { - if (frame.length < 2) return null; - - const nonceLength = frame[0]; - if (frame.length < 1 + nonceLength) return null; - - const nonceBytes = frame.slice(1, 1 + nonceLength); - const encrypted = frame.slice(1 + nonceLength); - - return { - encrypted, - nonce: encodeBase64(nonceBytes), - }; -} - -/** - * Buffer for accumulating incoming data and extracting complete messages - */ -export class MessageBuffer { - private buffer: Uint8Array = new Uint8Array(0); - - /** - * Add data to the buffer - */ - append(data: Uint8Array): void { - const newBuffer = new Uint8Array(this.buffer.length + data.length); - newBuffer.set(this.buffer); - newBuffer.set(data, this.buffer.length); - this.buffer = newBuffer; - } - - /** - * Try to extract a complete message from the buffer - */ - extractMessage(): Message | null { - const length = getMessageLength(this.buffer); - if (length === null || this.buffer.length < length) { - return null; - } - - const messageBytes = this.buffer.slice(0, length); - this.buffer = this.buffer.slice(length); - - return deserializeMessage(messageBytes); - } - - /** - * Extract all complete messages from the buffer - */ - extractAllMessages(): Message[] { - const messages: Message[] = []; - let message: Message | null; - - while ((message = this.extractMessage()) !== null) { - messages.push(message); - } - - return messages; - } - - /** - * Get current buffer size - */ - get size(): number { - return this.buffer.length; - } - - /** - * Clear the buffer - */ - clear(): void { - this.buffer = new Uint8Array(0); - } -} - -/** - * Create a ping message - */ -export function createPingMessage(): Message { - return { - type: 'ping', - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - }; -} - -/** - * Create a pong message in response to a ping - */ -export function createPongMessage(pingId: string): Message { - return { - type: 'pong', - id: pingId, - timestamp: Date.now(), - }; -} - -/** - * Create an error message - */ -export function createErrorMessage( - code: string, - errorMessage: string, - originalMessageId?: string -): Message { - return { - type: 'error', - id: Math.random().toString(36).substring(2, 10), - timestamp: Date.now(), - payload: { - code, - message: errorMessage, - originalMessageId, - }, - }; -} diff --git a/packages/sync/src/state-sync.ts b/packages/sync/src/state-sync.ts deleted file mode 100644 index b288a232..00000000 --- a/packages/sync/src/state-sync.ts +++ /dev/null @@ -1,42 +0,0 @@ -// State replication protocol over the @offgrid/sync 'state' app channel. -// PURE / platform-agnostic (same portability rationale as oplog.ts). -// -// Gossip is minimal and convergent: -// • on connect, each peer sends `have` (its version vector) -// • on receiving `have`, reply with `ops` the peer is missing -// • on receiving `ops`, ingest them (and the materializer updates real tables) -// • on a local change, broadcast `ops:[op]` to connected peers -// One round-trip each direction reconciles two devices; live ops stream after. - -import type { Op, VersionVector } from './oplog'; -import type { OpLog } from './oplog'; - -export type StateMsg = { t: 'have'; vv: VersionVector } | { t: 'ops'; ops: Op[] }; - -export interface StateSyncOptions { - oplog: OpLog; - /** Send a state message to one peer (host wires → sendApp(id,'state',msg)). */ - send: (deviceId: string, msg: StateMsg) => void; -} - -export class StateSync { - constructor(private readonly opts: StateSyncOptions) {} - - /** A peer connected: advertise our version vector so it can backfill us; we - * backfill it when its own `have` arrives. */ - onConnect(deviceId: string): void { - this.opts.send(deviceId, { t: 'have', vv: this.opts.oplog.versionVector() }); - } - - /** Inbound message on the 'state' channel from a paired peer. */ - onMessage(deviceId: string, data: unknown): void { - const msg = data as StateMsg | undefined; - if (!msg || typeof msg !== 'object' || !('t' in msg)) return; - if (msg.t === 'have') { - const missing = this.opts.oplog.opsSince(msg.vv); - if (missing.length) this.opts.send(deviceId, { t: 'ops', ops: missing }); - } else if (msg.t === 'ops' && Array.isArray(msg.ops)) { - this.opts.oplog.ingest(msg.ops); - } - } -} diff --git a/packages/sync/src/transfer/index.ts b/packages/sync/src/transfer/index.ts deleted file mode 100644 index 7f8e7b36..00000000 --- a/packages/sync/src/transfer/index.ts +++ /dev/null @@ -1,512 +0,0 @@ -import type { - TextTransfer, - FileTransfer, - TransferProgress, - TextMessage, - FileRequestMessage, - FileAcceptMessage, - FileRejectMessage, - FileChunkMessage, - FileCompleteMessage, - FileAckMessage, - DeviceInfo, -} from '../types'; -import { - generateMessageId, - encrypt, - decrypt, - calculateChecksum, - verifyChecksum, - encodeBase64, - decodeBase64, -} from '../crypto'; - -// Constants -export const CHUNK_SIZE = 64 * 1024; // 64KB chunks -export const MAX_TEXT_LENGTH = 1024 * 1024; // 1MB max text - -/** - * Create a text transfer record - */ -export function createTextTransfer( - content: string, - device: DeviceInfo, - direction: 'send' | 'receive' -): TextTransfer { - return { - id: generateMessageId(), - type: 'text', - timestamp: Date.now(), - direction, - deviceId: device.id, - deviceName: device.name, - content, - }; -} - -/** - * Create a file transfer record - */ -export function createFileTransfer( - fileName: string, - fileSize: number, - mimeType: string, - device: DeviceInfo, - direction: 'send' | 'receive', - durationMs?: number -): FileTransfer { - const transfer: FileTransfer = { - id: generateMessageId(), - type: 'file', - timestamp: Date.now(), - direction, - deviceId: device.id, - deviceName: device.name, - fileName, - fileSize, - mimeType, - }; - if (durationMs != null && durationMs > 0) { - transfer.durationMs = durationMs; - transfer.speedBytesPerSec = Math.round((fileSize / durationMs) * 1000); - } - return transfer; -} - -/** - * Create a text message - */ -export function createTextMessage(content: string): TextMessage { - return { - type: 'text', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - content, - }, - }; -} - -/** - * Create an encrypted text message - */ -export function createEncryptedTextMessage( - content: string, - secretKey: string -): { message: TextMessage; nonce: string } { - const { encrypted, nonce } = encrypt(content, secretKey); - return { - message: createTextMessage(encrypted), - nonce, - }; -} - -/** - * Decrypt a text message - */ -export function decryptTextMessage( - message: TextMessage, - nonce: string, - secretKey: string -): string | null { - const decrypted = decrypt(message.payload.content, nonce, secretKey); - if (!decrypted) return null; - return new TextDecoder().decode(decrypted); -} - -/** - * Create a file request message - */ -export function createFileRequest( - fileName: string, - fileSize: number, - mimeType: string, - fileData: Uint8Array -): FileRequestMessage { - return { - type: 'file_request', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum: calculateChecksum(fileData), - }, - }; -} - -/** - * Create a file request message with a pre-computed checksum (for streaming/large files). - * Avoids needing the entire file in memory. - */ -export function createFileRequestStreaming( - fileName: string, - fileSize: number, - mimeType: string, - checksum: string -): FileRequestMessage { - return { - type: 'file_request', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum, - }, - }; -} - -/** - * Create a file complete message with a pre-computed checksum (for streaming/large files). - * Avoids needing the entire file in memory. - */ -export function createFileCompleteStreaming(requestId: string, checksum: string): FileCompleteMessage { - return { - type: 'file_complete', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - checksum, - }, - }; -} - -/** - * Create a file request message with an HTTP download URL (for large files sent via HTTP). - */ -export function createFileRequestHttp( - fileName: string, - fileSize: number, - mimeType: string, - checksum: string, - httpUrl: string -): FileRequestMessage { - return { - type: 'file_request', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - fileName, - fileSize, - mimeType, - checksum, - httpUrl, - }, - }; -} - -/** - * Create a file accept message - */ -export function createFileAccept(requestId: string): FileAcceptMessage { - return { - type: 'file_accept', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - }, - }; -} - -/** - * Create a file accept message with an HTTP upload URL (for receiving large files via HTTP). - */ -export function createFileAcceptHttp(requestId: string, uploadUrl: string): FileAcceptMessage { - return { - type: 'file_accept', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - uploadUrl, - }, - }; -} - -/** - * Create a file ack message (sent after HTTP transfer completes). - */ -export function createFileAck(requestId: string, success: boolean): FileAckMessage { - return { - type: 'file_ack', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - success, - }, - }; -} - -/** - * Create a file reject message - */ -export function createFileReject(requestId: string, reason: string): FileRejectMessage { - return { - type: 'file_reject', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - reason, - }, - }; -} - -/** - * Create a file chunk message - */ -export function createFileChunk( - requestId: string, - chunkIndex: number, - totalChunks: number, - data: Uint8Array -): FileChunkMessage { - return { - type: 'file_chunk', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - chunkIndex, - totalChunks, - data: encodeBase64(data), - }, - }; -} - -/** - * Create a file chunk message from already-base64-encoded data. - * Avoids the decode → re-encode roundtrip when data is read as base64 from disk. - */ -export function createFileChunkFromBase64( - requestId: string, - chunkIndex: number, - totalChunks: number, - base64Data: string -): FileChunkMessage { - return { - type: 'file_chunk', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - chunkIndex, - totalChunks, - data: base64Data, - }, - }; -} - -/** - * Create a file complete message - */ -export function createFileComplete(requestId: string, fileData: Uint8Array): FileCompleteMessage { - return { - type: 'file_complete', - id: generateMessageId(), - timestamp: Date.now(), - payload: { - requestId, - checksum: calculateChecksum(fileData), - }, - }; -} - -/** - * Split file data into chunks - */ -export function* chunkFile( - data: Uint8Array, - chunkSize: number = CHUNK_SIZE -): Generator<{ chunk: Uint8Array; index: number; total: number }> { - const totalChunks = Math.ceil(data.length / chunkSize); - - for (let i = 0; i < totalChunks; i++) { - const start = i * chunkSize; - const end = Math.min(start + chunkSize, data.length); - yield { - chunk: data.slice(start, end), - index: i, - total: totalChunks, - }; - } -} - -/** - * Reassemble chunks into complete file data - */ -export function reassembleChunks( - chunks: Map, - totalChunks: number -): Uint8Array | null { - // Verify all chunks are present - if (chunks.size !== totalChunks) { - return null; - } - - // Calculate total size - let totalSize = 0; - for (let i = 0; i < totalChunks; i++) { - const chunk = chunks.get(i); - if (!chunk) return null; - totalSize += chunk.length; - } - - // Reassemble - const result = new Uint8Array(totalSize); - let offset = 0; - for (let i = 0; i < totalChunks; i++) { - const chunk = chunks.get(i)!; - result.set(chunk, offset); - offset += chunk.length; - } - - return result; -} - -/** - * Calculate transfer progress with optional speed/ETA computation - */ -export function calculateProgress( - transferId: string, - bytesTransferred: number, - totalBytes: number, - currentFile?: string, - startTime?: number -): TransferProgress { - const clampedBytes = Math.min(bytesTransferred, totalBytes); - const result: TransferProgress = { - transferId, - bytesTransferred: clampedBytes, - totalBytes, - percentage: totalBytes > 0 ? Math.min(100, Math.round((clampedBytes / totalBytes) * 100)) : 0, - currentFile, - }; - - if (startTime && startTime > 0) { - const elapsedMs = Date.now() - startTime; - result.elapsedMs = elapsedMs; - if (elapsedMs > 500 && clampedBytes > 0) { - result.speedBytesPerSec = Math.round((clampedBytes / elapsedMs) * 1000); - if (result.speedBytesPerSec > 0 && clampedBytes < totalBytes) { - const remainingBytes = totalBytes - clampedBytes; - result.etaSeconds = Math.round(remainingBytes / result.speedBytesPerSec); - } - } - } - - return result; -} - -/** - * Verify received file integrity - */ -export function verifyFileIntegrity(data: Uint8Array, expectedChecksum: string): boolean { - return verifyChecksum(data, expectedChecksum); -} - -/** - * Format file size for display - */ -export function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; -} - -/** - * Format transfer speed for display - */ -export function formatTransferSpeed(bytesPerSec: number): string { - if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; - if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; - if (bytesPerSec < 1024 * 1024 * 1024) return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`; - return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)} GB/s`; -} - -/** - * Format transfer duration for display - */ -export function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const seconds = ms / 1000; - if (seconds < 60) return `${seconds.toFixed(1)}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds.toFixed(0)}s`; -} - -/** - * Format ETA for display - */ -export function formatEta(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} - -/** - * Format live transfer progress info string (speed · elapsed · ETA) - */ -export function formatProgressInfo(progress: TransferProgress): string { - const parts: string[] = []; - if (progress.speedBytesPerSec != null && progress.speedBytesPerSec > 0) { - parts.push(formatTransferSpeed(progress.speedBytesPerSec)); - } - if (progress.elapsedMs != null && progress.elapsedMs >= 1000) { - parts.push(formatDuration(progress.elapsedMs) + ' elapsed'); - } - if (progress.etaSeconds != null && progress.etaSeconds > 0) { - parts.push('~' + formatEta(progress.etaSeconds) + ' left'); - } - return parts.join(' · '); -} - -/** - * Get MIME type from file extension - */ -export function getMimeType(fileName: string): string { - const ext = fileName.split('.').pop()?.toLowerCase() || ''; - const mimeTypes: Record = { - txt: 'text/plain', - html: 'text/html', - css: 'text/css', - js: 'application/javascript', - json: 'application/json', - xml: 'application/xml', - pdf: 'application/pdf', - zip: 'application/zip', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - svg: 'image/svg+xml', - webp: 'image/webp', - mp3: 'audio/mpeg', - wav: 'audio/wav', - mp4: 'video/mp4', - webm: 'video/webm', - doc: 'application/msword', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - xls: 'application/vnd.ms-excel', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ppt: 'application/vnd.ms-powerpoint', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - }; - - return mimeTypes[ext] || 'application/octet-stream'; -} - -// Re-export for convenience -export { decodeBase64 }; diff --git a/packages/sync/src/transport.ts b/packages/sync/src/transport.ts deleted file mode 100644 index 46cc3b87..00000000 --- a/packages/sync/src/transport.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Transport abstraction for @offgrid/sync. -// -// The engine speaks frames (see wire.ts) over a duplex byte connection. The -// host supplies the actual transport: a Node TCP server/client on desktop, a -// React Native socket module on mobile. Keeping this an interface is what makes -// the sync engine embeddable in both apps without platform code leaking in. - -/** A duplex, ordered, reliable byte stream to one remote peer. */ -export interface SyncConnection { - /** Stable id for this connection (host:port or a socket id). */ - readonly id: string; - /** Remote host/address, when the transport knows it. */ - readonly remoteHost?: string; - send(data: Uint8Array): void; - onData(cb: (data: Uint8Array) => void): void; - onClose(cb: () => void): void; - close(): void; -} - -/** Listens for inbound connections and dials outbound ones. */ -export interface TransportBridge { - /** Start accepting inbound connections on `port`. */ - listen(port: number, onConnection: (conn: SyncConnection) => void): Promise; - /** Dial a remote peer and resolve once the byte stream is open. */ - connect(host: string, port: number): Promise; - /** Stop listening and release resources. */ - stop(): Promise; -} diff --git a/packages/sync/src/types/index.ts b/packages/sync/src/types/index.ts deleted file mode 100644 index 051ff1c5..00000000 --- a/packages/sync/src/types/index.ts +++ /dev/null @@ -1,291 +0,0 @@ -// Device and Discovery Types -export type DevicePlatform = 'macos' | 'windows' | 'linux' | 'android' | 'ios'; - -export interface DeviceInfo { - id: string; - name: string; - platform: DevicePlatform; - version: string; - host: string; - port: number; -} - -export interface DiscoveredDevice extends DeviceInfo { - lastSeen: number; -} - -export interface PairedDevice extends DeviceInfo { - sharedSecret: string; // Base64 encoded - pairedAt: number; - lastConnected?: number; -} - -// Pairing Types -export interface PairingChallenge { - challenge: string; // Base64 encoded random bytes - timestamp: number; -} - -export interface PairingResponse { - response: string; // Base64 encoded HMAC - deviceInfo: DeviceInfo; -} - -export type PairingStatus = 'idle' | 'waiting' | 'verifying' | 'success' | 'failed'; - -// Transfer Types -export type TransferType = 'text' | 'file' | 'files'; - -export interface TransferMetadata { - id: string; - type: TransferType; - timestamp: number; - direction: 'send' | 'receive'; - deviceId: string; - deviceName: string; -} - -export interface TextTransfer extends TransferMetadata { - type: 'text'; - content: string; -} - -export interface FileTransfer extends TransferMetadata { - type: 'file'; - fileName: string; - fileSize: number; - mimeType: string; - filePath?: string; // Local path after receiving - durationMs?: number; // Transfer duration in milliseconds - speedBytesPerSec?: number; // Transfer speed in bytes per second -} - -export interface FilesTransfer extends TransferMetadata { - type: 'files'; - files: Array<{ - fileName: string; - fileSize: number; - mimeType: string; - filePath?: string; - }>; - totalSize: number; -} - -export type Transfer = TextTransfer | FileTransfer | FilesTransfer; - -export interface TransferProgress { - transferId: string; - bytesTransferred: number; - totalBytes: number; - percentage: number; - currentFile?: string; - speedBytesPerSec?: number; - etaSeconds?: number; - elapsedMs?: number; -} - -export interface TransferQueueItem { - id: string; - fileName: string; - fileSize: number; - status: 'pending' | 'transferring' | 'completed' | 'failed'; - progress: number; - direction: 'send' | 'receive'; -} - -// Message Protocol Types -export type MessageType = - | 'ping' - | 'pong' - | 'pair_request' - | 'pair_challenge' - | 'pair_response' - | 'pair_confirm' - | 'pair_reject' - | 'hello' - | 'text' - | 'file_request' - | 'file_accept' - | 'file_reject' - | 'file_chunk' - | 'file_complete' - | 'file_ack' - | 'app' - | 'error'; - -export interface Message { - type: MessageType; - id: string; - timestamp: number; - payload?: unknown; -} - -/** Generic encrypted application message: a channel name + arbitrary payload. - * Lets features (memory sync, clipboard sync, ...) ride the paired channel - * without each needing its own protocol message type. */ -export interface AppMessage extends Message { - type: 'app'; - payload: { - channel: string; - data: unknown; - }; -} - -/** Reconnect greeting: identifies the device so an already-paired peer can - * resume with the stored shared secret, skipping the pairing handshake. */ -export interface HelloMessage extends Message { - type: 'hello'; - payload: { - deviceInfo: DeviceInfo; - }; -} - -export interface PingMessage extends Message { - type: 'ping'; -} - -export interface PongMessage extends Message { - type: 'pong'; -} - -export interface PairRequestMessage extends Message { - type: 'pair_request'; - payload: { - deviceInfo: DeviceInfo; - }; -} - -export interface PairChallengeMessage extends Message { - type: 'pair_challenge'; - payload: PairingChallenge; -} - -export interface PairResponseMessage extends Message { - type: 'pair_response'; - payload: PairingResponse; -} - -export interface PairConfirmMessage extends Message { - type: 'pair_confirm'; - payload: { - deviceInfo: DeviceInfo; - }; -} - -export interface PairRejectMessage extends Message { - type: 'pair_reject'; - payload: { - reason: string; - }; -} - -export interface TextMessage extends Message { - type: 'text'; - payload: { - content: string; - }; -} - -export interface FileRequestMessage extends Message { - type: 'file_request'; - payload: { - fileName: string; - fileSize: number; - mimeType: string; - checksum: string; - httpUrl?: string; - }; -} - -export interface FileAcceptMessage extends Message { - type: 'file_accept'; - payload: { - requestId: string; - uploadUrl?: string; - }; -} - -export interface FileRejectMessage extends Message { - type: 'file_reject'; - payload: { - requestId: string; - reason: string; - }; -} - -export interface FileChunkMessage extends Message { - type: 'file_chunk'; - payload: { - requestId: string; - chunkIndex: number; - totalChunks: number; - data: string; // Base64 encoded - }; -} - -export interface FileCompleteMessage extends Message { - type: 'file_complete'; - payload: { - requestId: string; - checksum: string; - }; -} - -export interface FileAckMessage extends Message { - type: 'file_ack'; - payload: { - requestId: string; - success: boolean; - }; -} - -export interface ErrorMessage extends Message { - type: 'error'; - payload: { - code: string; - message: string; - originalMessageId?: string; - }; -} - -// Connection Types -export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; - -export type PairingStep = - | 'idle' - | 'connecting' - | 'sending_request' - | 'waiting_for_passphrase' - | 'deriving_key' - | 'sending_challenge' - | 'waiting_for_challenge' - | 'responding_to_challenge' - | 'verifying_response' - | 'confirming' - | 'success' - | 'failed'; - -export interface ConnectionState { - status: ConnectionStatus; - device?: DeviceInfo; - error?: string; - /** Verbose status message for UI display */ - statusMessage?: string; - /** Current step in the pairing process */ - pairingStep?: PairingStep; -} - -// Storage Types -export interface AppSettings { - deviceName: string; - deviceId: string; - autoAcceptFromPaired: boolean; - saveDirectory: string; - notificationsEnabled: boolean; -} - -export interface StoredData { - settings: AppSettings; - pairedDevices: PairedDevice[]; - transferHistory: Transfer[]; -} diff --git a/packages/sync/src/wire.ts b/packages/sync/src/wire.ts deleted file mode 100644 index 44e7ff94..00000000 --- a/packages/sync/src/wire.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Wire codec for @offgrid/sync. -// -// One length-prefixed framing carries both the plaintext pairing handshake and -// the encrypted application traffic that follows it on the same stream: -// -// [4-byte big-endian length L][1-byte kind][L-1 byte body] -// kind 0x00 = plaintext : body is UTF-8 JSON of a Message -// kind 0x01 = encrypted : body is [nonceLen:1][nonce][ciphertext], -// ciphertext = encryptMessage(message) (see protocol.ts) -// -// Pairing messages are sent plaintext (no shared secret yet); everything after -// a successful pairing is encrypted with the per-pair shared secret. - -import type { Message } from './types'; -import { encryptMessage, decryptMessage } from './protocol'; -import { encodeBase64, decodeBase64 } from './crypto'; - -export const FRAME_HEADER_LENGTH = 4; -export const MAX_FRAME_SIZE = 16 * 1024 * 1024; // 16MB - -export const FRAME_KIND_PLAINTEXT = 0x00; -export const FRAME_KIND_ENCRYPTED = 0x01; - -function frame(body: Uint8Array): Uint8Array { - const out = new Uint8Array(FRAME_HEADER_LENGTH + body.length); - new DataView(out.buffer).setUint32(0, body.length, false); - out.set(body, FRAME_HEADER_LENGTH); - return out; -} - -/** Encode a plaintext (unencrypted) message frame, used for pairing. */ -export function encodePlaintextFrame(message: Message): Uint8Array { - const json = new TextEncoder().encode(JSON.stringify(message)); - const body = new Uint8Array(1 + json.length); - body[0] = FRAME_KIND_PLAINTEXT; - body.set(json, 1); - return frame(body); -} - -/** Encode an encrypted message frame using the per-pair shared secret. */ -export function encodeEncryptedFrame(message: Message, secretKey: string): Uint8Array { - const { encrypted, nonce } = encryptMessage(message, secretKey); - const nonceBytes = decodeBase64(nonce); - const body = new Uint8Array(1 + 1 + nonceBytes.length + encrypted.length); - body[0] = FRAME_KIND_ENCRYPTED; - body[1] = nonceBytes.length; - body.set(nonceBytes, 2); - body.set(encrypted, 2 + nonceBytes.length); - return frame(body); -} - -export type DecodedFrame = - | { kind: 'plaintext'; message: Message } - | { kind: 'encrypted'; message: Message }; - -/** Decode one frame body. `secretKey` is required to read encrypted frames. */ -export function decodeFrameBody(body: Uint8Array, secretKey?: string): DecodedFrame | null { - if (body.length < 1) return null; - const kind = body[0]; - const payload = body.subarray(1); - - if (kind === FRAME_KIND_PLAINTEXT) { - try { - const message = JSON.parse(new TextDecoder().decode(payload)) as Message; - return { kind: 'plaintext', message }; - } catch { - return null; - } - } - - if (kind === FRAME_KIND_ENCRYPTED) { - if (!secretKey || payload.length < 1) return null; - const nonceLen = payload[0]; - if (payload.length < 1 + nonceLen) return null; - const nonce = encodeBase64(payload.subarray(1, 1 + nonceLen)); - const encrypted = payload.subarray(1 + nonceLen); - const message = decryptMessage(encrypted, nonce, secretKey); - return message ? { kind: 'encrypted', message } : null; - } - - return null; -} - -/** - * Accumulates incoming bytes and yields complete frame bodies. The shared - * secret can be set once pairing succeeds so later encrypted frames decode. - */ -export class FrameBuffer { - private buffer: Uint8Array = new Uint8Array(0); - - append(data: Uint8Array): void { - const next = new Uint8Array(this.buffer.length + data.length); - next.set(this.buffer); - next.set(data, this.buffer.length); - this.buffer = next; - } - - /** Pull the next complete frame body, or null if none is fully buffered. */ - private nextBody(): Uint8Array | null { - if (this.buffer.length < FRAME_HEADER_LENGTH) return null; - const len = new DataView( - this.buffer.buffer, - this.buffer.byteOffset - ).getUint32(0, false); - if (len > MAX_FRAME_SIZE) { - // Corrupt/oversized: drop the buffer to avoid getting stuck. - this.buffer = new Uint8Array(0); - return null; - } - if (this.buffer.length < FRAME_HEADER_LENGTH + len) return null; - const body = this.buffer.slice(FRAME_HEADER_LENGTH, FRAME_HEADER_LENGTH + len); - this.buffer = this.buffer.slice(FRAME_HEADER_LENGTH + len); - return body; - } - - /** Decode all complete frames currently buffered. */ - drain(secretKey?: string): DecodedFrame[] { - const out: DecodedFrame[] = []; - let body: Uint8Array | null; - while ((body = this.nextBody()) !== null) { - const decoded = decodeFrameBody(body, secretKey); - if (decoded) out.push(decoded); - } - return out; - } -} diff --git a/packages/sync/test/cap.test.mjs b/packages/sync/test/cap.test.mjs deleted file mode 100644 index eb7f2abb..00000000 --- a/packages/sync/test/cap.test.mjs +++ /dev/null @@ -1,85 +0,0 @@ -// Phase 1.4: device cap (open-core 2 free / 3+ paid) refuses a new pairing -// past the limit on the accepting side, and a pro entitlement lifts it. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import pkg from '../dist/index.js'; -const { SyncEngine, policyFor, FREE_DEVICE_CAP } = pkg; - -const delay = (ms) => new Promise((r) => setTimeout(r, ms)); - -function makeNetwork() { - const listeners = new Map(); - const makePipe = () => { - const ends = {}; - const mk = (self, peer) => { - let onData = null; - const onClose = []; - return { - _deliver: (d) => onData && onData(d), - _close: () => onClose.forEach((f) => f()), - id: self, - send: (data) => queueMicrotask(() => ends[peer]._deliver(data)), - onData: (cb) => (onData = cb), - onClose: (cb) => onClose.push(cb), - close: () => ends[peer]._close(), - }; - }; - ends.client = mk('client', 'server'); - ends.server = mk('server', 'client'); - return ends; - }; - return { - listen: async (port, onConnection) => listeners.set(port, onConnection), - connect: async (_h, port) => { - const cb = listeners.get(port); - if (!cb) throw new Error('no listener'); - const ends = makePipe(); - cb(ends.server); - return ends.client; - }, - stop: async () => listeners.clear(), - }; -} - -const dev = (id) => ({ id, name: id, platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }); - -test(`free tier (cap ${FREE_DEVICE_CAP}) refuses a 3rd new device; pro lifts it`, async () => { - const transport = makeNetwork(); - - // Host A is at the free cap (already has 2 paired devices). - let failReason; - const hostFree = new SyncEngine({ - localDevice: dev('host'), - transport, - getPassphrase: () => 'pw', - cap: { policy: policyFor(false), pairedCount: () => FREE_DEVICE_CAP, isKnown: () => false }, - onPairingFailed: (_d, reason) => (failReason = reason), - }); - await hostFree.start(9001); - - let cPaired = false; - const devC = new SyncEngine({ localDevice: dev('dev-c'), transport, onPairingFailed: () => {}, onPaired: () => (cPaired = true) }); - await devC.pair(dev('host'), 'pw'); - await delay(50); - - assert.equal(cPaired, false, 'a 3rd new device must be refused at the free cap'); - assert.equal(failReason, 'device_cap_reached'); - await hostFree.stop(); - - // Pro entitlement lifts the cap. - const transport2 = makeNetwork(); - let dPaired = false; - const hostPro = new SyncEngine({ - localDevice: dev('host2'), - transport: transport2, - getPassphrase: () => 'pw', - cap: { policy: policyFor(true), pairedCount: () => 5, isKnown: () => false }, - onPaired: () => {}, - }); - await hostPro.start(9001); - const devD = new SyncEngine({ localDevice: dev('dev-d'), transport: transport2, onPaired: () => (dPaired = true) }); - await devD.pair(dev('host2'), 'pw'); - await delay(50); - assert.equal(dPaired, true, 'pro entitlement allows pairing beyond the free cap'); - await hostPro.stop(); -}); diff --git a/packages/sync/test/discovery.test.mjs b/packages/sync/test/discovery.test.mjs deleted file mode 100644 index 896ee221..00000000 --- a/packages/sync/test/discovery.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -// Real mDNS discovery on the local interface: advertise one device, browse from -// another, confirm it is found with the right TXT data. Needs Local Network -// permission on macOS. node:test has no default timeout, so the 6s wait is fine. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import disc from '../dist/adapters/node-discovery.js'; -const { NodeDiscovery } = disc; - -const delay = (ms) => new Promise((r) => setTimeout(r, ms)); - -test('advertise + browse discovers a device over mDNS', async () => { - const advertiser = new NodeDiscovery(); - const browser = new NodeDiscovery(); - let found; - browser.onDeviceFound((d) => { - if (d.id === 'dev-adv') found = d; - }); - - await browser.start(); - await advertiser.advertise({ - id: 'dev-adv', - name: 'Advertised Mac', - platform: 'macos', - version: '1', - host: '127.0.0.1', - port: 9999, - }); - - // Poll for up to ~6s for the multicast announcement to land. - for (let i = 0; i < 30 && !found; i++) await delay(200); - - await advertiser.stop(); - await browser.stop(); - - assert.ok(found, 'advertised device should be discovered'); - assert.equal(found.id, 'dev-adv'); - assert.equal(found.name, 'Advertised Mac'); - assert.equal(found.port, 9999); - assert.ok(found.lastSeen > 0); -}); diff --git a/packages/sync/test/handshake.test.mjs b/packages/sync/test/handshake.test.mjs deleted file mode 100644 index 158b2b72..00000000 --- a/packages/sync/test/handshake.test.mjs +++ /dev/null @@ -1,101 +0,0 @@ -// Smoke test: two SyncEngines pair over an in-memory transport, then exchange -// an encrypted application message. Verifies the handshake + wire codec end to -// end without sockets. Run: node --test packages/sync/test/ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -// Import the CJS build: bundlers (vite/metro) resolve the ESM build's -// tweetnacl-util named imports, but Node's strict ESM loader does not, so the -// CJS bundle is the reliable target for a direct node --test run. -import pkg from '../dist/index.js'; -const { SyncEngine, createTextMessage } = pkg; - -const delay = (ms) => new Promise((r) => setTimeout(r, ms)); - -// Linked in-memory connection pair: each end's send() delivers to the other's -// onData on a microtask, mimicking an ordered byte stream. -function makePipe() { - const ends = {}; - const mk = (self, peer) => { - let onData = null; - const onClose = []; - return { - _deliver: (d) => onData && onData(d), - _close: () => onClose.forEach((f) => f()), - id: self, - send: (data) => queueMicrotask(() => ends[peer]._deliver(data)), - onData: (cb) => (onData = cb), - onClose: (cb) => onClose.push(cb), - close: () => ends[peer]._close(), - }; - }; - ends.client = mk('client', 'server'); - ends.server = mk('server', 'client'); - return ends; -} - -// Shared in-memory network: listen() registers by port, connect() links a pipe. -function makeNetwork() { - const listeners = new Map(); - return { - listen: async (port, onConnection) => listeners.set(port, onConnection), - connect: async (_host, port) => { - const onConnection = listeners.get(port); - if (!onConnection) throw new Error(`no listener on ${port}`); - const ends = makePipe(); - onConnection(ends.server); - return ends.client; - }, - stop: async () => listeners.clear(), - }; -} - -test('two engines pair and exchange an encrypted message', async () => { - const transport = makeNetwork(); - const devA = { id: 'dev-a', name: 'Mac A', platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }; - const devB = { id: 'dev-b', name: 'Phone B', platform: 'android', version: '1', host: '127.0.0.1', port: 9001 }; - - let aPaired, bPaired, received; - const engineA = new SyncEngine({ - localDevice: devA, - transport, - getPassphrase: () => 'correct horse battery', - onPaired: (d) => (aPaired = d), - onMessage: (id, m) => (received = { id, m }), - }); - const engineB = new SyncEngine({ - localDevice: devB, - transport, - onPaired: (d) => (bPaired = d), - }); - - await engineA.start(9001); - await engineB.pair(devA, 'correct horse battery'); - await delay(50); - - // Both sides completed pairing and agree on the peer identity. - assert.ok(aPaired, 'A should be paired'); - assert.ok(bPaired, 'B should be paired'); - assert.equal(aPaired.id, devB.id); - assert.equal(bPaired.id, devA.id); - // Independently derived shared secrets must match. - assert.equal(aPaired.sharedSecret, bPaired.sharedSecret); - assert.equal(engineA.isPaired(devB.id), true); - assert.equal(engineB.isPaired(devA.id), true); - - // Encrypted application message B -> A. - const sent = engineB.send(devA.id, createTextMessage('hello off grid')); - await delay(20); - assert.equal(sent, true); - assert.ok(received, 'A should receive the message'); - assert.equal(received.id, devB.id); - assert.equal(received.m.type, 'text'); - assert.equal(received.m.payload.content, 'hello off grid'); - - // A wrong passphrase must NOT pair. - let cPaired = false; - const devC = { id: 'dev-c', name: 'Mac C', platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }; - const engineC = new SyncEngine({ localDevice: devC, transport, onPaired: () => (cPaired = true) }); - await engineC.pair(devA, 'wrong passphrase'); - await delay(50); - assert.equal(cPaired, false, 'mismatched passphrase must not pair'); -}); diff --git a/packages/sync/test/node-tcp.test.mjs b/packages/sync/test/node-tcp.test.mjs deleted file mode 100644 index 2288db70..00000000 --- a/packages/sync/test/node-tcp.test.mjs +++ /dev/null @@ -1,52 +0,0 @@ -// Full C1.1 over REAL sockets: two SyncEngines pair across localhost TCP and -// exchange an encrypted message. No GUI/permissions needed, so this runs -// headlessly and verifies the actual NodeTcpTransport, not a mock. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import pkg from '../dist/index.js'; -import nodeAdapter from '../dist/adapters/node-tcp.js'; -const { SyncEngine, createTextMessage } = pkg; -const { NodeTcpTransport } = nodeAdapter; - -const delay = (ms) => new Promise((r) => setTimeout(r, ms)); -const dev = (id, port) => ({ id, name: id, platform: 'macos', version: '1', host: '127.0.0.1', port }); - -test('two engines pair and message over real localhost TCP', async () => { - const transportA = new NodeTcpTransport(); - const transportB = new NodeTcpTransport(); - - let aPaired, received; - const engineA = new SyncEngine({ - localDevice: dev('dev-a', 0), - transport: transportA, - getPassphrase: () => 'localhost-secret', - onPaired: (d) => (aPaired = d), - onMessage: (id, m) => (received = { id, m }), - }); - let bPaired; - const engineB = new SyncEngine({ - localDevice: dev('dev-b', 0), - transport: transportB, - onPaired: (d) => (bPaired = d), - }); - - await engineA.start(0); // ephemeral port - const port = transportA.boundPort; - assert.ok(port, 'server should bind a port'); - - await engineB.pair(dev('dev-a', port), 'localhost-secret'); - await delay(150); - - assert.ok(aPaired && bPaired, 'both sides paired over TCP'); - assert.equal(aPaired.id, 'dev-b'); - assert.equal(bPaired.id, 'dev-a'); - assert.equal(aPaired.sharedSecret, bPaired.sharedSecret); - - const sent = engineB.send('dev-a', createTextMessage('over the wire')); - await delay(80); - assert.equal(sent, true); - assert.equal(received?.m?.payload?.content, 'over the wire'); - - await engineA.stop(); - await engineB.stop(); -}); diff --git a/packages/sync/test/portable-engine.test.mjs b/packages/sync/test/portable-engine.test.mjs deleted file mode 100644 index 3a80e1a5..00000000 --- a/packages/sync/test/portable-engine.test.mjs +++ /dev/null @@ -1,225 +0,0 @@ -// Real tests for the zip-flow BackupEngine, driven through fake-but-real ports -// (an in-memory archive that records every op, a real FileMapper that rewrites a -// path field, a recording sink) — NOT mocks of the engine's own logic. Deleting -// the flow fails these: export must stage a backup.json whose payload has its -// file path swapped for a bundle KEY, copy the real file under that key, pack a -// .zip, and deliver it; import must unpack, restore each keyed file to a real -// path, rewrite the payload back to real paths, and apply it. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { BackupEngine, createBundle, serializeBundle } from '../dist/portable/index.js'; - -const NOW = '2026-07-09T12:00:00.000Z'; - -// A real FileMapper for the payload shape { marker, file }: `file` is the one -// file-bearing field. extract lists it + returns a keyed copy; restore swaps back. -const fileMapper = { - extract(data) { - if (data.file) { - return { files: [{ key: 'files/f0', sourcePath: data.file }], keyed: { ...data, file: 'files/f0' } }; - } - return { files: [], keyed: data }; - }, - listKeys(keyed) { - return typeof keyed.file === 'string' && keyed.file.startsWith('files/') ? [keyed.file] : []; - }, - restore(keyed, keyToPath) { - return { ...keyed, file: keyToPath[keyed.file] ?? keyed.file }; - }, -}; - -function makeData() { - const applied = []; - return { - applied, - async collectAll() { - return { marker: 'all', file: '/real/a.png' }; - }, - async collectProject(id) { - return id === 'p1' ? { marker: 'p1', file: '' } : null; // no file -> empty - }, - async collectConversation() { - return { marker: 'c', file: '' }; - }, - validate(d) { - if (typeof d !== 'object' || d === null) throw new Error('bad payload'); - return d; - }, - async apply(d) { - applied.push(d); - return { ok: true }; - }, - }; -} - -// In-memory archive recording every operation. Text files land in `writes`; -// copies are recorded; pack/unpack return synthetic paths. -function makeArchive(seed = {}) { - let n = 0; - const ops = { writes: { ...seed }, copies: [], packed: null, unpacked: null }; - return { - ops, - async stageDir() { - return `/stage${n++}`; - }, - async writeText(p, t) { - ops.writes[p] = t; - }, - async readText(p) { - return ops.writes[p]; - }, - async copyInto(src, dest) { - ops.copies.push({ src, dest }); - }, - async pack(dir, name) { - ops.packed = { dir, name }; - return `/out/${name}`; - }, - async unpack(archivePath) { - ops.unpacked = archivePath; - return '/unpack'; - }, - restorePathFor(key) { - return `/restored/${key}`; - }, - join(...parts) { - return parts.join('/'); - }, - }; -} - -function makeSink(pickPath = null) { - const delivered = []; - return { delivered, async deliverFile(absPath, name) { delivered.push({ absPath, name }); return { path: absPath }; }, async pickFile() { return pickPath; } }; -} - -test('exportAll stages a keyed envelope, copies the real file under its key, packs + delivers a .zip', async () => { - const data = makeData(); - const archive = makeArchive(); - const sink = makeSink(); - const engine = new BackupEngine(data, fileMapper, archive, sink, () => NOW); - - const result = await engine.exportAll(); - assert.deepEqual(result, { path: '/out/offgrid-backup-2026-07-09T12-00-00-000Z.zip' }); - - // backup.json in the stage carries the payload with the path swapped for a KEY. - const raw = archive.ops.writes['/stage0/backup.json']; - assert.ok(raw, 'backup.json written to the stage dir'); - const bundle = JSON.parse(raw); - assert.equal(bundle.data.file, 'files/f0'); // path rewritten to bundle key - assert.equal(bundle.data.marker, 'all'); - - // the real file was copied under its key inside the stage. - assert.deepEqual(archive.ops.copies, [{ src: '/real/a.png', dest: '/stage0/files/f0' }]); - assert.equal(archive.ops.packed.name, 'offgrid-backup-2026-07-09T12-00-00-000Z.zip'); - assert.deepEqual(sink.delivered, [{ absPath: '/out/offgrid-backup-2026-07-09T12-00-00-000Z.zip', name: 'offgrid-backup-2026-07-09T12-00-00-000Z.zip' }]); -}); - -test('a payload with no files still packs (envelope only), no copies', async () => { - const archive = makeArchive(); - const engine = new BackupEngine(makeData(), fileMapper, archive, makeSink(), () => NOW); - const result = await engine.exportProject('p1'); - assert.ok(result.path.startsWith('/out/offgrid-project-')); - assert.equal(archive.ops.copies.length, 0); -}); - -test('exportProject returns null (packs nothing) for a missing project', async () => { - const archive = makeArchive(); - const engine = new BackupEngine(makeData(), fileMapper, archive, makeSink(), () => NOW); - assert.equal(await engine.exportProject('missing'), null); - assert.equal(archive.ops.packed, null); -}); - -test('import unpacks, restores the keyed file to a real path, rewrites + applies', async () => { - // Seed the archive so unpack->/unpack has a backup.json holding a KEYED payload. - const keyedBundle = serializeBundle(createBundle({ data: { marker: 'x', file: 'files/f0' }, exportedAt: NOW })); - const archive = makeArchive({ '/unpack/backup.json': keyedBundle }); - const data = makeData(); - const engine = new BackupEngine(data, fileMapper, archive, makeSink('/picked.zip'), () => NOW); - - const summary = await engine.import(); - assert.deepEqual(summary, { ok: true }); - assert.equal(archive.ops.unpacked, '/picked.zip'); - // the bundled file was copied out to its restore path... - assert.deepEqual(archive.ops.copies, [{ src: '/unpack/files/f0', dest: '/restored/files/f0' }]); - // ...and apply received the payload with the key rewritten to that real path. - assert.deepEqual(data.applied, [{ marker: 'x', file: '/restored/files/f0' }]); -}); - -test('import returns null and applies nothing when the picker is cancelled', async () => { - const data = makeData(); - const engine = new BackupEngine(data, fileMapper, makeArchive(), makeSink(null), () => NOW); - assert.equal(await engine.import(), null); - assert.equal(data.applied.length, 0); -}); - -// A round-trip-capable in-memory archive: pack snapshots a stage dir's entries, -// unpack restores them into a fresh dir. This lets one engine's exported bundle -// be imported by another through the same archive — the "wire" between devices. -function makeRoundTripArchive() { - const files = new Map(); - const zips = new Map(); - let n = 0; - return { - files, - async stageDir() { return `/stage${n++}`; }, - async writeText(p, t) { files.set(p, t); }, - async readText(p) { return files.get(p); }, - async copyInto(src, dest) { files.set(dest, files.get(src) ?? `COPY:${src}`); }, - async pack(dir, name) { - const entries = []; - for (const [p, c] of files) if (p.startsWith(`${dir}/`)) entries.push({ rel: p.slice(dir.length + 1), content: c }); - const zip = `/out/${name}`; - zips.set(zip, entries); - return zip; - }, - async unpack(zip) { - const dir = `/unpack${n++}`; - for (const { rel, content } of zips.get(zip)) files.set(`${dir}/${rel}`, content); - return dir; - }, - restorePathFor(key) { return `/restored/${key}`; }, - join(...parts) { return parts.join('/'); }, - }; -} - -test('round-trip: a bundle exported on device A imports + applies on device B (the share model)', async () => { - const wire = makeRoundTripArchive(); // shared "wire" both devices see - - // Device A exports; capture the zip it produced. - let zipPath; - const sinkA = { async deliverFile(p) { zipPath = p; return { path: p }; }, async pickFile() { return null; } }; - await new BackupEngine(makeData(), fileMapper, wire, sinkA, () => NOW).exportAll(); - assert.ok(zipPath.endsWith('.zip')); - - // Device B receives that zip and imports it. - const dataB = makeData(); - const sinkB = { async deliverFile() { throw new Error('n/a'); }, async pickFile() { return zipPath; } }; - const summary = await new BackupEngine(dataB, fileMapper, wire, sinkB, () => NOW).import(); - - assert.deepEqual(summary, { ok: true }); - // B applied A's payload, with the bundled file restored to a real local path. - assert.equal(dataB.applied[0].marker, 'all'); - assert.equal(dataB.applied[0].file, '/restored/files/f0'); -}); - -test('importPath applies a pushed bundle from a known path, no picker (receiver side)', async () => { - const keyedBundle = serializeBundle(createBundle({ data: { marker: 'pushed', file: 'files/f0' }, exportedAt: NOW })); - const archive = makeArchive({ '/unpack/backup.json': keyedBundle }); - const data = makeData(); - const engine = new BackupEngine(data, fileMapper, archive, makeSink(null), () => NOW); - - const summary = await engine.importPath('/received/backup.zip'); - assert.deepEqual(summary, { ok: true }); - assert.equal(archive.ops.unpacked, '/received/backup.zip'); // unpacked the given path, not a picked one - assert.deepEqual(data.applied[0], { marker: 'pushed', file: '/restored/files/f0' }); -}); - -test('import surfaces a bad-payload rejection from the data port validator', async () => { - const badBundle = serializeBundle(createBundle({ data: 42, exportedAt: NOW })); - const archive = makeArchive({ '/unpack/backup.json': badBundle }); - const data = makeData(); - const engine = new BackupEngine(data, fileMapper, archive, makeSink('/x.zip'), () => NOW); - await assert.rejects(() => engine.import(), /bad payload/); - assert.equal(data.applied.length, 0); -}); diff --git a/packages/sync/test/portable.test.mjs b/packages/sync/test/portable.test.mjs deleted file mode 100644 index 1b15e824..00000000 --- a/packages/sync/test/portable.test.mjs +++ /dev/null @@ -1,94 +0,0 @@ -// Real tests for the portable-bundle core, exercised through the built dist -// (matching this package's node:test convention). Pure logic, so every branch -// is covered directly: the additive-merge rule (add / skip-existing / -// dedup-incoming) and the envelope round-trip + every rejection path. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - mergeById, - createBundle, - serializeBundle, - parseBundle, - BUNDLE_FORMAT, - BUNDLE_VERSION, - BundleError, -} from '../dist/portable/index.js'; - -test('mergeById appends only genuinely-new ids', () => { - const existing = [{ id: 'a' }, { id: 'b' }]; - const incoming = [{ id: 'b' }, { id: 'c' }, { id: 'd' }]; - const { merged, addedIds } = mergeById(existing, incoming); - assert.deepEqual( - merged.map((x) => x.id), - ['a', 'b', 'c', 'd'], - ); - assert.deepEqual(addedIds, ['c', 'd']); -}); - -test('mergeById never removes or overwrites an existing item', () => { - const existing = [{ id: 'a', v: 1 }]; - const incoming = [{ id: 'a', v: 2 }]; // same id, different content - const { merged, addedIds } = mergeById(existing, incoming); - assert.deepEqual(merged, [{ id: 'a', v: 1 }]); // original kept, not clobbered - assert.deepEqual(addedIds, []); -}); - -test('mergeById dedups repeated ids within the incoming batch', () => { - const { merged, addedIds } = mergeById([], [{ id: 'x' }, { id: 'x' }]); - assert.deepEqual( - merged.map((x) => x.id), - ['x'], - ); - assert.deepEqual(addedIds, ['x']); -}); - -test('createBundle + serialize + parse round-trips the payload', () => { - const data = { projects: [{ id: 'p1' }], note: 'hi' }; - const bundle = createBundle({ data, exportedAt: '2026-07-09T00:00:00.000Z' }); - assert.equal(bundle.format, BUNDLE_FORMAT); - assert.equal(bundle.version, BUNDLE_VERSION); - - const parsed = parseBundle(serializeBundle(bundle), { validateData: (d) => d }); - assert.deepEqual(parsed.data, data); - assert.equal(parsed.exportedAt, '2026-07-09T00:00:00.000Z'); -}); - -test('parseBundle runs the app payload validator', () => { - const bundle = createBundle({ data: { n: 1 }, exportedAt: 'now' }); - const raw = serializeBundle(bundle); - let received; - parseBundle(raw, { - validateData: (d) => { - received = d; - return d; - }, - }); - assert.deepEqual(received, { n: 1 }); -}); - -test('parseBundle rejects non-JSON', () => { - assert.throws(() => parseBundle('{not json', { validateData: (d) => d }), BundleError); -}); - -test('parseBundle rejects a non-object payload file', () => { - assert.throws(() => parseBundle('42', { validateData: (d) => d }), BundleError); -}); - -test('parseBundle rejects a foreign format', () => { - const raw = JSON.stringify({ format: 'something-else', version: 1, data: {} }); - assert.throws(() => parseBundle(raw, { validateData: (d) => d }), BundleError); -}); - -test('parseBundle rejects an incompatible version', () => { - const raw = JSON.stringify({ format: BUNDLE_FORMAT, version: 999, data: {} }); - assert.throws( - () => parseBundle(raw, { validateData: (d) => d }), - /different app version/, - ); -}); - -test('parseBundle honors a caller-supplied expectedVersion', () => { - const raw = JSON.stringify({ format: BUNDLE_FORMAT, version: 2, exportedAt: '', data: { ok: true } }); - const parsed = parseBundle(raw, { expectedVersion: 2, validateData: (d) => d }); - assert.deepEqual(parsed.data, { ok: true }); -}); diff --git a/packages/sync/test/reconnect.test.mjs b/packages/sync/test/reconnect.test.mjs deleted file mode 100644 index 0076a55d..00000000 --- a/packages/sync/test/reconnect.test.mjs +++ /dev/null @@ -1,113 +0,0 @@ -// Reconnect/resume: two devices that already share a secret reconnect WITHOUT -// re-running the passphrase handshake, then exchange an encrypted message. Plus -// the DiscoveryOrchestrator auto-reconnects known devices and surfaces unknowns. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import pkg from '../dist/index.js'; -const { SyncEngine, DiscoveryOrchestrator, deriveSharedSecret } = pkg; - -const delay = (ms) => new Promise((r) => setTimeout(r, ms)); - -function makeNetwork() { - const listeners = new Map(); - const pipe = () => { - const ends = {}; - const mk = (self, peer) => { - let onData = null; - const onClose = []; - return { - _deliver: (d) => onData && onData(d), - _close: () => onClose.forEach((f) => f()), - id: self, - send: (data) => queueMicrotask(() => ends[peer]._deliver(data)), - onData: (cb) => (onData = cb), - onClose: (cb) => onClose.push(cb), - close: () => ends[peer]._close(), - }; - }; - ends.client = mk('client', 'server'); - ends.server = mk('server', 'client'); - return ends; - }; - return { - listen: async (port, onConnection) => listeners.set(port, onConnection), - connect: async (_h, port) => { - const cb = listeners.get(port); - if (!cb) throw new Error('no listener'); - const ends = pipe(); - cb(ends.server); - return ends.client; - }, - stop: async () => listeners.clear(), - }; -} - -const dev = (id) => ({ id, name: id, platform: 'macos', version: '1', host: '127.0.0.1', port: 9001 }); - -test('reconnect resumes with stored secret, no passphrase', async () => { - const transport = makeNetwork(); - // Both sides already know the pair secret (as if previously paired). - const secret = deriveSharedSecret('the-passphrase', 'dev-a', 'dev-b'); - - let aPaired, bPaired, received; - const engineA = new SyncEngine({ - localDevice: dev('dev-a'), - transport, - getSharedSecret: (id) => (id === 'dev-b' ? secret : undefined), - onPaired: (d) => (aPaired = d), - onMessage: (id, m) => (received = m), - }); - const engineB = new SyncEngine({ - localDevice: dev('dev-b'), - transport, - getSharedSecret: (id) => (id === 'dev-a' ? secret : undefined), - onPaired: (d) => (bPaired = d), - }); - - await engineA.start(9001); - await engineB.reconnect(dev('dev-a'), secret); // no passphrase - await delay(50); - - assert.ok(aPaired && bPaired, 'both resumed without handshake'); - assert.equal(aPaired.id, 'dev-b'); - assert.equal(bPaired.id, 'dev-a'); - - const sent = engineB.send('dev-a', { type: 'text', id: '1', timestamp: 1, payload: { content: 'resumed!' } }); - await delay(20); - assert.equal(sent, true); - assert.equal(received?.payload?.content, 'resumed!'); -}); - -test('orchestrator auto-reconnects known devices, surfaces unknown', async () => { - const reconnected = []; - const discoveredUnknown = []; - // Fake discovery we can drive manually. - let foundCb; - const discovery = { - onDeviceFound: (cb) => (foundCb = cb), - onDeviceLost: () => {}, - start: async () => {}, - advertise: async () => {}, - stop: async () => {}, - }; - const engine = { - isPaired: () => false, - reconnect: async (device) => { reconnected.push(device.id); }, - }; - const orch = new DiscoveryOrchestrator({ - engine, - discovery, - localDevice: dev('me'), - getSharedSecret: (id) => (id === 'known' ? 'secret' : undefined), - onDiscovered: (d) => discoveredUnknown.push(d.id), - }); - await orch.start(); - - foundCb({ ...dev('me'), lastSeen: 1 }); // self -> ignored - foundCb({ ...dev('known'), lastSeen: 1 }); // known -> reconnect - foundCb({ ...dev('stranger'), lastSeen: 1 }); // unknown -> surfaced - await delay(10); - - assert.deepEqual(reconnected, ['known']); - assert.deepEqual(discoveredUnknown, ['stranger']); -}); diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json deleted file mode 100644 index 3e9a43f7..00000000 --- a/packages/sync/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "lib": ["ES2022", "DOM"], - "types": ["node"], - "skipLibCheck": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/src/main/__tests__/rag-message-uuid.dbtest.ts b/src/main/__tests__/rag-message-uuid.dbtest.ts new file mode 100644 index 00000000..19674056 --- /dev/null +++ b/src/main/__tests__/rag-message-uuid.dbtest.ts @@ -0,0 +1,136 @@ +/** + * The `rag_messages.uuid` migration, against a REAL legacy database on disk. + * + * `rag_messages.id` is INTEGER AUTOINCREMENT and therefore device-local. Cross-device sync keys + * records by a globally-unique id, so without this column device A's row 7 and device B's row 7 + * would look like the SAME message and silently overwrite each other. This test proves the + * migration is real: an existing profile gains the column, every pre-existing row is backfilled, + * the uniqueness constraint exists, and new inserts populate it. + * + * Written as a dbtest because it must run the production migration over a database created with the + * OLD schema — a unit test with a fresh DB would never exercise the upgrade path at all. + */ +import fs from 'node:fs' +import path from 'node:path' +import Database from 'better-sqlite3-multiple-ciphers' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + tmpDir: `/tmp/offgrid-rag-uuid-${process.pid}-${process.env.VITEST_POOL_ID ?? '0'}` +})) + +fs.mkdirSync(h.tmpDir, { recursive: true }) + +vi.mock('electron', () => ({ + app: { getPath: () => h.tmpDir, getAppPath: () => process.cwd(), isPackaged: false }, + safeStorage: { + // Force the plaintext path so the pre-seeded legacy DB is the one that gets opened. + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + } +})) + +const dbPath = path.join(h.tmpDir, 'memories.db') + +/** A profile created BEFORE the uuid column existed, with messages already in it. */ +const seedLegacyProfile = (): void => { + const legacy = new Database(dbPath) + legacy.exec(` + CREATE TABLE rag_conversations ( + id TEXT PRIMARY KEY, + title TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE rag_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + context TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `) + legacy.prepare('INSERT INTO rag_conversations (id, title) VALUES (?, ?)').run('conv-old', 'Old') + const insert = legacy.prepare( + 'INSERT INTO rag_messages (conversation_id, role, content) VALUES (?, ?, ?)' + ) + insert.run('conv-old', 'user', 'first legacy message') + insert.run('conv-old', 'assistant', 'second legacy message') + legacy.close() +} + +beforeAll(() => { + fs.rmSync(dbPath, { force: true }) + seedLegacyProfile() +}) + +afterAll(() => { + fs.rmSync(h.tmpDir, { recursive: true, force: true }) +}) + +describe('rag_messages.uuid migration', () => { + it('adds the column and backfills every pre-existing row with a distinct uuid', async () => { + const { getDB } = await import('../database') + const db = getDB() // runs the production migration over the legacy profile + + const columns = (db.prepare('PRAGMA table_info(rag_messages)').all() as { name: string }[]).map( + (c) => c.name + ) + expect(columns).toContain('uuid') + + const rows = db.prepare('SELECT id, uuid FROM rag_messages ORDER BY id').all() as { + id: number + uuid: string | null + }[] + expect(rows).toHaveLength(2) + for (const row of rows) { + // A null uuid would mean that message can never sync. + expect(row.uuid, `row ${row.id} was not backfilled`).toBeTruthy() + } + expect(new Set(rows.map((r) => r.uuid)).size).toBe(2) // distinct, not one shared value + }) + + it('enforces uniqueness so a replayed remote op upserts instead of duplicating', async () => { + const { getDB } = await import('../database') + const db = getDB() + const existing = (db.prepare('SELECT uuid FROM rag_messages LIMIT 1').get() as { uuid: string }) + .uuid + + expect(() => + db + .prepare('INSERT INTO rag_messages (uuid, conversation_id, role, content) VALUES (?,?,?,?)') + .run(existing, 'conv-old', 'user', 'duplicate uuid') + ).toThrow(/UNIQUE/i) + }) + + it('populates uuid on every new message written through the production writer', async () => { + const { getDB, addRagMessage } = await import('../database') + const db = getDB() + + const id = addRagMessage('conv-old', 'user', 'a brand new message') + const row = db.prepare('SELECT uuid, content FROM rag_messages WHERE id = ?').get(id) as { + uuid: string | null + content: string + } + expect(row.content).toBe('a brand new message') + expect(row.uuid, 'new messages must carry a uuid or they cannot sync').toBeTruthy() + }) + + it('is idempotent — running the migration again neither throws nor rewrites uuids', async () => { + const { getDB } = await import('../database') + const before = ( + getDB().prepare('SELECT uuid FROM rag_messages ORDER BY id').all() as { uuid: string }[] + ).map((r) => r.uuid) + + // Close and reopen so the whole migration block runs a second time over the SAME profile. + getDB().close() + const after = ( + getDB().prepare('SELECT uuid FROM rag_messages ORDER BY id').all() as { uuid: string }[] + ).map((r) => r.uuid) + + // Rewriting uuids on every launch would orphan the record on every other device. + expect(after).toEqual(before) + }) +}) diff --git a/src/main/database.ts b/src/main/database.ts index fe76471c..5a10d41b 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -374,6 +374,34 @@ export function getDB(): Database.Database { // Column already exists, ignore } + // Migration: Add a stable UUID to rag_messages so chat messages can replicate across devices. + // + // WHY: `rag_messages.id` is INTEGER AUTOINCREMENT, which is device-local. Cross-device sync keys + // every record by a globally-unique id, so device A's row 7 and device B's row 7 would look like + // the SAME message and silently overwrite each other. The autoincrement id stays as the local + // primary key; `uuid` is the cross-device identity. Mobile uses the same column name and the same + // 'message' entity name — they must match or the two platforms will not converge. + try { + db.exec(`ALTER TABLE rag_messages ADD COLUMN uuid TEXT`) + } catch { + // Column already exists, ignore + } + // Backfill rows that predate the column. Done in JS because SQLite has no uuid() function. + try { + const needsUuid = db + .prepare('SELECT id FROM rag_messages WHERE uuid IS NULL OR uuid = ?') + .all('') as Array<{ id: number }> + if (needsUuid.length > 0) { + const assign = db.prepare('UPDATE rag_messages SET uuid = ? WHERE id = ?') + for (const row of needsUuid) assign.run(crypto.randomUUID(), row.id) + } + } catch { + // Table may not exist yet on a brand-new profile; the CREATE above covers that path. + } + // Unique so a replayed remote op upserts instead of duplicating. SQLite treats NULLs as + // distinct, so this is safe to create even if a backfill ever misses a row. + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_rag_messages_uuid ON rag_messages(uuid)') + return db } @@ -1475,14 +1503,15 @@ export function addRagMessage( const db = getDB() const contextJson = context ? JSON.stringify(context) : null + // uuid is the cross-device identity for sync (the autoincrement id is device-local). const info = db .prepare( ` - INSERT INTO rag_messages (conversation_id, role, content, context, created_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO rag_messages (uuid, conversation_id, role, content, context, created_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ` ) - .run(conversationId, role, content, contextJson) + .run(crypto.randomUUID(), conversationId, role, content, contextJson) // Update conversation updated_at timestamp db.prepare( From 1f1946f182d9ee4b9ab9fdbba7137aab7abfebc9 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:05:52 +0530 Subject: [PATCH 003/145] fix(license): replace the stalest seat when activation hits the cap --- ...cense-seat-replacement.integration.test.ts | 105 ++++++++++++++++++ src/main/licensing/license-service.ts | 67 ++++++++++- 2 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/main/licensing/__tests__/license-seat-replacement.integration.test.ts diff --git a/src/main/licensing/__tests__/license-seat-replacement.integration.test.ts b/src/main/licensing/__tests__/license-seat-replacement.integration.test.ts new file mode 100644 index 00000000..91af91e4 --- /dev/null +++ b/src/main/licensing/__tests__/license-seat-replacement.integration.test.ts @@ -0,0 +1,105 @@ +/** + * License activation through the real service and Keygen client. Only the third-party HTTP + * boundary and Electron's OS storage boundary are replaced. + */ +import fs from 'node:fs' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + userData: `/tmp/offgrid-license-seat-${process.pid}-${process.env.VITEST_POOL_ID ?? '0'}`, + fingerprint: 'current-device-fingerprint' +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => h.userData, + isPackaged: false + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + } +})) + +import { activateProByKey, getProLicenseInfo } from '../license-service' + +const machine = (id: string, fingerprint: string, lastSeen: string): Record => ({ + type: 'machines', + id, + attributes: { + fingerprint, + platform: 'macos', + name: id, + lastHeartbeat: lastSeen + } +}) + +beforeAll(() => { + fs.mkdirSync(h.userData, { recursive: true }) + fs.writeFileSync(path.join(h.userData, 'device-fingerprint'), h.fingerprint) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +afterAll(() => { + fs.rmSync(h.userData, { recursive: true, force: true }) +}) + +describe('Pro activation at the five-device limit', () => { + it('evicts the least-recently-seen other device and activates this device', async () => { + const requests: Array<{ method: string; path: string }> = [] + const fetchBoundary = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input.toString()) + const method = init?.method ?? (input instanceof Request ? input.method : 'GET') + requests.push({ method, path: url.pathname }) + + if (url.pathname.endsWith('/licenses/actions/validate-key')) { + return Response.json({ + meta: { valid: false, code: 'TOO_MANY_MACHINES' }, + data: { + type: 'licenses', + id: 'license-1', + attributes: { expiry: null, metadata: {}, name: 'Pro' } + } + }) + } + if (url.pathname.endsWith('/licenses/license-1/machines')) { + return Response.json({ + data: [ + machine('machine-current', h.fingerprint, '2020-01-01T00:00:00Z'), + machine('machine-oldest', 'oldest-device', '2024-01-01T00:00:00Z'), + machine('machine-newer-1', 'newer-1', '2025-01-01T00:00:00Z'), + machine('machine-newer-2', 'newer-2', '2025-02-01T00:00:00Z'), + machine('machine-newest', 'newest', '2025-03-01T00:00:00Z') + ] + }) + } + if (url.pathname.endsWith('/machines/machine-oldest') && method === 'DELETE') { + return new Response(null, { status: 204 }) + } + if (url.pathname.endsWith('/machines') && method === 'POST') { + return new Response(null, { status: 201 }) + } + return new Response(null, { status: 500 }) + }) + vi.stubGlobal('fetch', fetchBoundary) + + await expect(activateProByKey('license-key')).resolves.toEqual({ ok: true }) + expect( + requests.map(({ method, path: requestPath }) => ({ + method, + path: requestPath.replace(/^\/v1\/accounts\/[^/]+/, '') + })) + ).toEqual([ + { method: 'POST', path: '/licenses/actions/validate-key' }, + { method: 'GET', path: '/licenses/license-1/machines' }, + { method: 'DELETE', path: '/machines/machine-oldest' }, + { method: 'POST', path: '/machines' } + ]) + expect(getProLicenseInfo()).toMatchObject({ isPro: true, tier: 'lifetime' }) + }) +}) diff --git a/src/main/licensing/license-service.ts b/src/main/licensing/license-service.ts index 5e653c52..3a6730c6 100644 --- a/src/main/licensing/license-service.ts +++ b/src/main/licensing/license-service.ts @@ -147,6 +147,43 @@ export function checkProStatus(): boolean { return isProActive(cache) } +function lastSeenAt(machine: KeygenMachine): number { + if (!machine.lastSeen) return Number.NEGATIVE_INFINITY + const parsed = Date.parse(machine.lastSeen) + return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY +} + +/** + * Claim a seat for this install. If all five seats are occupied, replace the least-recently-seen + * other install and retry once. Keygen remains the owner of the cap; this service owns the product + * policy for which stale seat gives way. + */ +async function activateWithSeatReplacement( + key: string, + licenseId: string, + device: { fingerprint: string; platform: string }, + knownFull = false +): Promise<{ ok: boolean; limitReached: boolean }> { + if (!knownFull) { + const firstAttempt = await activateMachine(key, licenseId, device) + if (!firstAttempt.limitReached) return firstAttempt + } + + const machines = await listMachines(key, licenseId) + const replacement = machines + .filter((machine) => machine.fingerprint !== device.fingerprint) + .sort((left, right) => { + const byLastSeen = lastSeenAt(left) - lastSeenAt(right) + return byLastSeen || left.id.localeCompare(right.id) + })[0] + + if (!replacement) return { ok: false, limitReached: true } + if (!(await deactivateMachine(key, replacement.id))) { + return { ok: false, limitReached: true } + } + return activateMachine(key, licenseId, device) +} + /** * Re-check the stored key with Keygen when online. A revoked or expired key flips * the cached flag to false and locks the app. Network errors are swallowed so @@ -179,8 +216,9 @@ async function revalidatePro(): Promise { verifiedAt: Date.now() }) } else if (NEEDS_ACTIVATION.includes(r.code) && r.license) { - // Valid key but this device lost its slot — try to reclaim it. - const act = await activateMachine(lic.key, r.license.id, { + // Valid key but this device lost its slot - reclaim it, replacing the stalest other install + // when the license is already using all five seats. + const act = await activateWithSeatReplacement(lic.key, r.license.id, { fingerprint: fp, platform: getPlatformTag() }) @@ -231,14 +269,35 @@ export async function activateProByKey(rawKey: string): Promise }) return { ok: true } } - if (r.code === 'TOO_MANY_MACHINES') return { ok: false, reason: 'limit' } + if (r.code === 'TOO_MANY_MACHINES') { + if (!r.license) return { ok: false, reason: 'limit' } + try { + const act = await activateWithSeatReplacement( + key, + r.license.id, + { fingerprint: fp, platform: getPlatformTag() }, + true + ) + if (!act.ok) return { ok: false, reason: act.limitReached ? 'limit' : 'invalid' } + writeLicense({ + isPro: true, + key, + licenseId: r.license.id, + expiry: r.license.expiry, + verifiedAt: Date.now() + }) + return { ok: true } + } catch { + return { ok: false, reason: 'network' } + } + } if (REVOKED_CODES.includes(r.code) || !r.license) return { ok: false, reason: 'invalid' } // Valid key, this device not yet activated — claim a slot. if (NEEDS_ACTIVATION.includes(r.code)) { let act try { - act = await activateMachine(key, r.license.id, { + act = await activateWithSeatReplacement(key, r.license.id, { fingerprint: fp, platform: getPlatformTag() }) From a5c7537af3fa371c6acb31cea78bb51ca34c660c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:15:52 +0530 Subject: [PATCH 004/145] feat(sync): emit committed chat and project mutations --- .../__tests__/sync-mutation-hooks.dbtest.ts | 126 ++++++++++++++++++ src/main/bootstrap/hookRegistry.ts | 4 +- src/main/database.ts | 55 ++++++-- src/main/rag/store.ts | 38 +++++- src/main/sync-mutation.ts | 31 +++++ 5 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 src/main/__tests__/sync-mutation-hooks.dbtest.ts create mode 100644 src/main/sync-mutation.ts diff --git a/src/main/__tests__/sync-mutation-hooks.dbtest.ts b/src/main/__tests__/sync-mutation-hooks.dbtest.ts new file mode 100644 index 00000000..9174451d --- /dev/null +++ b/src/main/__tests__/sync-mutation-hooks.dbtest.ts @@ -0,0 +1,126 @@ +/** + * Core data owners emit one committed mutation contract for the private sync integration. + * + * The database, project store, transactions, UUID generation, and hook registry are real. Only + * Electron's userData/safeStorage OS boundary points at a synthetic temp profile. + */ +import fs from 'node:fs' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + userData: `/tmp/offgrid-sync-mutations-${process.pid}-${process.env.VITEST_POOL_ID ?? '0'}` +})) + +vi.mock('electron', () => ({ + app: { getPath: () => h.userData, getAppPath: () => process.cwd(), isPackaged: false }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + } +})) + +import { registerHook, HOOKS } from '../bootstrap/hookRegistry' +import { + addRagMessage, + createRagConversation, + deleteRagConversation, + getDB, + getRagConversation, + setRagConversationProject, + truncateRagMessages, + updateRagConversationTitle +} from '../database' +import { createProject, deleteProject, updateProject } from '../rag/store' +import type { SyncMutation } from '../sync-mutation' + +const mutations: SyncMutation[] = [] + +beforeAll(() => { + fs.mkdirSync(h.userData, { recursive: true }) + registerHook(HOOKS.syncRecordLocalMutation, (mutation: SyncMutation) => { + mutations.push(mutation) + }) +}) + +afterAll(() => { + getDB().close() + fs.rmSync(h.userData, { recursive: true, force: true }) +}) + +describe('core sync mutation contract', () => { + it('reports committed chat and project writes with stable cross-device ids', () => { + createProject({ id: 'project-1', name: 'Shared project' }) + createRagConversation('conversation-1', 'First title', 'project-1') + addRagMessage('conversation-1', 'user', 'hello') + updateRagConversationTitle('conversation-1', 'Updated title') + setRagConversationProject('conversation-1', null) + updateProject('project-1', { name: 'Updated project' }) + + expect( + mutations.slice(0, 7).map(({ entity, entityId, kind }) => [entity, entityId, kind]) + ).toEqual([ + ['project', 'project-1', 'put'], + ['conversation', 'conversation-1', 'put'], + ['message', expect.stringMatching(/^[0-9a-f-]{36}$/), 'put'], + ['conversation', 'conversation-1', 'put'], + ['conversation', 'conversation-1', 'put'], + ['conversation', 'conversation-1', 'put'], + ['project', 'project-1', 'put'] + ]) + }) + + it('reports every child tombstone when messages or a parent are removed', () => { + mutations.length = 0 + createProject({ id: 'project-delete', name: 'Delete me' }) + createRagConversation('conversation-delete', 'Delete me', 'project-delete') + addRagMessage('conversation-delete', 'user', 'one') + addRagMessage('conversation-delete', 'assistant', 'two') + addRagMessage('conversation-delete', 'user', 'three') + + mutations.length = 0 + expect(truncateRagMessages('conversation-delete', 1)).toBe(2) + expect(mutations).toHaveLength(2) + expect(mutations.every(({ entity, kind }) => entity === 'message' && kind === 'delete')).toBe( + true + ) + + mutations.length = 0 + expect(deleteRagConversation('conversation-delete')).toBe(true) + expect(mutations.map(({ entity, kind }) => [entity, kind])).toEqual([ + ['message', 'delete'], + ['conversation', 'delete'] + ]) + + createRagConversation('project-child', 'Project child', 'project-delete') + addRagMessage('project-child', 'user', 'child') + mutations.length = 0 + deleteProject('project-delete') + expect(mutations.map(({ entity, kind }) => [entity, kind])).toEqual([ + ['message', 'delete'], + ['conversation', 'delete'], + ['project', 'delete'] + ]) + }) + + it('keeps a committed core write successful when the optional Pro hook fails', () => { + const syncError = new Error('sync store unavailable') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + registerHook(HOOKS.syncRecordLocalMutation, () => { + throw syncError + }) + + createRagConversation('conversation-offline', 'Saved locally') + + expect(getRagConversation('conversation-offline')).toMatchObject({ + id: 'conversation-offline', + title: 'Saved locally' + }) + expect(consoleError).toHaveBeenCalledWith( + '[sync] Failed to record committed mutation', + { entity: 'conversation', entityId: 'conversation-offline', kind: 'put' }, + syncError + ) + consoleError.mockRestore() + }) +}) diff --git a/src/main/bootstrap/hookRegistry.ts b/src/main/bootstrap/hookRegistry.ts index 7565bf70..d1d297f5 100644 --- a/src/main/bootstrap/hookRegistry.ts +++ b/src/main/bootstrap/hookRegistry.ts @@ -38,5 +38,7 @@ export const HOOKS = { * system/context with captured memory + entity/observation context (pro). */ chatAugmentContext: 'chat.augmentContext', /** () => Promise — extra universal-search sources (pro). */ - searchExtraSources: 'search.extraSources' + searchExtraSources: 'search.extraSources', + /** (mutation: SyncMutation) => void - record a committed core data change in Pro sync. */ + syncRecordLocalMutation: 'sync.recordLocalMutation' } as const diff --git a/src/main/database.ts b/src/main/database.ts index 5a10d41b..c42697b3 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -6,6 +6,7 @@ import path from 'path' import fs from 'fs' import crypto from 'crypto' import { createSettingsStore, initializeSettingsStore } from './settings-store' +import { CORE_SYNC_ENTITIES, emitSyncMutation } from './sync-mutation' import type { RagConversationContract, RagMessageContract, @@ -1374,6 +1375,7 @@ export function createRagConversation( VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ` ).run(id, title || null, projectId || null) + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.conversation, entityId: id, kind: 'put' }) return id } @@ -1415,9 +1417,14 @@ export function getRagConversation(id: string): RagConversation | null { export function setRagConversationProject(id: string, projectId: string | null): void { const db = getDB() - db.prepare( - `UPDATE rag_conversations SET project_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` - ).run(projectId, id) + const result = db + .prepare( + `UPDATE rag_conversations SET project_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + ) + .run(projectId, id) + if (result.changes === 1) { + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.conversation, entityId: id, kind: 'put' }) + } } /** Conversation ids whose MESSAGE CONTENT matches a query (all terms, AND) — so the @@ -1482,16 +1489,29 @@ export function updateRagConversationTitle(id: string, title: string): RagConver if (result.changes !== 1) { throw new Error(`Conversation not found: ${id}`) } + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.conversation, entityId: id, kind: 'put' }) return getRagConversation(id)! } export function deleteRagConversation(id: string): boolean { const db = getDB() + const messages = db + .prepare('SELECT uuid FROM rag_messages WHERE conversation_id = ?') + .all(id) as Array<{ uuid: string }> // FKs are off (no PRAGMA foreign_keys), so rag_messages' ON DELETE CASCADE never // fires — delete the conversation's messages explicitly or they orphan (D23). db.prepare('DELETE FROM rag_messages WHERE conversation_id = ?').run(id) const info = db.prepare('DELETE FROM rag_conversations WHERE id = ?').run(id) - return info.changes > 0 + if (info.changes === 0) return false + for (const message of messages) { + emitSyncMutation({ + entity: CORE_SYNC_ENTITIES.message, + entityId: message.uuid, + kind: 'delete' + }) + } + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.conversation, entityId: id, kind: 'delete' }) + return true } export function addRagMessage( @@ -1502,6 +1522,7 @@ export function addRagMessage( ): number { const db = getDB() const contextJson = context ? JSON.stringify(context) : null + const uuid = crypto.randomUUID() // uuid is the cross-device identity for sync (the autoincrement id is device-local). const info = db @@ -1511,7 +1532,7 @@ export function addRagMessage( VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ` ) - .run(crypto.randomUUID(), conversationId, role, content, contextJson) + .run(uuid, conversationId, role, content, contextJson) // Update conversation updated_at timestamp db.prepare( @@ -1520,6 +1541,12 @@ export function addRagMessage( ` ).run(conversationId) + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.message, entityId: uuid, kind: 'put' }) + emitSyncMutation({ + entity: CORE_SYNC_ENTITIES.conversation, + entityId: conversationId, + kind: 'put' + }) return Number(info.lastInsertRowid) } @@ -1528,12 +1555,22 @@ export function addRagMessage( export function truncateRagMessages(conversationId: string, keepCount: number): number { const db = getDB() const rows = db - .prepare(`SELECT id FROM rag_messages WHERE conversation_id = ? ORDER BY id ASC`) - .all(conversationId) as { id: number }[] - const toDelete = rows.slice(Math.max(0, keepCount)).map((r) => r.id) + .prepare(`SELECT id, uuid FROM rag_messages WHERE conversation_id = ? ORDER BY id ASC`) + .all(conversationId) as Array<{ id: number; uuid: string }> + const toDelete = rows.slice(Math.max(0, keepCount)) if (!toDelete.length) return 0 const ph = toDelete.map(() => '?').join(',') - return db.prepare(`DELETE FROM rag_messages WHERE id IN (${ph})`).run(...toDelete).changes + const result = db + .prepare(`DELETE FROM rag_messages WHERE id IN (${ph})`) + .run(...toDelete.map(({ id }) => id)) + for (const message of toDelete) { + emitSyncMutation({ + entity: CORE_SYNC_ENTITIES.message, + entityId: message.uuid, + kind: 'delete' + }) + } + return result.changes } export function getRagMessages(conversationId: string): RagMessage[] { diff --git a/src/main/rag/store.ts b/src/main/rag/store.ts index a4e016e6..caeeb16f 100644 --- a/src/main/rag/store.ts +++ b/src/main/rag/store.ts @@ -6,6 +6,7 @@ import { getDB } from '../database' import { deleteArtifactsForProject } from '../artifacts' +import { CORE_SYNC_ENTITIES, emitSyncMutation } from '../sync-mutation' import type { VectorStore, ChunkCandidate } from '@offgrid/rag' import type { MediaKind, Project, RagDocument } from '@offgrid/rag' @@ -236,6 +237,7 @@ export function createProject(p: { 'INSERT INTO projects (id, name, description, system_prompt, icon) VALUES (?, ?, ?, ?, ?)' ) .run(p.id, p.name, p.description ?? '', p.systemPrompt ?? '', p.icon ?? null) + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.project, entityId: p.id, kind: 'put' }) } export function updateProject( @@ -275,12 +277,30 @@ export function updateProject( if (!sets.length) return sets.push("updated_at = datetime('now')") args.push(id) - db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...args) + const result = db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...args) + if (result.changes === 1) { + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.project, entityId: id, kind: 'put' }) + } } export function deleteProject(id: string): void { migrate() const db = getDB() + const projectExists = + db.prepare('SELECT 1 AS present FROM projects WHERE id = ?').get(id) !== undefined + const conversations = db + .prepare('SELECT id FROM rag_conversations WHERE project_id = ?') + .all(id) as Array<{ id: string }> + const conversationIds = conversations.map(({ id: conversationId }) => conversationId) + const messages = + conversationIds.length === 0 + ? [] + : (db + .prepare( + `SELECT uuid FROM rag_messages + WHERE conversation_id IN (${conversationIds.map(() => '?').join(', ')})` + ) + .all(...conversationIds) as Array<{ uuid: string }>) const tx = db.transaction(() => { const docs = db.prepare('SELECT id FROM rag_documents WHERE project_id = ?').all(id) as { id: number @@ -307,4 +327,20 @@ export function deleteProject(id: string): void { // Artifacts (generated images/docs) are files, not DB rows — clean them outside // the transaction so a deleted project's artifacts don't linger in the library. deleteArtifactsForProject(id) + if (!projectExists) return + for (const message of messages) { + emitSyncMutation({ + entity: CORE_SYNC_ENTITIES.message, + entityId: message.uuid, + kind: 'delete' + }) + } + for (const conversation of conversations) { + emitSyncMutation({ + entity: CORE_SYNC_ENTITIES.conversation, + entityId: conversation.id, + kind: 'delete' + }) + } + emitSyncMutation({ entity: CORE_SYNC_ENTITIES.project, entityId: id, kind: 'delete' }) } diff --git a/src/main/sync-mutation.ts b/src/main/sync-mutation.ts new file mode 100644 index 00000000..4d624b79 --- /dev/null +++ b/src/main/sync-mutation.ts @@ -0,0 +1,31 @@ +import { callHook, HOOKS } from './bootstrap/hookRegistry' + +/** + * Stable desktop entity names shared by the core writers and the private sync materializer. + * Values are wire identities, so changing one requires a cross-platform migration. + */ +export const CORE_SYNC_ENTITIES = { + conversation: 'conversation', + message: 'message', + project: 'project' +} as const + +export type CoreSyncEntity = (typeof CORE_SYNC_ENTITIES)[keyof typeof CORE_SYNC_ENTITIES] + +export interface SyncMutation { + entity: CoreSyncEntity + entityId: string + kind: 'put' | 'delete' +} + +/** + * Core owns its committed writes; Pro optionally records them. Free builds register no hook, so + * this is an inert call with no sync engine or Pro business logic in the public application. + */ +export function emitSyncMutation(mutation: SyncMutation): void { + try { + callHook(HOOKS.syncRecordLocalMutation, mutation) + } catch (error) { + console.error('[sync] Failed to record committed mutation', mutation, error) + } +} From afb25a0d6b537ccd1cdd43e9503bf34f9ba8b503 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:33:52 +0530 Subject: [PATCH 005/145] feat(sync): emit committed model setting changes --- src/main/llm.ts | 15 ++++- .../settings-sync.integration.test.ts | 64 +++++++++++++++++++ src/main/sync-mutation.ts | 39 ++++++++++- 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/main/llm/__tests__/settings-sync.integration.test.ts diff --git a/src/main/llm.ts b/src/main/llm.ts index f74f0378..bf0a1861 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -37,6 +37,7 @@ import { ENGINE_TEARDOWN_GRACE_MS, type TeardownOutcome } from './llm/engine-teardown' +import { emitChangedLlmSettings } from './sync-mutation' export type { KvCacheType, PerformanceMode } @@ -58,6 +59,11 @@ export interface LlmSettings { batchSize?: number // -b: prompt batch size } +export interface LlmSettingsUpdateOptions { + /** Remote sync applies the winning value without creating a new local op. */ + emitSync?: boolean +} + export interface ChatStreamResult extends StreamResult { /** The resolved request cap, included so callers never duplicate settings lookup. */ maxTokens: number @@ -349,7 +355,8 @@ export class LLMService { /** Update inference settings; respawns the server if any launch-time arg changed * (context, KV-cache type, flash-attn, GPU layers, threads, batch). */ - async setSettings(s: LlmSettings): Promise { + async setSettings(s: LlmSettings, options: LlmSettingsUpdateOptions = {}): Promise { + const before = options.emitSync === false ? undefined : this.getSettings() // Granular launch-time fields the user sets in THIS patch become pinned: a mode // preset (now or on a future restart / mode re-pick) must NOT clobber them. Pin // BEFORE applying the preset so an explicit q8_0 in the same patch survives. @@ -408,6 +415,12 @@ export class LLMService { // Quantized KV cache requires FlashAttention — auto-enable it so the pair is valid. if (this.kvCacheType !== 'f16' && !this.flashAttn) this.flashAttn = true this.persist() + if (before) { + emitChangedLlmSettings( + before as Record, + this.getSettings() as Record + ) + } if (launchChanged && !this.paused) { this.stop() await this.init() diff --git a/src/main/llm/__tests__/settings-sync.integration.test.ts b/src/main/llm/__tests__/settings-sync.integration.test.ts new file mode 100644 index 00000000..ab4c86f2 --- /dev/null +++ b/src/main/llm/__tests__/settings-sync.integration.test.ts @@ -0,0 +1,64 @@ +/** + * The real LLM settings owner persists user-controlled values and emits one committed sync + * mutation per changed key. Applying a remote winner uses the same owner with emission suppressed, + * preventing an echo loop. + */ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SyncMutation } from '../../sync-mutation' + +describe('LLM settings sync contract', () => { + let dataDir: string + const previousDataDir = process.env.OFFGRID_DATA_DIR + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-settings-sync-')) + fs.mkdirSync(path.join(dataDir, 'models'), { recursive: true }) + process.env.OFFGRID_DATA_DIR = dataDir + vi.resetModules() + }) + + afterEach(() => { + if (previousDataDir === undefined) delete process.env.OFFGRID_DATA_DIR + else process.env.OFFGRID_DATA_DIR = previousDataDir + fs.rmSync(dataDir, { recursive: true, force: true }) + }) + + it('emits changed safe keys and suppresses a remote apply echo', async () => { + const mutations: SyncMutation[] = [] + const [{ registerHook, HOOKS }, { LLMService }] = await Promise.all([ + import('../../bootstrap/hookRegistry'), + import('../../llm') + ]) + registerHook(HOOKS.syncRecordLocalMutation, (mutation: SyncMutation) => { + mutations.push(mutation) + }) + const settings = new LLMService() + + await settings.setSettings({ temperature: 0.35, topP: 0.8 }) + + expect(mutations).toEqual([ + { + entity: 'model_setting', + entityId: 'temperature', + kind: 'put', + fields: { value: 0.35 } + }, + { + entity: 'model_setting', + entityId: 'topP', + kind: 'put', + fields: { value: 0.8 } + } + ]) + expect( + JSON.parse(fs.readFileSync(path.join(dataDir, 'models', 'llm-settings.json'), 'utf8')) + ).toMatchObject({ temperature: 0.35, topP: 0.8 }) + + await settings.setSettings({ temperature: 0.55 }, { emitSync: false }) + expect(settings.getSettings().temperature).toBe(0.55) + expect(mutations).toHaveLength(2) + }) +}) diff --git a/src/main/sync-mutation.ts b/src/main/sync-mutation.ts index 4d624b79..1abd60ca 100644 --- a/src/main/sync-mutation.ts +++ b/src/main/sync-mutation.ts @@ -7,7 +7,8 @@ import { callHook, HOOKS } from './bootstrap/hookRegistry' export const CORE_SYNC_ENTITIES = { conversation: 'conversation', message: 'message', - project: 'project' + project: 'project', + modelSetting: 'model_setting' } as const export type CoreSyncEntity = (typeof CORE_SYNC_ENTITIES)[keyof typeof CORE_SYNC_ENTITIES] @@ -16,6 +17,42 @@ export interface SyncMutation { entity: CoreSyncEntity entityId: string kind: 'put' | 'delete' + /** Optional canonical fields for a committed owner that is not backed by the core SQLite DB. */ + fields?: Record +} + +/** User-controlled LLM settings that are safe and meaningful on another device. */ +export const SYNCABLE_LLM_SETTING_KEYS = [ + 'performanceMode', + 'temperature', + 'ctxSize', + 'topP', + 'topK', + 'minP', + 'repeatPenalty', + 'maxTokens', + 'systemPrompt', + 'kvCacheType', + 'flashAttn', + 'gpuLayers', + 'threads', + 'batchSize' +] as const + +export function emitChangedLlmSettings( + before: Record, + after: Record +): void { + for (const key of SYNCABLE_LLM_SETTING_KEYS) { + const value = after[key] + if (value === undefined || Object.is(value, before[key])) continue + emitSyncMutation({ + entity: CORE_SYNC_ENTITIES.modelSetting, + entityId: key, + kind: 'put', + fields: { value } + }) + } } /** From 2dad9f57b89b2237ee95c380ffe55e3e583575de Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:48:36 +0530 Subject: [PATCH 006/145] feat(sync): expose the Pro Devices shell and evidence --- e2e/devices-sync.spec.ts | 118 ++++++++++++++++++ e2e/screenshots/devices-free-upgrade.png | Bin 0 -> 207787 bytes e2e/screenshots/devices-pro.png | Bin 0 -> 128919 bytes e2e/screenshots/devices-sync-settings.png | Bin 0 -> 172969 bytes src/renderer/src/App.tsx | 8 +- src/renderer/src/components/pro/proCatalog.ts | 16 ++- .../src/components/pro/proSettingsCatalog.ts | 9 ++ 7 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 e2e/devices-sync.spec.ts create mode 100644 e2e/screenshots/devices-free-upgrade.png create mode 100644 e2e/screenshots/devices-pro.png create mode 100644 e2e/screenshots/devices-sync-settings.png diff --git a/e2e/devices-sync.spec.ts b/e2e/devices-sync.spec.ts new file mode 100644 index 00000000..c06ec8a0 --- /dev/null +++ b/e2e/devices-sync.spec.ts @@ -0,0 +1,118 @@ +/** + * M2 gate: the Devices (sync) surface is reachable by a USER, in both tiers. + * + * Free build must show the locked upsell (the inert core shell), pro build must render the real + * screen with working sync settings. Unit and DB tests prove replication works; this proves a person + * can actually get to it — the difference between "wired" and "shipped". + */ +import { test, expect, type ElectronApplication, type Page } from '@playwright/test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { launchOffGrid } from './helpers/launch' +import { completeOnboarding } from './helpers/onboarding' +import { navButton } from './helpers/settings' + +const PRO_PRESENT = fs.existsSync(path.resolve('pro/package.json')) + +let app: ElectronApplication +let page: Page +let userDataDir: string + +const launch = async (pro: '0' | '1'): Promise => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `offgrid-devices-${pro}-`)) + app = await launchOffGrid({ + env: { OFFGRID_USER_DATA: userDataDir, OFFGRID_PRO: pro, NODE_ENV: 'production' } + }) + page = await app.firstWindow() + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.waitForLoadState('domcontentloaded') + await completeOnboarding(page) + const dismissSetup = page.getByRole('button', { name: 'Dismiss' }) + if (await dismissSetup.isVisible().catch(() => false)) await dismissSetup.click() + const expand = page.getByRole('button', { name: 'Expand sidebar' }) + if (await expand.isVisible().catch(() => false)) await expand.click() +} + +/** + * Open the sync-settings panel IDEMPOTENTLY. The header control is a toggle and the app instance is + * shared across tests in this file, so a blind click can CLOSE a panel a previous test left open. + */ +const openSyncSettings = async (): Promise => { + const toggle = page.getByRole('button', { name: 'Sync settings' }) + if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') +} + +const teardown = async (): Promise => { + await app?.close().catch(() => {}) + if (userDataDir) fs.rmSync(userDataDir, { recursive: true, force: true }) +} + +test.describe('Devices surface — free tier', () => { + test.beforeAll(async () => launch('0')) + test.afterAll(teardown) + + test('shows Devices as a locked Pro item that opens the upgrade screen', async () => { + const nav = navButton(page, 'Devices') + await expect(nav).toBeVisible() + await nav.click() + // The inert shell: core advertises the feature and sells it, with no pro logic present. + await expect(page.getByText('Your chats and settings, on every device.')).toBeVisible() + await page.screenshot({ path: 'e2e/screenshots/devices-free-upgrade.png' }) + }) +}) + +test.describe('Devices surface — pro tier', () => { + test.beforeAll(async () => { + test.skip(!PRO_PRESENT, 'pro package not present') + await launch('1') + }) + test.afterAll(teardown) + + test('renders the real Devices screen with live sync status', async () => { + await navButton(page, 'Devices').click() + await expect(page.getByRole('heading', { name: 'Devices', exact: true })).toBeVisible() + // Status comes from the running SyncService over IPC — a bound port proves it actually started. + await expect(page.getByText(/port \d+/)).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('heading', { name: 'Paired devices' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Available on this network' })).toBeVisible() + await page.screenshot({ path: 'e2e/screenshots/devices-pro.png' }) + }) + + test('sync settings on the screen expose a toggle per replicated category', async () => { + await navButton(page, 'Devices').click() + await openSyncSettings() + + await expect(page.getByRole('heading', { name: 'Data sent from this device' })).toBeVisible() + // One switch per user-facing category, plus the master switch. + await expect(page.getByRole('switch', { name: 'Sync enabled' })).toBeVisible() + for (const label of ['Sync Chats', 'Sync Projects', 'Sync Model settings']) { + await expect(page.getByRole('switch', { name: label })).toBeVisible() + } + await page.screenshot({ path: 'e2e/screenshots/devices-sync-settings.png' }) + }) + + test('turning a category off persists across a screen change', async () => { + await navButton(page, 'Devices').click() + await openSyncSettings() + const chats = page.getByRole('switch', { name: 'Sync Chats' }) + await expect(chats).toHaveAttribute('aria-checked', 'true') + await chats.click() + await expect(chats).toHaveAttribute('aria-checked', 'false') + + // Leave and come back: the preference is persisted in main, not just React state. + await navButton(page, 'Models').click() + await navButton(page, 'Devices').click() + await openSyncSettings() + await expect(page.getByRole('switch', { name: 'Sync Chats' })).toHaveAttribute( + 'aria-checked', + 'false' + ) + }) + + test('sync also appears in Settings as its own section', async () => { + await page.getByRole('button', { name: 'Settings', exact: true }).first().click() + await expect(page.getByRole('heading', { name: 'Device sync' })).toBeVisible() + }) +}) diff --git a/e2e/screenshots/devices-free-upgrade.png b/e2e/screenshots/devices-free-upgrade.png new file mode 100644 index 0000000000000000000000000000000000000000..1282c2594d432b215c9f68d532bb84616260a9d0 GIT binary patch literal 207787 zcmeEubyQSc`?iXL3W9|+C@3H)AYCHe0y7}3ba$tUfV9MbNJ$P13?bbiof1QL*AN3k z4}8bx5r4n;$?wl^ed}FkEe?*u%-MV2`;P0rt{w0~LGs3R((4y4T(}`EC8m7g0?yS7 z7cQS%y#%~MOe!jR;lf2GQ&G_ux)(0E#6vy`%3_J!^rI4eHt;kh#kUD!I`B|W=mqW_ zPLo)AvC58xt9rM?Is*1gF}DVt-i2Jvvdp>6c#F(kBtK+xf{^Ir-PqSoiJs2G6C@v) z9|S?aOl*(Kl$#7FD1@{r($2q)RULX=l(o9goa3qe+9S?vSX=f`bEe!)OO2~>r|V;$ zXRg1;+zw>PWmYq3B2`BE^Zh1~CsbY}aayE6P_- zZRa)qlycYoZfjv~70uL(v^J^79}z6#0o1&3-o5mB$7mz&SKxykyrgx{Iw$WZ!~)`@ zuc7>mHi>&J;YchgvoT4>xjPtj&J&v1gk zN)i)kZKoIUqU1-(%dwU(T)25bT1*7w62Cry<1v87v}{{DT_w4E>B0Nz{fGymQOpd= zA~tKfS%VsyaI9i8!eX<6Z=)4v6!6*0<0~tENx5s)l;nbWo0jK-3sTpa^(t&{mGLTI(~kBupLcl=>R<( zr_5&jy1%@>gD5T$-nvyjzq!fEia9wMI1Hh9^S9TREIykjGGd^h>FHOlB)4y;bX!9p zkU)yzWK#YMe|bGqah!e?^&#`#xHHcBg}=Y#5pR8zs8cjv^$-?d<^S#V6pVu}5%9sR zUeM*Q$Mf@5!c;La+NS@$>AzF(Un}_UM)U09LIRSKjWRK6wL6#d~3j zD-*FU)L$Xif^&dFPfe|8OF;Seu`wU(;54xJq`e9~2=Rq|Pr1R%2r~5LIxFfDg(HZV zN@AhNbS?sTX1=@}GvU1;O9t2tiQ@1~OPCoLJl88TCK>)Z(=x&g@!U*b+0``%<=|v` zDynZ4hId0vOe}Hs-^bm;7{Sxuf`DP5+WGM7@PT`Q ztK4u{~xh>v%B5?VS|i&L)m)C~;=o<8M3Ox`8E$)4Wt zTX-aY?FOEbm>9b5_XJ^jjDmrI!Oq@(;W-Eddi5n+H@ZylXF5Yg_jc@OHnL@VyG-!S zW*#y8v%4zmU4&68DyiXtShntIkrv1utj_%7X-M&z1AC!d4~q5JOwq3f0_#nrp0zgO zr!MZ+K4mKNrKW~aZPa5qU2wWUwQ;|;DI+Z{Ej_($VGlXxyf)a?wE1oCgXwhpj8pF+9nA@Y#R|#>oJ^F)G*rfJNkF&= z;W?j6R8_mQ($jR)r_vLewK?Vk>n@B{k41bv-Wo~e4*d-O@Ij#A=lH)-7);fi+;czJ zdR;YI;kd?SIXd8=Z$FHnuLqnU<;e$f_vW)YzfAm6DFj z`|`vWy2{FSCkNXc#@*A|LI$ljN8G2;&TPp~L$rwb95P|4>+9kGV7An>&O8Gh)ybz3B*}y&=e0Lp#CyT%#gs5S_&uXV9MZe)3Jr<5P)Zt&L z>ho_ZiGd(|=3uxbW1yFC>-E!RIT7s8r=)~;y&RKdq5BEOD==r6ckUN5lGI(k~4!jr;soL(jBD+im`Hurz{QLct2AYABnlf7_)&PkDQfS(dg5+{!!M`bL0K&Pb7sW+D-A$zR*A-k)@@J($H!_tbA}YI?n* z<>FH2Jk{ihLqJ)z-AtyUth{SJn4?a^Y6wHeO`R;B9&f7EYy{9He!R%d(qT|&K9s9q zy*Fx}XE~f--_#N->@9?oTJbp}h0-IwDTs*i&bgH#@lhSw{Kx!+rT%8W*IB=9);}6p z+1IoIja-TY71JKuAw{Av*G#WVZod7GA1fX*3Lk!ei>6wU2)G+8J$Uf8o-jIazr>^B zeF}X`Ju8<0yN|>lm5fJh`$P%?=3-2&iA6 z4$=A$9E^vL-we5R>sH`Xl;AsDz`l|MD_4hWaPZ0N`d3>*$hd81#fGRvte9xrbI2gp z7de}ykLD&!avGWz52-=)u0pK9D?Yn3B(|lAeMQLgC z!0a@Fc7YAhJLJ!zTXT{TXVu=*qnrP#9&p2EZq9gaQCtfS_Khp`m1Qg6lSNA>oZ<#P z9;5cgtQ8aZz}}JLUBGTx-02Zko7{UN5!(!yBI^|cEq=G% z5UAuWe3qN6y`C%AE(oJ|b=MzrvLYOmw6tGkDTdf44eXHB(cGUTXp8IU=-BP&HZ?9O z)xLTSk7UZb2*!=E@n^qQ?A~t}qfP6hG~UcZJW=a?wmsK^?kLOmIJBD!ca{txO&69Q zD|F7vPL&MvI$D9s@WV1DKp?gLZRMdn-K3Zpy0Z!oeAMb>wR3}{-j{36 zV%98qIw{1-iEa(2aiW?}31U%ZL5`Q(kCj+nyLL^`eUF^qX&v}KNGTx^(cBxDN`_qQ zQ_~2o%*@P}1$tK$w#5t@{~0WqR$-J#h{!S#Pq!>ELA(NKc9^;=R3{amiq150w2dj! zMzw(koTim6!7w2EaJy8??bprDsafd+*IEX`rawN#`XnD>+|>srf#m-7uF+>@EB%VFLpbBTiPEameN>9R3Gd&By9 zHAk>*I1rY0>wR$0G}a8rho~?C)Q1TiTU%AP!$wN4gV&XphIHKFLrV~L!X}a;1BTIJ z^C7E=iuAO!)(f9~l91_T2Iw@3Qncx?mmhKmO?2}2q355_l)bE{S7QP}N5ukB_giu&&nFP{pBhljt#1%4=4ztLB@M?^$)@7}5+ zU9Eyo2W!>VrHuHzDpcUM>$a1COH4&Wvg`s@`^y)u^TP%DKy=K*V_CQp6cW+|EEZ;L z>eQ(8v10!;3h-&H`>jp>6E&Xv87Uk_oi4}7QnoKq(hGTBXhw{|i;_omXxg~?A0Cg0 zRw4d_q6=!ijh(m)@Sn9_o^Yr%KAhawkr+dV?B zEyxGmV^$v6+RdW>2=F70rHl*N{BG`M^1ENN5(BD6PdVtT6}*Qdj~B@btmpTdcMGs#4V{kDVJr8Mb^( z&Z^j{P{pLlN@rGl0ht@^JcOR>1uanl{~k4Z6JKk44Q*s7o0q?yUD=-~M<4G*TCR!1 zRWTpGAX-SlXsDpdsj-TZ=aW$(81jz&W}XIPV2^}Qo5I8zY6c8=a9a^!jtB~jTP5U^ z(H(Mfrle%K2WZaxieyoL2IT!jb`l4S2%pn>W)%`h<;%W6xlnqDsHmv4w1?>wY6C8q zsK+XaZmW;e;dBeHijsj$Y&FKXC(&|oAtlg#3-(WF`vDO3gcpXA(=n#~nu{g*FLRD~ zXW=|b5n6jY-Q^BNua|o=Qe0-Ms_rcl@nmXxwy+$%>PEi;(T-Cl^=9@utPV(Hr}Fah zHgn{67}uB~7|LIOKo7ZXFt=uBXQN6N78hsV_4^P4QH-8dy~cx=rPbwbaP#M7{ zVM#TmC-BG`<2~f<6<`JvDew_fQ@XBk zLKa=^?RFm7{fvS>OTA>Q!y}c>n=Ad$xw$z)PGSN7_M&be`7&wDlukk~V(6Jm#Ysb{t>3%!;E_7+m zW7`R+pdj3>TJCFuaJyunL<^O@yRcU2g>ft2nsA<)a2m4=B#oG`c*@2WZk*1|`&S|2 z{Iw(vHn)uB^~#po0qoS0$diK+qi`={*&gsHoVe+r57nP6EJ#Ns>YZ2v)@9QOzzR$3 z>eATSQ}Jq7JZ^z|LE3w!yxTtpc!SDg z-z-U7h4j1ee9ATqyqjWH(lrRZVl&0emUact4_T}5)lS*y_M|Sr-O#3`6B-_eQpxc| z)3aIQ+2aTS;e*M~c(iN)31LQ&W zJ%UiVxn?r!s2CtMt4oukOM>>BX5rLe_B7LxAIc79gD9p*DJg~Ac1_Cjy)Z{7Cm!NA z6c>~2pSCxiJYB77`O9|R@6{gA)FU-`V#e>3X^CydKW>b8IH2r!W0>|6!Wy>Q&ar|} z#1+r(*+>&EpEQH#FQ+P#yMRu0g^0kC-~0)vo|=5rdRgJH(%_4`J--12qAAb)3C$9V z@D2bQd3qKbGYwQ9Eo@KEP%*V9r~9>2mCn}U;=X;+ay6)SMp!^=85a-FOH+>J`FR7a z*oX+~Iiozh?;Y*!Ht!xE@(ntCc8`bY_)TQ1r}QP6V)iDYuChT2m2!O1MkpDqEx&nG>bOB_i?2} z#2FGT8EpsB)TWQ^3b)*}Q!_>G5&NO73?=dV21mjr-4BPe|cX=}(|~D@#Av>8ka97@PNt z(K6)`zwnady-h5tJmJE^zQEMr(dP71h!;i^eSqAGqkgWTkuz9y5-d(ODa9-mH;iuh z)XJAzIS{I5h8TROVe3T~57BWC_4J48NEj9rbbPdy2Vem#Bn3c?q0hju z(-_NP+ zO|C#}03@ZUrR8j|v=;EcGSbpYqo#QVi}Xp%*r}(diLCa`JIhUt0e8$Ry53yG8ZEU> zijFSPYr1~uTUS|&^MV5r49txLfMw1A=1Py;hNjf&~F!Fqny4v95oOLI5o@HPps?GNt z3y9};w(3a~^!4=xz(Hhhs((OLJF#wpipPu0q%;DE zATI1xwstUPTzLVBMbvtdZc4}sMte`tr z^XHCe=1AVk&f*1BmI`!hu&PP`VX6C+-&p>$u2 zH3n6;wJ0rq58j);f{nemieRLrmEf!cFi*OFHfNwol9}Fc`^AcI@y@V0w)U;t#7slv zJ$&fK)fl_L9fM0|ninxL#(P2u^&3?sQ&PZQm0`T`5Gyxu68qg@7^ zCf$)T+0-e`eR;aI0N}`~T`}NdC?g{yE-t>mF@bgIk{RYI0JsgQC4Ls!8TFy7t#aGu zc_Hy{23S+8w;Q35>L%$JJGho|;p{@}OUlTs<#z8@P|?vf)b$PlxXY11BENNk9w&Vw zd)TA&Pj63m4P3{@Rlr@+jM;fZ*vY8Ayw%QKv4LSZ6q8Hdbn4ZAYmPmz#mozrzj$5z zFiCK)Vbpu+?xF@9oGrpwTyKI1>|I5C8C>-VBvs5xBY4JmV@dVM^q~euB0lfhnQq&a zR51|2PXPMwcr3u6?lR;M9(O;i{<>WqT*V~ddh?fX1uBMZs)lVr9Kb2lJ)zEbgxm&kfVE_ZkRya}l z(W6KILJ2Y%s5c4v@?Bc6In5wmKbFtxSZj{kQ?uzo=vkzAlyb9b8R=B-io6i-=ku>m=oDT;rx z&UjYibUJD?Czm3kzi%$T!r@`xR4@EcM9$=YE+ERHCMi1lS5&RyeW#iESO7tvy0&QP9tX=l$##=KeCsUd7W$ zZ4qPKueOG3`K?a3dTqnmgg727oHu9BH@6m>jF^Ek;Pxk1@6WxtGhzz0W8XEWe%>%| z=$}bkMVeb}rjeeW{?je|o?(u!ym+DUkab7Kg4OWsV^tJJ6g5@9J4c~eyhc7DB#`E7 zws>IwGkl-Z9Ln^fKD4F*Az*c?Uay#3$Q=c=By}LJ^nS^-N$*F0HH|kM>S%PzUi-f= z%e53>5LF)0e|Y8?8j3`esJ!uhV^;EuxNy$KR13a+EB5aGw1^qh!d!+9y*vT-AMM-J9QwXDtStGat?!*Dtp+02Q@c%u0=gf;p%H zew7)Sia3?q%iVWyDqC7@ic-z|&OiSbHT3*p-WpQ?Ju(7Aa>b60hz?-tn`=+v9{*aP zGyEf;v`z^_Z)GKQ=_DuZ=a+myg$?Iyh(Ar4!14<%or<$grFiEW#meq3x8%&w^GA2d z#%fMwCxcYW7!odE%~q6NHB+D~nf>E3%OmP_0rIRYgw$~|+uXk|&`wUA=6QpM5dcsM zg7kl@qqg*LVnf<#paNd)shI-N?KChmp+AoB7v0tC_YV>!X<;KoB1Kh{nvg#^vTvOE z-E!72!3>;98~{N09P z{uU?oLpe(A1vb@K%D?}5sHlkPcEGv=;w64sdZt9n`RbDvVeN6X>E{UaG1$-jaT$N@ z)(r=q@PrJ~ZRuUyTF;%xiEQxP|4hN?1A&K3&-u+bC{4S9&kg0zHa?m>u58OdCwjvW-(CI z-m`mTWWZ_pDq6-|9=cX=YywVtrp*PyZ15mo?K?VbhojwRKBZ@P_l8?M{F;?1q14}O z1kZ`8B!6||sTU*W1Y5Nn*J;Xp<6BDaNguUq6_R{HCzjCL>e9Iz`tAN}cZ_@D6g#!H z_Nd;FrcK6-IsYiK&f28z5@CaMb=Bw!`_D=izYUBRx`2(yOG9h)EJ{V3xR3kyHwPR9 z`fnv13Gy6bV-RtZ+L>lHJDD9Uo5HHD216@p&LtiRiOvd(k}_l$w7+L<7Jq{}1co-< zs1eG4y}h@&JJ#WnY3bt7^C`5@U+Xr}Nb^CmT_CO(x_3w648qcD8)SsRjxlsfog#`g zZjva8c6F{D&gD1?s~_$z+zcAx_Sl;X*FMUAdx~-`S*7fM-8~(!4If<{s`Pe#h3OUC zw7}5TrrpeQ`e5ch^=N3-mK3DTKfb>rZ19Cvw_a^gaF~y&?;wM9AAV-DbbTb~8}Bb( zKw?svL(PPH+sC89CU022s(|NVo?9u>z@z_&#{IO|DnRSgxcfYXzA^7TSg~|T>xxu!gZTyU##>)zWbWm@lb8;oI3_AEbLt_ zA2WzF#wWMl7j!?-8Wq*8V&Xp7iiT>P1v$liREmpM%IoxefoMwAt)}HCn{Qxk(c&%N zzd9E;IkAW2#^KsCpF)TATXX1ay6I~sRLyyh#e}W0PoD-1@nVQ-<;PE^;8VdXe9M!v zxk{Abn~kC*bo1MTojyvZ2E190})TlnZ6Vh&)BYl=-J(^P%MHZ z+ld%Su=6$?y}*+Y)M0dcxU-@B#vGNfOUNUs%%Jn3&NJsrwm**_rod z4&K6tOg1ePh8|aV=6`E1R!yF1r%Bq-KJLv0qc(2@uT5rKLL&mdelu3I5{{++)*dI> z{bZ6|;9*V>7o&7nN0e#a37u&CF`c$>ii$3V>+R`9K|Q`%6tI!YkJ%N>(nE@7sS`Yq z2IT5l6UsuL<+{A&COwD53oA=PvwQY^qZ((&f!K)(U#OLTe7sTaGU=<0Gl<^!0G1w` z8a^nmt*xzc_W+Gyt+d?kS5AC+n$(hUrn}Fz4SS2o^0Ck-rfGlU-qPlyavaQF{PbOa2Rsw`Cb%GxVsMJ3uUj2=yf9lQyV z55Bc*Sn^HFGy8~w1&KF9J2&X|NoRJAj)n@xHEc`n8xLiPqI5m??pz(wGpuwh``SIF z2N}JsP`>5b5O&A?26t`}WNksXQun+0#PBS&1;N$WE4-YTpjhAXk~m)X@yt7}RS?Mu z3C#zZYA5AC5vD)Io?LzdqJ?a~qQ(*F=-ksoDTkR-Yd)4@`n zwI5*~9CTblDQr{WmYl7SVH74;t!}C)C-ga%q1P#q@8SM(lKlp5HsxDi|00*YKz`dp z#i`mtZqCE6r}_)Ih}~=Bg0ETpcuWtMPOG;IF~*@nrL*3wUFC|1x#J&hhCBFEXb;EZ zximR1L*>2Z3|8z$$Ch%#@mhXD)s9)@RNDZPxA=y22`6Gn60Qd5LR~o z9G2fF{YrvS8;3l(86?pB6%57?>SSr>|Kw#M<-s}^cUw8EwIg}E`o#P6IAt6o2#wUl zE?pienM>^VHyqsL;Z5K)d3Qob6v3__aN1RCI1jVFfHtV&=U03rzv}w7Kd$ZR)Pe7T z-W1&{H{CbNM2Ac*;5E zQ@k#Z+P6;YZvpDcvW<_L%3oIZhiY(}ReCxMJ&&^T2n9bze-&mH*J;10QwIo4`d}jv{R3R}*8NGfeGGe4jL;_9G1I3)OOv+*l3hK>z zJ8xGVQVD1RDfG@)5ehe;BSzJGw7pU)w0p?dQrH8)&ZZJcxZvuxr4gvMaV*W*YN~L@ zlIy{G+YH4qPqn(dvm|z-uVK^InH3*~bWeFh)ki)J>$4s$OU~|U2_-lre(fFE*xFUx z=++tz$)FBd&C3{z1}_n)5F>$QBwHrt)o!rmlT~8lv)HIvR0Nwg4mt!$y{{%zGxw@! zhIq64?XLbKLc?5V;<_TC)R+x*6=fa3M-5yN);$>mu`G`}w*>ekJ<1^)^2`rP9jPcR zC^jV_B)=J@ru@?Av9nthD2EvNy&abTg32@>t*8HsC}}cW@LXM8T}de%PlA{55j)m; z=EqH$!%2rTZG#rSYtFCRpaUlES}xJgY$lSw6usmmp4%2AFu{6JNw%z56cdxgqp7)S z0(cXEiVj_VSy?|ajV}n{x|p>>L1ypH6Pr-8*?B8=KyIB=3z2{~kx#uTrxT6BK9plY zTF3c&m3#XT@xQ;~KYJAqYTsQ^7TaIxlXthqtYYwWI8enz=569zO6&XB)ru?bGE{E5Yq0+VjNi_xr)L`iiWK3Vxce~2Vjd$+Pg$(yb~gqb3^Wh<65HEa8CtjQ7QO*kjl9Ssq zEzr&}1v}&(2sD!DYsR1D&~g8G0*lARP@y1FG{dXReBo=g`MN^Q?r4?&C33D>x|p+; zjdn^l3PGO91$Cs9Zz6@ilEjmaJKT(x+LP06!eKf@U`(6Az4`a<`eigu2{H|h{Y|d( z<`w+BO(ps~Jc(jg6_tA6LXKs|2CW|uS?mpk?%kE$m==GE(K|i<4&P}5Mq#3{%o>dF z<~yhFFJtdpPgz>8&Ul(iqJ4O$FQk6s+bcPK^LVLSs#KvDY3t-3tB@;D1onf+5Nw^K zNo?3x@mDI)jZ9E{w4QN~sir4tXIem^uV24<()1-LkC;aR@onF~B$vMe7)M*Rq>Beb zNg^8sPNQQC$u8h_aGD*R-N>A|xJD>p03*P%4=9#Eqp9sP!I5RkPHV1fA8BsvX08$b zDar~t1}l*$PX|=Q2i}gox%V>4RLdiBwV$egqqc!=@ah^8;BIO;XXXzlQcpK6HR~O% z9OSsZRIU~REhe)|MVKF{>Ds8|a?P3CvJgf#yn34F_x7{bi3kx6HRz!E@bHGfm|wTq zl4$@to&;`3U`P-5CgmM#0qKF2@xrsQ5-k>kdE-rac@{W*BrOmtDOGDD!^nIuWBX$J zMn%OK5iU4p3zfTSCor}vw(}$x^>?LA($2syR1I4o^UGcLH`*xsmVr_^*d z=%K4rYx5P>wl$V-*>Kp|76C#Ak%7sFEUWB$x3p8{LXDGnqr6gdk8_(f@i&LZxQGSq z=b`nPdw0>ej4XPQhEg7NeE9=mSogt@{`3`5P+9-`)XXn3=%_4 zM_|?#MC4q_N1d{0!AF9WrrVk33V&V?fV5vVzL(7aO%coR9Wenq;U1-Yf@;{&K}WHt zY#Kt<5-*>&3%X2K_z;n;;NZa_mIAO4u_gQj5Hu^(IG2K}sjIM0*mlLkIsr1NpxoTH zq;{(4jYhcQ=4;#pwyJlvx6kQnlc4_;?*@anwKtKzY-W#809w+y!@JpB!kL1{dza9O z+dRHfMbS!PIL=MXFQ9tNaj?Zqbk@E7mBUz@*m1oHR0t?eNVCNt_Z|qI#p4Z;$MnyR zOCG(IG^7)G<6T!DJjX6*y^`Kk)sC#>pwF$ulc3lP?Za=+VvS*8VbzYSl3kn#^Tup0 z8BZ=86ghBrEps;q8K(mf+uny2dK%MMqrDFSYLwx~2VCTI8KnU4UdUbMom3`Ta=+E1 zcJ)H4(xf_@*-Boj zdb4tVl-{2PyUgBs3&OzUcL9={U{C(n~IT?;iW8V(tfI`y3B1iDBN7T`2^9)-_M6!cTm z&-Zx|DIhsyN>X}mdx2;<+_#!gb|aIV3RA1y%|-wp^I@V)l6@{cYb8$}fNJr4HUl8H z&gYvs#8)`ZUe5jz_XOaF8)%SQQ$2ln`3(O6T74p}DYdZ1q-e(GN4j>C_~uKiEP8ro z*izvNPSIwG7$O#@Lp-&JL{iB$YXberw&>_EYc+(_-rZ!ZkBhjA`EccM8Ypfj1?>E^ zUfSAZi;rv$n~;n@_Y1HS!8V9=4|7>x8`@9eJ?eTJ!hsX2rW2upncoF61WG9J7eShu z!MJjJXN}^kiak9Hc09Q91$z+q(kqAVj{&HpwXw20^&08{g%3T;tud%Z`DW)R#Z!lF z^~0~E*1|dZn?UV<%{WvplvoKZaHN3WlR6~y$~S#>D5>g;u6oLZ`C%!`Dw`N5G1M7H zSU!G|LXy1`!)4na-l@`V0AygLDh@fpp0<-5{2LY)D8m~BRu2e#Vy~3*)R}BLCGbhM zlw7?IQIl=A8#Nr42@DVlY(QnBC5G5X4f4sFMdA8q)uu2jU zw>oP~-x1`QlK~266v{!tZvqW1G7s)iEbad^Cfc&>gktC9z61T18~No;ou-5~Es@Yf z<)H2BoR%lhpzAln@GM6@O%ZWUP5{oN=%;7T^GYuHa-q>BLTi{VY za=LE%Q?wHN`L}IdhoBEvE%|DZ1}PIre{=skkO2rx*{I%b3a6-4c=gesQSVcBuhfpucH#W-UE~>M`QhOdpC(jXtQ4c zL`MK_trTlHJU-kH4rl=wfWl{cIvQ-NaLEb3x}>N@_QNyhBCqgG#IZFIjkNJF6J5FF>pxE5#R06qMS(gmfsu(rYUB5 zkNwtIlGAPrO{F)R$vYTL0LPQu79d8}jMl!M%x{BD=0FCmi&eh$kn_}R*RsY>CMani zS@T7|;TT(m9#G_b;>Ve03vp=q;qZg zf^19W+*P`pK_MpaPGhZok)(B^1_1G?#nkx?1AEgf{tJ>cm*(h;Z#M7 zn4UXcK=Bu4YWJdA&214f46p+vQaS^Lx!;}xrN7^Q!KIG=TQ|qrbd>EmAp5TbjsU|# z=C&~ORwVjOhT@3=33LSR=H`J;tsS^;Z}%i;>tp_{1atCCPSoBL_tr-!yOl)FN=2%{ zyr_$Y>F&ll^&eK_u;NzBO+csRcScscPZouNubT7vknL|C)6 z_yM~S%qUUZ&zQcLh@8m1V>MW5xdgOlAq&DMSrNP3hpYQy!AiD6YZLo%~lzfBp ztzEpA+uG$_c1QVko`Q|mjo=fD!%qM%+< zYLf+b0RKmSSb6z6YuI-6Bc8g(Wnb*WA(Xwx3x7%!izK^Z+mr3pz3E4Gc4Lzt6nzqS-dqst4UnJ)(iA5KLQ0;s@ZGF~ogKE0c%) zhYD~##l^Hu0Y?+%JltUG2$8Yqxnu6M^_TJ-aC1vd8N+Kc)2)s<~G7c_Y-} zgVn8>-3@X%;nQWcyf)wK*^esA?w9iDnvzA#F)X&rWS^Fkpv}g%)%3kzS1tO_Csg89 zUUyK`;j2BJYmjxrkJ&6ENA5heGuF@6VAJrNJQOO>-mSdfJCENi?mFYTYmBm}{6e~+ zRC@ZS65=xq3kQ&)7f)~S+e|#*>vSriCaFN2oydk7m_5e2iUAXZ(yxNTJ{|aScpZjO z2}k$(oj{B1Stj_7P`H)&4%<2*wSt`_>;nA|>@y;tgtI4B0*Dh|i5Zks2TrloxI<~l$3roE z139co8(>G)QlB|~D>k*M)NF`_dh~$y@cIUyqZByl>SSmqqv1{~L#I5iv&7=3cBj)L zfjKoTp^Q^A+w#a2CoAc<+a9_V%E(3$!23JcxH$vzp@`xgrYlD{C8i`JY`Mok!WXhB z^sug{Z6|u2OQ9~SFBm0zeL=WIQ=`h(22Ap%o1Eu#!m@*63bfYjhyU^(gEexZ1Q2Gu zv@wCIA{7jvj$4y~N&7cmhM>#_Etzj^Q~1>gpI<_$~mgB)xd!)VpkdV+Q?|EZM#bb3=X zfb3>qG#Nm(@FW((_9l^hjCl<+Vj)2i)r>%=_1K@^q5=7UiKhrCA==614m^9gsvsv& zQX(p<>EFN1gkpX0R@{wR*8ilT0leU?d0D_-GY2wvek}gFl0ck}E17BlSEF5T#s74Z zCN6e}gVA$vQ`>XqB}~2j`j5P5YM|=?K7i<{?I3`4kAl=CB+s3Z>w0_dPi|`N6E5sN4s1 z%Zh?%kPi#Rqbb_Bk5v{dg-U=riktuP0S;c^X8BH|$RU{!`%@-?hjNGa&OiJ6Gm2fh z-j394fo6vOKh3c~Grg-+QsCZ7zwznGA?zX^>CHi?vYa>qi@0S|4``WS?!lHyhsnWrYMk?xetqXnig~5GDoUOH(Q!>F1grKmWR$Hc%e3ed5Yl_NUK0h00T z+#E1|{#SJupi~c7S*`mskn@HXAo)By#Yl#dlMpYPsmMu7OG`;zB_K%Rv8CQm zf9lLm2pL%h^x(3xvT+?1N>+|9p!kCNeD@%bv<1V%$$E)%&Gj=;hmSx7F(ULeL?9Pt z^zM)C)Ggvvka37GA)#uvBcSWGdkv6dk05{oC}JdC>*EN(^Z4GKzz+zdg8*izjytm0 z=}r#?zf;vtH$Tv|UrS9%0ggu4p9>SFrKOz&6bnEz0yX%az59`TwK*DeX( zaQ_M?Kqg9hbI9{77GNp}M%hxcur~wx`^NK@ch#^+MhdF+F_8fSLs&ZM1MD-wxwV{I zKj3IUI6nmXL%QC~L={d*wR%5N+?fb#Z_E4!^ z9MrVtocsdLR*j^U=d+t{1?CzUD4?jTO-Y#rw8(vVQ9Y}GniOVQT9>O`spEBMcxVD> z+72S2v=3;kgPN4rx&Rw9WMbit9?7hFid>HaI{!XD3IPDibfuqj(a^aU8oq>8_{+Yh z;rK@*(W8TD@C*o}n$^hHCfjS!PUFJDLVG*A9946`yrP`$xntmV3vaP-3~rl| zL*DgITmsZ-p1?5#V@0MkNlp$9MJbD^U%uSuiUj*422Dg62sIDf=oo$xN`7={y@+YSN= zeGVR;ufD7FgUAHXY7WP0z%RubRt!A3V4smLQRlmC0P17=ko4|N9x1<+uKEAP)A(PE zn}|xQvKj1H=2gY?BtU%1k7FF=ygg_u)e=lX!>Vf{0MTJ-X>R_!m$eH76ZHCh?-|8qw%x6fNV>26ap?bfe3$RZ&*I*%(kKD3 zrbKN_;o|8<*vPNQ`oH>$j!s}2qhyw8rOe(0XAt(+Y0kS#^+-n@739w3SPmb1QY z5!?1d+MSUS18hsjA#Lc?BCsKyHhq2G8yOi@>?pQ6paHpkl=mZEURQv*EE~g8z4+Pu zE+-YLI2+9ckhlQX*bFLCfhrgY9o_~QQyBi%sY6fwN+%0KcWQnlNCCS!)rEw}lq^ib zZ&YF02(>e0e;)Il`R;-Uo*5D|u%cZvmxp26OUrvR53G>lTpWM3SGnh!Ln32<-3~-W zK(ssZsj#=9;W>%X;qkGgn9;it)^jXCtTmfwQzcKQI=GbAZvMlBd$Gj`J76t;|y{`*uXI+{@28)2c=*dw@d>H}SvI|}&iElP?$%wLFu5Ij!# z2W2C0a$qj8`2PKSUtFS45)L39N%)lm+N}>^VJ+{6vP68a<&%Uk zfOs8J+J$kyEx_P23WIy!fr&j;>AGB^<4O!#;y#b(f1KnCKLWe~H<{;r zxMWo6jO+H-5D7r#KV1xnngPo;%~pkP4dPSqog4vfant*(Qu+=tF^|LY_c5zVWNzig z*JB=32A~~S2M%u0F3@YDOY(Tfs6Sft+i4_<)2rEeLN?QHWNVu}qI^$7A|h_5Cx}gM%kw}zL_JJz-??M>WOX7d6JZ94 zxp;9>_$@1Cmkp10?uA(y`g1zkpYH(9sl~QR(;PxJ1SrUw1-Ppd04GioP~oaCEL`hE zD!!BisO8!&OeFLV#^LNwU;K}<;g?_h{nZ{BMRXG{4JT2TIRnh4Gv?>PDd(Q&Kkq=k z_y0Yq8jx-=ZnUVt($d5Vu=sxxAOCU@$N^p{q5%-f+6q(t{TpB50PFSJTd9HKLdWm@ z`^+7(y@>+RNaxnp){ilO08~Xj@I0LTcEZ-rCHi^r)(@>8NpO4A5Ujo|=7XG6jVKr` z6#KaqEAr4;le0m_eWMb~=}iU_I7@3|q)?ItkAywV)H~)EkJsbJfmJ`Vq@U+l0Ufm7 zH*YQg=ZMPT`zRe+IUcFHO2G!6`m~r>J{kRzL>IPq=|5NgKmR4<+mmZewGkt{y9=a|sKJm+TQcp(g{w6qKb#5G|hCo)b0@vbXXZv;V29KkXUe zy?^;U@MztYZ$u^h%N6*jSzrLagQDRWfOt#Zyb5*~f>m#saV~vE*bboqv;{ zm^IM@y839-C%zA;cdiZ&xN(rPmYHE6?x?3pw~89mtULVM@p#3AsrMse>gwu%Gb)0b z-f>jlRJs#ElG4fN^X}dF|6%Vd!>UTVu*X5gAPkfS5s^|rx)Bu+B&0-1L6B0qQ$#@N zMp^+$DMe`zP`Xn(4u{S|_qVnVj`NN)@B97w{(N&?b8$pDoW1w+JZr7{zSq4dFOGx~ z`7yBRM+GGB8q;g-8yWre{(t|IV4fC%w%JMWpnaaq!`FoG_FpVvha$fL)xZ7wiSVQ; z1Mq%>x&Lw_eyFwodP@HFPyhcP9r%BB3-~AQXWy-b|LSTPE}c#5NOidBeC(-qVbM5fbc5t@%He#)AnQhEQBhVF%Y_RUE?uhk zsEXe%`K7ap)o;i$Vx@=8zs3h%3oibTucXc!Z6gtPOv={QHsvBe zKR-LW!p3RDD3#Wl^Cq>mYGMT|MaY5d4g*}sWduD2 z34c_Cf9((7x4#^c#tuKM#n5w}ot=*RZgl3ThaFT)aME~rdC@$F(se$a6uoxnucDg$ zKmDoy?djQZ!Qy}n=JXbse|f8X=ITSF>~mjE)-A;#4}c$GH>p72CgU;*2aS`M{DFVq(mL{ zS^n|*{?yL*zb@C3CG+n5?Vf+N?t10g7gFP*#Twe!iOk)t!M(Yj4{52#1qFZkxPLtC zX}mjWirI>|{)b+WNPAW;+7Z`@Dj9aadxJSOxUNjeXZ5JU9d|R0B8u9pmw*2W{rvUE z)5>zKaok?40st&{y7z{Nh!51%P}E1TXqE$9ry%e*BxFA~{jrFx77lm=q1#ZT-j{>gW0kGxNtHOi`6Iw6tjje}mHY z5BPl-@O6FU=OPF=i)^Dw0 z-oUY$2AG|a<>z0otsg7v33&v)v-94a2=JDYddV^jmN(Lpk`Algx;m#)2NQF0mcc3k zyq@e86oMluJGnl3aQmcU|HDT2`_^g%;9!<((?nP1AlSAszL!NYQ>LV(z{iT+qI~vG z_xq3a`k>@DzV(;(bNS;g*c2@4OYm_ZDXMC)9{1Yj$~TK~$CcUFU?oFOKMAfK;C28fCnRlNetz>EX<@uV?aPjuAC&Pa1)~CD zV!}fd6%^XDA5;NWRC`N2fuDww4eVboUzU;!gC>0dLpeFEz#-7ZMSa~;y5uP>J0{=( z$82w|pRcdgQkVQMx5W3`0(!EUA^Y<3^0|_&IWY>`8>M65^=1cm8ojAJmMnEjTgXSxP1c4mzF9?_s8F{GxgcOjMRLxV}$9El1j-RQ4e$?-u!op5A82W*I?(;q`4pEs+F}8{Ns6(PaJHOkPvK@PHl(Mg$HK;A zSEr7zOv@;!U9oorTR_(9?Ya>h0qG(St_fJ!Gqmgo6nrOwpZe>)(0)(q1hO)wlj2OCgw&U zU1U!I_Nz7s{vOB{3B8h*+5oF*4@%gNhF#M6Vi(g-3DMKnM3Zi9Z6V)P2TLX?@CjNs z+5LMcfqjlFTiMGt_0TID+YZ(+Q1KCGc9%F7diB<=8<#yj)TL&vmM$;*X2*;=J_oL% zBqyhR5;r4%j0Ba&nksL^XFBp3&Vdyrz-h-J9GWaRQWb_97fdE<>`W=mNs5SQY7PT1 zL%*cc#kDmWBy&;nHVtZ?;+~CQtk8}Gkw(%BDL;Ab9JV}k!Szlid-SoQJ+C+n! zU?o$BGY4RT_4bouyA={ahjjqU7gHsFLsRGFXIRw)(o{ZfHLC2<8#S-4tBd5b&S-Nn zyK&?5D`8h{Ek^TEc-GbY3i-1izzuK61$(Asel+Y0Y)-9!%mit1@hby>e%kir=mRHI z!Mhf5a{*AA=DAuxx|iX|h26@^DwmiBEJ1)c-D+>4tKWVp^>cFZ;9G~G_22=%b%w&z zH2Fl{B@5-pK^j2Zx`U$JdC&BP?j=?#u3CD?_bE-Zty+umH^e4bfuFHhy?2ZfV*Y7ljoW)nVT$;MbDNU>8t z;(7kP&m3uJlAnZm6tXF@;{xz}!D^AcBmxeJn3y@6_YIL;y*|B0`RGvC@{-}KU%1bl zfy2`te%pZUcr+NuOeod9l%X6TZiwdVvia0Wb1RgNi;UNT4!8nmOL#CXEiD;?B4aO3 z;>`$}<5#P;&JizCi}?oM;Tm*!FX`h`I-r?74n8s3qL-}OzLb9jlj4n~@j6wx(uXr& zvjdeyE3Iw`>nJlk=;ko&v;b4OQsmfybz~w%$qzej-C^%IQ{Gkg7DB0)L+g>rrKQdQ zjmFxH9JLKo(s1lpX;Io(|Hasj{Xo6&lKB%}2?Hl*=UhZzpK4X40I+`Civ;yC0$T_; z<(2fP?(YJjA>8DP!j?mGBIPuxr<^>og+Uw;&IflJW;e|3iYaKhNYiQl&2_fF8ZJj5-Shs+%7}*xF1(cq{QZR6|Dbs`Ab|hk9_NOzk1UjtA%2Fg2@i zgJZy`8d+I=+)c$fp(9N~Krq_4Wi`=U#(lDLxJVKj$6Q=kmn|IYZ(lKJPlg9IotdHh z0dcjGHrD4NBIkVc5_9-hvEYXS2H9g(hg>%{Hmtgez!kVsDb@i~12&$@x!A;0;OgbL znZ}A?6WmkdGVHvB3YsIAnbSrLb!%#BHq(^UZQm^RleR%Ns_CF88pJxMu!1}s^yuhB z=732BF14t)$v8RTrMveXEaAA9HVzFB*LUbxH}1gx)$Mz-uU>jW?ht}fXfgdV5y4@& zT!-1J1;Vwzj{;83`wktG;nC$46+X8FXUAfEy_3}BgfvWgUTT-WeEBlDl;rH$)lXRX z$#!gsXAv*VxfhUHfEUiLffQ}aRDWUaVh6Z|G8zw|elZjV^30f!7-YH(k{+aa({3C< zb^Ct?i*CRl(@J{)A^h~|>RWci9ypVEllh7j;plaUg_H2{^4=xt_j7cqK5#g^6G(Fl zaSct%&9ToY?x&I82DB^HDDVGs@%**PzgjFAXc&3#~g%*=>Q)LS5r!GEQWqE*>(&k66Dt-ID~)vqZ9lXftv*?0 z&RElK52RD>fL+>RttH8u=sSj=pPxiL$XKJK=7 z;A2wkZgw1w_VoP1S7JYrrqpg*zbLtARfpq4tDZ?)l{7m&BN`Gv*yEvH?JCzCb zguJLt>Wi;Bi?yDz%!y~gKxlFW4xz&F82jy==!Uxv0%-reeV?SXVkCNSI?TS(X1(t*AqeP(pVq6dC0>lpl}YQu|1H(9 zEzxIDvC#YqE(O0$d$KH1LC1>z3^g0EAT}5U~8l4ZPy|OmS^5K0UHWr`MiZ`Od3cQjoA^O*a7?b;saQRlOq;Q zPwj&me||-79atpH@#$^jZN3)KT_$CVpLa_=P?R%w+FMNLi2VSbXi#9FcV=Z(m6(_q zX=XOx;~knXRSYcikl^5IbaV4npIP}%!6+Rq7AS?c67 zH#cI94w-<_FMjkDonYfjnW_m~&_h@Uh?`u?IB?aDP)|k}(W_KLt+5T!w(;ea_{3ho zf&cup`>#Mh77N}d3de8*njZa~`VApVJk4AUL!f6S%_PR{83^bzX)SX3f z_(C&vWaqH96mGTvPV+=2WLE7qlC2OXm_S|N{Nbof;X$jV5x9RTcJFV^HkJwWhb5*= zrkk3Y0u{A^n7>(0-yseZT=;|qIRyK_IWT!V$n^WE6!BK}JI2SwZj|e5k8+ zGRZQpYC%CQ&xAuMSOlz4hViBAtw5Cx02Ry1AO%TpW?1)fkc{BuvKm;heJ?;8-dvBS zpcVLe&;>uE0K&4!TwK5E5!UHAV!<%gK0-bijh`kKc3I`2gW_RxLN$wk{>P%;Lo`Ri9#2;1>i(IO55uARjb{-0!uiG$E-bL?JL}gYv2D_(0)bSgO}@ey4X98GA7C z+GF%q{8Ynx4NcGzEZ3QJ87L>${j_XJCS)9ch}j&Xp8XKa#YpPSkvl0f^_1ABb9Hw4 zTudNWEJ+550<18QIPYR#(HFWrxq}e2U?Yec z=8N2RST2BKtudUf%BF#GcOfj!DQ6!G@DTAjdoTS?*Zkc(cP?J<0An^j$7%3F^~Ppj zuL6(eVz?5gJ^mm#6Ghh;_X$7Emu zCEEn;bETdDQRC8`RbbFNESkjw!ot>S88fTdXAy`7f*2?og6+NIWr&A~!z4fIw~5ZV zvEmZDT1~c!ICp4e!x#V)7?J_%6iYk%@Ng$UGocD{00n{J=F50dA3IRj_{?LrmzqE? zWCf9kqu47aq`dW>0b-OdSCXg#2;>@g%0Bj^5O9Jcz6Kp(bVpfv6BdQg?mDWH5psRy zw%K1k;2#*-x;l1YP$O*=&&Vw?0ABO+2Y zHlTdZgc#oZN*GZgLhS-M5@>w_0|Oy}yu=KnKl)V1^~4T4K=}X}y-i16ktw7m90HMr z7g@2{ne5pmZr)@mVL(+UC(|Y6M)8;@O1h9|hBYmv=MDtso;`DBQYk~dSo!Jl{GhXc zB1{T_N}cC+I#`Kzj0K2MbVE&@s+hKH2vj>QAUKeeP4;DK2-(tC+_tE^Wi9wtILYc;>t3J8qDBMlQi+_d{AC9 z2wo#=0WLB;rWAk&1cvz|GX9Jm?eox2L7=wATTQea*}Iq8cniRz5_G7_9Gq9mgq}T6 zd}x4Z0ydJifqwSgKIbZs#Q71kRqpg^0FveVO$De5(`|r*alGQNz5tte0PJzC#EdLo zLka{D)7;e6-`^kbQ+vp50O=_5N~+{D=zfQWBh=6?Z5rz$ckvFOUxb&HwfU(-sllgp zpXmAX>plx`fR9}sipCZaVt1$`g%g7y9uu+Le^RB_SF*h-@);g~bp)k|_>_3i#r(U!Z$t2cSu~m-#y+`V zF$uo%zA;`Oz68SvAn#Y|aQ`Jlw9h_$!n?TUFUk>0Az+78udJ5+A0J2J^k_c?KkM-5 z=>9~hNZ$@HJO!t1V{`zJ^wu#Wvv|giWJ!R+R{D}NqFTTuU;iCoJl@{kZ{NNJ(Ns!{ zR2wX6>oIT9bmb3ra^|3R20_v=Sj5745rR*8_G~9;OV`(JHijURrvNCyfyZD+kF_f% zBcN__auOq+n3z}_CX<+fN!?aaOI?|46BEoc!~ZW8Z}pL0SILn|z4ndT$u&3cOGyU7 zN-G_7^DlgSEKLR9@DBf~VEvPm!6N=o3H1*q@lV?29Q6#b)M#W4rI$OeLP3&<^Sbpek3#qDU>PB zyS?EFipKi|=a+{6iN5=$^`beSdI&^(@|N(6*e{VQt}HEdWintW{(*hr`whfEh}D}T zwp6bss6RdHpZ&{a6imZ*20^KSW$o@wa|l5s0(Lp)&to{@o{SA>-d4pY%V4LP)k}ZS z300~5=&AqgFWAp60Ym9OFW&+aDtH`&ByXWvP)wM)ew%EuTgv0%%nJE?3lHQ_KXSd+_H>_|u;g;LNjLA{-fO z*6A#bSA0e{U1ke4__SHoLyp@^Qx1e#DeO2bPnKI&B=Qc#Z}y3?FXPTV!XN)OxbDxY z_m5XV(Kgo~j^AXPK!)&;WWw~P>GvxRmEPe{#sx3u3mkT>IL4!w#27T$2+)9&kk>w{pYsZhaVyuJY~Z@ zd1Fj!j))OL<_;pF9Y-CTDQ<;yzm@4ON&!0sXT^(sP_ub^dInBD1_ft?_9$3YTOs92 zZS6%6H6=#H#xj!w${YXO;rF$E7kxe(lXANlRLSl?UO#_jvN=bz>i~V1uKFeb+9@Pg zWo2cJj4~D@Kmoh)Y;At96q1cP=2BJngeeDPpdCbWL0;ZXu$ou6A?PAzf57TrPL=nq ze8VGT%M?(ppy+)JeuI!!JoBzDc-5`GsYymcf){cK80-Z&BNtT6q7?RiL|3!`8dY_d z8odmQcIN8e718?z6$}t+_ISDZVvJPp?LB9hkh2-EvcPX=YYANma129qM>tm>I?4Fw z)wd7<&OaYofQRzFj+Hzrb|`&?B|Z_*B7eZz>$*4qXfk54TNo1d`T`>BFo=Ok`k1kSWE z?$M)@-EF(iolcDdRtQWPq^)mt3J^~1zpqg102qmxvyT$+$+YLskL>Nv?-2xqL-SSEzXq}u4jmmG)}7$voANWW z+7BNZ^?po;`3+DY7h@IB00Te4$+h_7KuuAXouzWd#K;Q(fT&TH3k<3@q;)l>p?)hsTY?`fC~jYoMJN z9uvcKT@saS0oQf+N<>6N0VK(=D7=_U?m8O+E7hMwby_prvzExB+Mfz}Fg#|`(v3h~ zqWjMT)cg;Z2QbLzVEK!F(#BbEgo6m!hf+vgG&XMxDgVR5YTdAU($TyNkL%pMQ`b1+b1ZN>1Q|QVTL>a(J%%fPzg0MqCGaTtOn~` zKg#pkjT;A<$hb{DX?>`Dyqr(vO?2hj;drPBL8Fj?Pc@}rYPtx_IJz9N$G0*fns8TH ziKN8EVRN_Oa~3dDz5b@2(gvi{s$%Ob3Hsy)?WG=!+|KS4Sy$*O4jzPv`IqzQOngHx zOb8@oR)1uz1K16Wk1*J5U7hRiHDWLbj*Mhv)>+oKj+~g#3lFW$k~11AyN~o-iRNPH z;n0T#IMs!^w*Y4k>u|{Q3aTKc!>BD?g=;?Mg!mlbv^x9+p#DC3`eF{W%bW}+Ld1gp zC-;H1pUVm4^^j-Fn3Sj|H+xb&8AKUqnb0xlh6hVHKe9ZE;pI;nu<5ZTKOA3~lwHWo z$ETvN(hC%&VS$gg>PnfOw6t&P220Ay6{qcW=)ifCmJ{IspN2KJ9x?WUlCYBVlKC_p*a zD1GY37~L!^SZl84juPkJ2W0EtxpN0xu27LCOzS3L<{c6D!wq0}!}a&)JHi7~SC4U$ zpqn?ylEzA~|rfA-24LM772wG@SV#$Anjp$N$IW%r0LysC{k@l-XumasO)>k?Cc1 zsUuD5)=@WWnU5*D%6hb&&Bsa;)?qQ=uLzv(y~Rtc%8;umUnzX6za5GmIB?*~mVv(u zX_|$HLFOHpf*d}sJT+J!aBf4ZS)9rTyNwW~D2h|OmoGmC(ggEcFVwEETIr1eLIb~3 zlc7(!bzyaGy<<&lwF*7Y8a-x*ffF4J^FLSmq#vdya`abbxbn@91pSSn0zv&*VneOZ z`HJyV8HnhG=edthKaBCLIZX=$l@G16kk!mx0L*bHdp{T^O-b)M;o;*m)6so}R*z)a zc!Od`cwzvsYRlQK&fMej2*E6(-#GN7`i_#L)2R1Czi7$+5$(n<2vI{nk~?y&^U;pK zZxSqoCLA*3CBOk{6cm{1V}%A)G{j)Yu(Su1c(_oex=kDB32!7XY1(r;tYlkNv z(I=^4TMv8z@s)E@mAnDdw@Yd$#wsBOq)}n#Pet3e+*I1ayhBl`L$@ZWXdciSMe}CZ z2hSHwygCXVHjp^pzI*p>o_^cK{O0$%GEJdT2M-_iCFPQ4nt_fM9-i*?i)kGUU0UsSck! z>=!PjG`8NiAsa6`DO1TM)J@A{)t3it ziPc#cfEmn~wc8Oki~mrGAgUkEW~9aQ$`#WM3>uCKpBFCzieF(oa<&=2eEGsXk<^0l zJR9S&b-04N>kn+dh0#~^*IN-3b?~b%Whq2CNH9bEO6^t*!kuc)B#Wq?yKO z=5o5q$Il;)DWUCyfZ!SJOx@a5SQrRP`TYB!56N34>@dYrd-&Mx@F1+&O<*E}Z4# z@Tem8E~i)LvszZ@@?2H3cJ+17tTK+uYU_2vj8VIGq|NX50H=Rk|DZhL(9;&2DX^P#KfJRN($q z|L|~n`}xswfKYU-!}|GZ@$z$W*llendCU@88}o{-W|g;}JooYnYAT9Ja_8cOOie<< z(}5KPc$l^%*rJ5$HdJ^^azWtG2qK@E-rOe0Kl?!y`gS|Y%`9ijE0ZhFGawb&NFqqeJxcO><7Q7C zHt_UEeIduF@s*o-X|Wkp92chrPg8=gOzF|1w{5B%0)h!iNloP@aiLFcMQV(O${vK|KxW_mI)4)_}0=<5<);_H<45Q0o^#x~* zqGAcZdi&{X7Y6bTK-jIyN_EQT2jRDNT|kpW$(pOHt3AxlNgvH_oO=jkP+@_$%9n6N zIgxFw1~3P;a$=zZK749;e0O{dm8B4p`JuJ(z?1Rw+^v*@qab|DJk{0fitTsGPIHpl z4P9bioV7jddcAodYC%HtaBbn)i)J=DKSY*jv{yTFK;u8`z8A)oy`hJBW1j zGRI?DMy*XZK3o_5gc(mW-aB=*fzV?GKsW0)DXy9Y5m6TCpWH)+Hon=JT!#~v{_!z3 zAd{=C1FDAo1UL02Ti;lfx>dg&1(9V?w@3dhbcyZG<_^|UhC}bF^DLNW@)m)h%1l_D zSDMEe@)ekjb&X;a9ScphZSdLjwq2Jh@Pz=bf4zM#ZA#23xfsZ4Mx~6L!rcb0!R;^1 zPQ%L+X%c*FtsvpKEOczeP;hU1$%E4At5IfZ-_Q%7E=z}k8b|QkzS|Df z;_3+xjBZJ$nF0kr}ePA3~!$SwrncUeE@w*@Qx6peCmtYx8v5 z2x!+eXe9b7)FsPi)MPiWNF$2Bo$jGo03%B>(;OaPN-GoWbP1OR`kjB_U4jeyl) z8?MU<^bn{J%!Kt0kOm+*sAR?5Ux!FjGv5KU$;USHxRSlyt?91Jutr}}_pRQn#)c}* z4V=<#%q`pd!Nyad?`xP0lM|$b&qp-_`kvQ)Lt5bbARA|TObBl9J0IfAl$E#3n6_b1 zLxzeep*r8>j6cSKqyq*Gy`Mgp8JEy(f^0>U=U zt8LEqqd*kgEs(5*3jLmaL$)#xI9>mv(fzVX0{&3jIp2EV60<#lNRhV{!$U(u0|ODr z0|FEexp!@3!d>W=Jd8th&Y81U+TI3}uuT*qeq!tf??zM*9_5`LcKKbSFnlI)bZn4@ zteKfPXsN`*&84EaWlM{<2Us=B5AK);WtnLrx(3QZY3Bsk7KQMZay_k_-CyY9vBMp! zyG-W?iZMzBD3e+&Gs-vl_3v+smz>1}WP9G;orE!9wO^|zGqIV>c82ktvkDhMx957# zwXZ?5!o6md{^Y0{Oz$Q}3s8&(-b=!9*TxFPRUZ&JR^26ADf#aGow@aSr!~Hv<4HMZ znm5fJY;2=%(QB*$+~2%@fF|DQp5R-Yvnz`Bb46a#CAebVL@sreN$q)KyPTKQVO5z@ zu;h2K3I_GwN=DKFu7^^TgYR+Qy>?cwHd?`^u=#Cp{*-(~9o?}twZoe9Vt#^Kedf}8 zd8zVD_|@*qFx(HuC0{sqZU{#}&Q?j%1-_#WkL#a=-?RMMhP+9ylHO-A} zod^C+orP|04uYHc+T`Q&zS@LOA6>)L)ai78(*ArYHc}zvqgm2u+ebc=n7-X^-B=Ov zZt2n2q64>fpM=OLJ}ki(Tz}<_iL4WMo%Sr1`GZFRYzUFp*~PdHM4!&wI@RqBj2mWY z32kQYkrZ?yKo=ILsIwqbI$LzxCSa8@Fgu^Cp3lo;=?TR+O z|AP9oBFfwhjpa+qXH}^a)=h3cRxq6rR62~78T>L|OzUhj?X0HpstgT!&igEz&U