From c06b2211fcaef9bf0d1f0db8ed88390bb77eb7b4 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 14:22:03 +0530 Subject: [PATCH 001/332] feat(sync): consume @offgrid/sync package in mobile (foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the Sync integration: mobile now consumes the public @offgrid/sync engine as a file: dep (published later), rather than reimplementing any transport/crypto/protocol — the package is the single source of truth shared with desktop, so the two converge with no rework. - Add @offgrid/sync (file:../shared/packages/sync) + its pure-JS runtime deps used by the RN path (tweetnacl, tweetnacl-util, js-sha512). bonjour-service is Node-only (node-discovery adapter) and intentionally NOT added — RN discovery uses injected react-native-zeroconf. - Metro: watch the out-of-root package and map its main + subpath adapters (./rn, ./rn-discovery, ./portable) to concrete prebuilt dist files, instead of enabling global package-exports (that flag changes resolution for every dep and breaks libs with malformed exports maps). Verified: Metro bundles @offgrid/sync + /rn + /rn-discovery on BOTH android and ios (probe entry, 238k bundle each). The RN adapters are dependency-injected (no react-native imports), so the package resolves + bundles before any native module is added. Plan in docs/SYNC_INTEGRATION_PLAN.md. --- docs/SYNC_INTEGRATION_PLAN.md | 53 +++++++++++++++++++++++++++++++++++ metro.config.js | 18 ++++++++++-- package-lock.json | 37 ++++++++++++++++++++++++ package.json | 4 +++ 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 docs/SYNC_INTEGRATION_PLAN.md diff --git a/docs/SYNC_INTEGRATION_PLAN.md b/docs/SYNC_INTEGRATION_PLAN.md new file mode 100644 index 000000000..306ff44c0 --- /dev/null +++ b/docs/SYNC_INTEGRATION_PLAN.md @@ -0,0 +1,53 @@ +# Off Grid Mobile — `@offgrid/sync` integration plan + +Integrate the device-to-device Sync engine into Off Grid Mobile. Built in parallel with a desktop +session; both converge automatically because **both apps consume the same public `@offgrid/sync` +package** — the wire protocol, mDNS service type (`_offgrid._tcp.local`), crypto, and op-log schema +all live in the package, so there is no per-app protocol to keep in sync and no rework. + +## Non-negotiable principles +- **Consume the package, never fork it.** All protocol / crypto / state / transfer logic stays in + `@offgrid/sync`. Mobile adds only thin, app-level glue. +- **Public engine, Pro feature.** The engine is public (auditable transport + NaCl crypto). The + mobile Sync *experience* (UI, orchestration, entitlement gate) lives in `pro/`, gated like MCP/TTS. +- **Native glue is injected.** The package's RN adapters (`@offgrid/sync/rn`, `/rn-discovery`) take + `react-native-tcp-socket`, `react-native-zeroconf`, and a Buffer byte-codec from the host; the + package never imports RN. +- **Every phase: own PR, hygiene + real tests + on-device verification on BOTH iOS and Android.** + +## v1 scope (device ↔ device, phone ↔ desktop) +1. **State sync** — chats/conversations, workspace/projects, model settings (op-log + state-sync). +2. **Model transfer** — move downloaded model files (GGUF / CoreML) between phone and desktop. +3. **Ambient sharing** — over the same encrypted transport. + +--- + +## Phase 0 — Foundation: transport live (nothing syncs until this works) +- [x] Add `@offgrid/sync` as a `file:../shared/packages/sync` dep; **node resolves it** (`VERSION 0.0.1`). +- [ ] **Metro resolution** — Metro must resolve the package (outside project root) and its subpath + exports (`./rn`, `./rn-discovery`, `./portable`): add `../shared/packages/sync` to + `watchFolders`, ensure `unstable_enablePackageExports`. De-risk with a bundle probe. +- [ ] **Native modules** — `react-native-tcp-socket` (TCP) + `react-native-zeroconf` (Android NSD / + iOS Bonjour): install, pod install, gradle, **native rebuild** both platforms. +- [ ] **Injection glue** — a mobile `SyncTransport` that wires `TcpSocket` + `Zeroconf` + a Buffer + codec into the package's RN adapters and constructs the engine + `DiscoveryOrchestrator`. +- [ ] **Minimal dev surface** — discovered-devices list → start engine → pairing handshake. +- [ ] **VERIFY on-device (both platforms):** phone discovers desktop (or other phone) over mDNS and + completes the **encrypted NaCl handshake**. Read `[SYNC]` trace from the device log. + +## Phase 1 — State sync (chats / projects / model settings) +- Map mobile stores (chat conversations, projects, `appStore` settings) → op-log ops via + `@offgrid/sync` `state-sync`; apply inbound ops with LWW. +- Verify convergence phone ↔ desktop on-device (edit on one, appears on the other). + +## Phase 2 — Model transfer +- Wire `@offgrid/sync` transfer to send/receive downloaded model files with progress + resume. +- Verify a real multi-GB model transfers phone ↔ desktop and **loads** on the receiver. + +## Phase 3 — Ambient sharing +- Wire ambient sharing over the transport (the transport-agnostic layer the desktop session flagged + as needing `@offgrid/sync`'s transport wired first). + +## Cross-cutting +- Pro entitlement gate. Device cap (2 free / 3+ paid) via the package's `cap.ts` + Keygen. +- Feature code in `pro/`; native modules + injection glue in core (native is app-level). diff --git a/metro.config.js b/metro.config.js index 146aeb05e..c40264bb3 100644 --- a/metro.config.js +++ b/metro.config.js @@ -8,10 +8,16 @@ const proStubPath = path.resolve(__dirname, 'src/bootstrap/proStub.js'); // for a real file inside it (package.json) to detect a populated submodule. const proExists = fs.existsSync(path.resolve(proPackagePath, 'package.json')); +// @offgrid/sync lives OUTSIDE the project root (shared monorepo). Metro must watch its dist and +// resolve the package + its subpath adapters. We map the subpaths to concrete built files rather +// than enabling `unstable_enablePackageExports` globally (that flag changes resolution for every +// dep and breaks libraries with malformed exports maps). The package ships prebuilt CJS in dist/. +const syncPackagePath = path.resolve(__dirname, '../shared/packages/sync'); + const config = { - // pro/ is a submodule inside the project root, so Metro already watches it by - // default; nothing extra needed here. (When absent it's just an empty dir.) - watchFolders: [], + // pro/ is a submodule inside the project root, so Metro already watches it by default. The sync + // package is out-of-root, so Metro must be told to watch it (for its dist) — nothing else needed. + watchFolders: [syncPackagePath], resolver: { // When resolving modules from outside the project root (i.e. @offgrid/pro), // Metro falls back here so @babel/runtime and all other peer deps are found. @@ -29,6 +35,12 @@ const config = { // duplicate RNFS Objective-C symbols at link time on iOS, so we alias the // old name onto the fork and keep a single native module. 'react-native-fs': path.resolve(__dirname, 'src/shims/react-native-fs.ts'), + // @offgrid/sync (out-of-root, prebuilt CJS). Main + subpath adapters mapped explicitly so we + // don't have to enable global package-exports. Keep in step with the package's exports map. + '@offgrid/sync': syncPackagePath, + '@offgrid/sync/rn': path.resolve(syncPackagePath, 'dist/adapters/rn-tcp.js'), + '@offgrid/sync/rn-discovery': path.resolve(syncPackagePath, 'dist/adapters/rn-discovery.js'), + '@offgrid/sync/portable': path.resolve(syncPackagePath, 'dist/portable/index.js'), }, }, }; diff --git a/package-lock.json b/package-lock.json index 2665cac7a..c9a8cadb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dr.pogodin/react-native-fs": "^2.38.1", "@kesha-antonov/react-native-background-downloader": "^4.5.6", "@modelcontextprotocol/sdk": "^1.29.0", + "@offgrid/sync": "file:../shared/packages/sync", "@op-engineering/op-sqlite": "^15.2.5", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/slider": "^5.1.2", @@ -25,6 +26,7 @@ "@testing-library/react-native": "^13.3.3", "@types/react-native-vector-icons": "^6.4.18", "js-sha256": "^0.11.0", + "js-sha512": "^0.9.0", "llama.rn": "^0.12.5", "node-html-parser": "^7.1.0", "patch-package": "^8.0.1", @@ -53,6 +55,8 @@ "react-native-vector-icons": "^10.3.0", "react-native-worklets": "^0.7.3", "react-native-zip-archive": "7.1.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "whisper.rn": "^0.5.5", "zustand": "^5.0.10" }, @@ -91,6 +95,17 @@ "node": ">=20" } }, + "../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/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -4335,6 +4350,10 @@ "node": ">= 8" } }, + "node_modules/@offgrid/sync": { + "resolved": "../shared/packages/sync", + "link": true + }, "node_modules/@op-engineering/op-sqlite": { "version": "15.2.5", "resolved": "https://registry.npmjs.org/@op-engineering/op-sqlite/-/op-sqlite-15.2.5.tgz", @@ -11994,6 +12013,12 @@ "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", "license": "MIT" }, + "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", @@ -16451,6 +16476,18 @@ "dev": true, "license": "0BSD" }, + "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", diff --git a/package.json b/package.json index c1995200b..c9aa1d4ce 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@dr.pogodin/react-native-fs": "^2.38.1", "@kesha-antonov/react-native-background-downloader": "^4.5.6", "@modelcontextprotocol/sdk": "^1.29.0", + "@offgrid/sync": "file:../shared/packages/sync", "@op-engineering/op-sqlite": "^15.2.5", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/slider": "^5.1.2", @@ -39,6 +40,7 @@ "@testing-library/react-native": "^13.3.3", "@types/react-native-vector-icons": "^6.4.18", "js-sha256": "^0.11.0", + "js-sha512": "^0.9.0", "llama.rn": "^0.12.5", "node-html-parser": "^7.1.0", "patch-package": "^8.0.1", @@ -67,6 +69,8 @@ "react-native-vector-icons": "^10.3.0", "react-native-worklets": "^0.7.3", "react-native-zip-archive": "7.1.0", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", "whisper.rn": "^0.5.5", "zustand": "^5.0.10" }, From 427da00410cde0ee99124358a6efe5cb165bf917 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 14:37:27 +0530 Subject: [PATCH 002/332] =?UTF-8?q?feat(sync):=20RN=20transport=20glue=20?= =?UTF-8?q?=E2=80=94=20byte=20codec=20+=20engine=20factory=20(+=20real=20p?= =?UTF-8?q?airing=20test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thin, app-level binding that lets @offgrid/sync run on React Native: - rnByteCodec: normalizes react-native-tcp-socket's inbound data (Buffer / Uint8Array / base64 string on Android) → raw bytes, and outbound bytes → Buffer. Pure (no RN import); the package injects it so it stays platform-agnostic. - buildSyncEngine(): constructs RnTcpTransport(tcpModule, codec) + SyncEngine over it. The socket module is injected by the caller, so the wiring is unit-testable with an in-memory module. Native deps added: react-native-tcp-socket, react-native-zeroconf, buffer. Tests (loop): - byteCodec: 7 real Buffer/base64 round-trips incl. the Android base64 path, byte-view offsets, and never-throw-on-garbage (a throw in the socket data path would kill the connection). - rnTransportPairing (integration): two engines built by buildSyncEngine complete the REAL NaCl pairing handshake and exchange an encrypted app-channel message over an in-memory socket that delivers base64 — the exact Android delivery. Drives the real engine/protocol/crypto + our real codec/factory; only the OS socket boundary is faked. On-device is then the same code over real sockets. jest: map @offgrid/sync (+ /rn, /rn-discovery, /portable) to package SOURCE (tsup dist trips on @babel/runtime), and pin @babel/runtime to mobile's copy for the out-of-root source. 216-test sanity slice + tsc + lint clean. --- .../sync/rnTransportPairing.test.ts | 110 +++++++++++++ .../unit/services/sync/byteCodec.test.ts | 48 ++++++ jest.config.js | 11 ++ package-lock.json | 152 ++++++++++++++---- package.json | 3 + src/services/sync/byteCodec.ts | 38 +++++ src/services/sync/engine.ts | 40 +++++ 7 files changed, 374 insertions(+), 28 deletions(-) create mode 100644 __tests__/integration/sync/rnTransportPairing.test.ts create mode 100644 __tests__/unit/services/sync/byteCodec.test.ts create mode 100644 src/services/sync/byteCodec.ts create mode 100644 src/services/sync/engine.ts diff --git a/__tests__/integration/sync/rnTransportPairing.test.ts b/__tests__/integration/sync/rnTransportPairing.test.ts new file mode 100644 index 000000000..d845796b8 --- /dev/null +++ b/__tests__/integration/sync/rnTransportPairing.test.ts @@ -0,0 +1,110 @@ +/** + * End-to-end proof that Off Grid Mobile's Sync wiring works: two SyncEngines built via our + * buildSyncEngine factory (RN TCP adapter + injected rnByteCodec) complete the REAL NaCl pairing + * handshake and exchange an encrypted app-channel message — over an in-memory socket module that + * delivers inbound data as base64 STRINGS, i.e. the exact react-native-tcp-socket Android path. + * + * This drives the real @offgrid/sync engine/protocol/crypto + our real codec + factory (nothing + * mocked but the OS socket boundary), so a green run means the encrypted frames survive the mobile + * transport end-to-end — the on-device handshake is then just the same code over real sockets. + */ +import { Buffer } from 'buffer'; +import { buildSyncEngine } from '../../../src/services/sync/engine'; +import type { RnTcpModule } from '@offgrid/sync/rn'; + +type Handler = (...a: any[]) => void; + +// One in-memory socket endpoint. write() delivers to the peer's 'data' listeners AS BASE64 STRINGS +// (Android delivery), exercising rnByteCodec.toBytes' string path against the real engine. +class FakeSocket { + peer?: FakeSocket; + remoteAddress = '127.0.0.1'; + private handlers: Record = {}; + on(ev: string, cb: Handler) { (this.handlers[ev] ||= []).push(cb); return this; } + private emit(ev: string, ...a: any[]) { (this.handlers[ev] || []).forEach(h => h(...a)); } + write(data: unknown) { + const buf = data as Buffer; + const b64 = Buffer.from(buf).toString('base64'); + // async delivery, like a real socket + setImmediate(() => this.peer?.emit('data', b64 as unknown)); + return true; + } + destroy() { setImmediate(() => { this.emit('close'); this.peer?.emit('close'); }); } + _deliverOpen() {/* no-op */} +} + +// In-memory RnTcpModule: servers keyed by bound port; connect() wires a socket pair and hands the +// server side to its onConnection. +function makeFakeTcp(): RnTcpModule & { _servers: Map void> } { + const servers = new Map void>(); + let nextPort = 41000; + return { + _servers: servers, + createServer(onConnection: (socket: any) => void) { + let boundPort = 0; + const srv: any = { + on() { return srv; }, + listen(opts: { port: number }, cb?: () => void) { + boundPort = opts.port && opts.port > 0 ? opts.port : nextPort++; + servers.set(boundPort, onConnection as any); + cb?.(); + return srv; + }, + address() { return { port: boundPort }; }, + close() { servers.delete(boundPort); }, + }; + return srv; + }, + createConnection(opts: { host: string; port: number }, cb?: () => void) { + const client = new FakeSocket(); + const server = new FakeSocket(); + client.peer = server; server.peer = client; + const onConn = servers.get(opts.port); + if (!onConn) throw new Error(`no server on port ${opts.port}`); + setImmediate(() => { onConn(server as any); cb?.(); }); + return client as any; + }, + }; +} + +const dev = (id: string, port: number) => ({ id, name: id, platform: 'android' as const, version: '1', host: '127.0.0.1', port }); +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe('mobile Sync wiring — pair + app message over the RN transport (base64 path)', () => { + it('two engines built by buildSyncEngine pair and exchange an encrypted app message', async () => { + const tcp = makeFakeTcp(); + let aPaired: any, bPaired: any, appMsg: any; + + const a = buildSyncEngine({ + localDevice: dev('dev-a', 0), tcpModule: tcp, + getPassphrase: () => 'shared-secret', + onPaired: (d) => { aPaired = d; }, + onAppMessage: (id, channel, data) => { appMsg = { id, channel, data }; }, + }); + const b = buildSyncEngine({ + localDevice: dev('dev-b', 0), tcpModule: tcp, + onPaired: (d) => { bPaired = d; }, + }); + + await a.engine.start(0); + const port = a.transport.boundPort!; + expect(port).toBeGreaterThan(0); + + await b.engine.pair(dev('dev-a', port), 'shared-secret'); + await delay(200); + + // Real NaCl handshake completed both sides, same derived secret. + expect(aPaired?.id).toBe('dev-b'); + expect(bPaired?.id).toBe('dev-a'); + expect(aPaired.sharedSecret).toBe(bPaired.sharedSecret); + + // Encrypted app-channel message survives the base64 transport round-trip. + const ok = b.engine.sendApp('dev-a', 'state', { hello: 'world', n: 42 }); + await delay(120); + expect(ok).toBe(true); + expect(appMsg).toEqual({ id: 'dev-b', channel: 'state', data: { hello: 'world', n: 42 } }); + + await a.engine.stop(); + await b.engine.stop(); + }, 15000); +}); diff --git a/__tests__/unit/services/sync/byteCodec.test.ts b/__tests__/unit/services/sync/byteCodec.test.ts new file mode 100644 index 000000000..42dfaeb83 --- /dev/null +++ b/__tests__/unit/services/sync/byteCodec.test.ts @@ -0,0 +1,48 @@ +/** + * The byte codec injected into @offgrid/sync's RN TCP adapter. Encrypted wire frames must survive + * both directions and every inbound shape react-native-tcp-socket can deliver (Buffer, Uint8Array, + * base64 string on Android). A single dropped/rewritten byte corrupts the NaCl frame → handshake + * fails. Real Buffer/base64 round-trips (no mocks). + */ +import { Buffer } from 'buffer'; +import { rnByteCodec } from '../../../../src/services/sync/byteCodec'; + +const bytes = Uint8Array.from([0, 1, 2, 253, 254, 255, 128, 64]); // spans full byte range + +describe('rnByteCodec', () => { + it('fromBytes → a Buffer with identical bytes (outbound)', () => { + const buf = rnByteCodec.fromBytes(bytes); + expect(Buffer.isBuffer(buf)).toBe(true); + expect([...buf]).toEqual([...bytes]); + }); + + it('round-trips through a base64 string (the Android inbound path)', () => { + const b64 = rnByteCodec.fromBytes(bytes).toString('base64'); + expect([...rnByteCodec.toBytes(b64)]).toEqual([...bytes]); + }); + + it('normalizes a Buffer inbound to identical bytes', () => { + expect([...rnByteCodec.toBytes(Buffer.from(bytes))]).toEqual([...bytes]); + }); + + it('normalizes a Uint8Array inbound to identical bytes', () => { + expect([...rnByteCodec.toBytes(bytes)]).toEqual([...bytes]); + }); + + it('normalizes an ArrayBuffer inbound', () => { + const ab = bytes.slice().buffer; + expect([...rnByteCodec.toBytes(ab)]).toEqual([...bytes]); + }); + + it('preserves a byte-view offset on fromBytes (no leading-byte corruption)', () => { + const backing = Uint8Array.from([9, 9, 1, 2, 3]); + const view = backing.subarray(2); // offset=2 → [1,2,3] + expect([...rnByteCodec.fromBytes(view)]).toEqual([1, 2, 3]); + }); + + it('never throws on an unknown shape (returns empty rather than killing the socket)', () => { + expect([...rnByteCodec.toBytes(null)]).toEqual([]); + expect([...rnByteCodec.toBytes(undefined)]).toEqual([]); + expect([...rnByteCodec.toBytes({})]).toEqual([]); + }); +}); diff --git a/jest.config.js b/jest.config.js index ee31e606d..4f4bcff67 100644 --- a/jest.config.js +++ b/jest.config.js @@ -61,6 +61,17 @@ module.exports = { // Mirrors the metro alias: 'react-native-fs' resolves to the maintained fork // (the only RNFS native module we ship — see metro.config.js). '^react-native-fs$': '/src/shims/react-native-fs.ts', + // @offgrid/sync: test against SOURCE (jest transforms the TS) rather than the tsup dist, + // which references @babel/runtime helpers not resolvable from the out-of-root package. Keep + // these subpaths in step with metro.config.js's aliases and the package's exports map. + '^@offgrid/sync$': '/../shared/packages/sync/src/index.ts', + '^@offgrid/sync/rn$': '/../shared/packages/sync/src/adapters/rn-tcp.ts', + '^@offgrid/sync/rn-discovery$': '/../shared/packages/sync/src/adapters/rn-discovery.ts', + '^@offgrid/sync/portable$': '/../shared/packages/sync/src/portable/index.ts', + // The sync source lives out-of-root; when jest transforms it, babel injects @babel/runtime + // helper imports that would otherwise resolve from ../shared (where they aren't installed). + // Pin them to mobile's own copy. + '^@babel/runtime/(.*)$': '/node_modules/@babel/runtime/$1', }, transformIgnorePatterns: ['node_modules/(?!(react-native|@react-native|@react-navigation|react-native-.*|@react-native-.*|moti|@motify|@gorhom|@shopify|@ronradtke|@op-engineering|@offgrid)/)',], testEnvironment: 'node', diff --git a/package-lock.json b/package-lock.json index c9a8cadb2..d1c07fe1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@ronradtke/react-native-markdown-display": "^8.1.0", "@testing-library/react-native": "^13.3.3", "@types/react-native-vector-icons": "^6.4.18", + "buffer": "^6.0.3", "js-sha256": "^0.11.0", "js-sha512": "^0.9.0", "llama.rn": "^0.12.5", @@ -51,9 +52,11 @@ "react-native-screens": "^4.20.0", "react-native-spotlight-tour": "^4.0.0", "react-native-svg": "^15.15.3", + "react-native-tcp-socket": "^6.4.1", "react-native-url-polyfill": "^3.0.0", "react-native-vector-icons": "^10.3.0", "react-native-worklets": "^0.7.3", + "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", @@ -2181,30 +2184,6 @@ "react-native": "*" } }, - "node_modules/@dr.pogodin/react-native-fs/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -7159,6 +7138,31 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -7293,9 +7297,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -7313,7 +7317,7 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-from": { @@ -9245,6 +9249,21 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -14720,6 +14739,47 @@ "react-native": "*" } }, + "node_modules/react-native-tcp-socket": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/react-native-tcp-socket/-/react-native-tcp-socket-6.4.1.tgz", + "integrity": "sha512-2+zya9ielB8nWfMYVULB64ZqLzQD32qIzTnDef7NM1BChaJiA1btkJTBL+8WnyBrujjlCBVxBC3gAkXtG5hmvQ==", + "license": "MIT", + "dependencies": { + "buffer": "^5.4.3", + "eventemitter3": "^4.0.7" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Rapsssito" + }, + "peerDependencies": { + "react-native": ">=0.60.0" + } + }, + "node_modules/react-native-tcp-socket/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/react-native-url-polyfill": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz", @@ -14890,6 +14950,18 @@ "node": ">=10" } }, + "node_modules/react-native-zeroconf": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-native-zeroconf/-/react-native-zeroconf-0.14.0.tgz", + "integrity": "sha512-TqjORroaVZrBYLzk3YtviQy8lUl/iiMacknxixRYlmGaqgsv4LJXIYafpnvPa3y2SC4/qu2mvF8D1/VTTxylgQ==", + "license": "MIT", + "dependencies": { + "events": "^3.0.0" + }, + "peerDependencies": { + "react-native": ">=0.60" + } + }, "node_modules/react-native-zip-archive": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/react-native-zip-archive/-/react-native-zip-archive-7.1.0.tgz", @@ -16919,6 +16991,30 @@ "node": ">=10" } }, + "node_modules/whatwg-url-without-unicode/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c9aa1d4ce..34ad9c1fc 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@ronradtke/react-native-markdown-display": "^8.1.0", "@testing-library/react-native": "^13.3.3", "@types/react-native-vector-icons": "^6.4.18", + "buffer": "^6.0.3", "js-sha256": "^0.11.0", "js-sha512": "^0.9.0", "llama.rn": "^0.12.5", @@ -65,9 +66,11 @@ "react-native-screens": "^4.20.0", "react-native-spotlight-tour": "^4.0.0", "react-native-svg": "^15.15.3", + "react-native-tcp-socket": "^6.4.1", "react-native-url-polyfill": "^3.0.0", "react-native-vector-icons": "^10.3.0", "react-native-worklets": "^0.7.3", + "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", diff --git a/src/services/sync/byteCodec.ts b/src/services/sync/byteCodec.ts new file mode 100644 index 000000000..9fb874b44 --- /dev/null +++ b/src/services/sync/byteCodec.ts @@ -0,0 +1,38 @@ +// Byte codec injected into @offgrid/sync's RN TCP adapter (RnTcpTransport). The package stays +// platform-agnostic and never imports react-native / Buffer; the host provides this conversion. +// +// react-native-tcp-socket hands socket 'data' as a Buffer, but on some Android builds it arrives as +// a base64-encoded STRING. Outbound, socket.write() wants a Buffer. This codec normalizes both +// directions so the encrypted wire frames survive intact. Pure — no native imports — so it's fully +// unit-testable off-device. +import { Buffer } from 'buffer'; + +export interface ByteCodec { + toBytes(data: unknown): Uint8Array; + fromBytes(bytes: Uint8Array): Buffer; +} + +export const rnByteCodec: ByteCodec = { + /** Outbound: raw frame bytes → Buffer for socket.write(). */ + fromBytes(bytes: Uint8Array): Buffer { + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + }, + + /** Inbound: normalize whatever react-native-tcp-socket delivers → raw frame bytes. + * Handles Buffer / Uint8Array / ArrayBuffer / base64 string. */ + toBytes(data: unknown): Uint8Array { + if (typeof data === 'string') { + // Android may deliver base64. Buffer.from(str,'base64') copies into a fresh buffer. + const b = Buffer.from(data, 'base64'); + return new Uint8Array(b.buffer, b.byteOffset, b.byteLength); + } + if (data instanceof Uint8Array) return data; // Buffer is a Uint8Array subclass — covered here + if (data instanceof ArrayBuffer) return new Uint8Array(data); + // Fallback: coerce array-likes ({length, [i]}) to bytes; unknown shapes → empty (never throw + // in the socket data path, which would kill the connection). + if (data && typeof (data as { length?: number }).length === 'number') { + return Uint8Array.from(data as ArrayLike); + } + return new Uint8Array(0); + }, +}; diff --git a/src/services/sync/engine.ts b/src/services/sync/engine.ts new file mode 100644 index 000000000..98ee6022a --- /dev/null +++ b/src/services/sync/engine.ts @@ -0,0 +1,40 @@ +// Builds a @offgrid/sync SyncEngine wired for React Native: the platform-agnostic engine + the +// package's RN TCP adapter, fed our injected byte codec. The actual socket module +// (react-native-tcp-socket) is passed in by the caller (see nativeSync.ts) so this wiring stays +// pure and unit-testable with an in-memory socket module — the package never imports RN. +import { SyncEngine } from '@offgrid/sync'; +import type { SyncEngineOptions, DeviceInfo } from '@offgrid/sync'; +import { RnTcpTransport } from '@offgrid/sync/rn'; +import type { RnTcpModule } from '@offgrid/sync/rn'; +import { rnByteCodec } from './byteCodec'; + +export interface BuildSyncEngineArgs { + localDevice: DeviceInfo; + /** react-native-tcp-socket (or an in-memory fake in tests). */ + tcpModule: RnTcpModule; + getPassphrase?: SyncEngineOptions['getPassphrase']; + getSharedSecret?: SyncEngineOptions['getSharedSecret']; + onPaired?: SyncEngineOptions['onPaired']; + onPairingFailed?: SyncEngineOptions['onPairingFailed']; + onMessage?: SyncEngineOptions['onMessage']; + onAppMessage?: SyncEngineOptions['onAppMessage']; + cap?: SyncEngineOptions['cap']; +} + +/** Construct the RN transport (codec-injected) and the engine over it. Returns both so the caller + * can read transport.boundPort after start() to advertise the real listening port over mDNS. */ +export function buildSyncEngine(args: BuildSyncEngineArgs): { engine: SyncEngine; transport: RnTcpTransport } { + const transport = new RnTcpTransport(args.tcpModule, rnByteCodec); + const engine = new SyncEngine({ + localDevice: args.localDevice, + transport, + getPassphrase: args.getPassphrase, + getSharedSecret: args.getSharedSecret, + onPaired: args.onPaired, + onPairingFailed: args.onPairingFailed, + onMessage: args.onMessage, + onAppMessage: args.onAppMessage, + cap: args.cap, + }); + return { engine, transport }; +} From 36581727695bbe88a80593cacb14e2a922432ddb Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 14:40:04 +0530 Subject: [PATCH 003/332] =?UTF-8?q?feat(sync):=20RN=20discovery=20glue=20?= =?UTF-8?q?=E2=80=94=20buildDiscovery=20over=20the=20mDNS=20adapter=20(+?= =?UTF-8?q?=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildDiscovery() wires @offgrid/sync's DiscoveryOrchestrator to the package's RnDiscovery adapter with an injected react-native-zeroconf. It browses `_offgrid._tcp.local` (same service the desktop Node adapter advertises → phone and laptop find each other) and either surfaces a new device for the pairing UI or auto-reconnects a known one. Tests (real orchestrator + real RnDiscovery, fake zeroconf): a NEW device is surfaced; a KNOWN device (stored secret) triggers engine.reconnect and is NOT surfaced; start() advertises this device and a removal fires onLost. tsc + lint clean. --- .../integration/sync/rnDiscovery.test.ts | 83 +++++++++++++++++++ src/services/sync/discovery.ts | 33 ++++++++ 2 files changed, 116 insertions(+) create mode 100644 __tests__/integration/sync/rnDiscovery.test.ts create mode 100644 src/services/sync/discovery.ts diff --git a/__tests__/integration/sync/rnDiscovery.test.ts b/__tests__/integration/sync/rnDiscovery.test.ts new file mode 100644 index 000000000..3f241db15 --- /dev/null +++ b/__tests__/integration/sync/rnDiscovery.test.ts @@ -0,0 +1,83 @@ +/** + * Discovery wiring: the REAL DiscoveryOrchestrator + REAL RnDiscovery adapter, fed a fake + * react-native-zeroconf. Proves that an mDNS-resolved peer is routed correctly — a NEW device is + * surfaced to the pairing UI, a KNOWN device (we hold a secret for) triggers auto-reconnect, and a + * removed device fires onLost. Only the injected native (zeroconf) + the engine collaborator are + * faked; the discovery/orchestrator behavior under test is the package's real code. + */ +import { buildDiscovery } from '../../../src/services/sync/discovery'; +import { createTxtRecord } from '@offgrid/sync'; +import type { RnZeroconf } from '@offgrid/sync/rn-discovery'; + +type Handler = (arg: any) => void; + +function makeFakeZeroconf() { + const on: Record = {}; + const z: RnZeroconf & { emitResolved: (svc: any) => void; emitRemove: (n: string) => void; published?: any } = { + on(ev: string, cb: Handler) { on[ev] = cb; }, + scan() {/* browsing started */}, + stop() {/* stopped */}, + removeDeviceListeners() {/* noop */}, + publishService(type, protocol, domain, name, port, txt) { z.published = { type, name, port, txt }; }, + unpublishService() { z.published = undefined; }, + emitResolved: (svc) => on['resolved']?.(svc), + emitRemove: (n) => on['remove']?.(n), + }; + return z; +} + +const local = { id: 'local-phone', name: 'Phone', platform: 'android' as const, version: '1', host: '', port: 0 }; +const remote = { id: 'remote-laptop', name: 'Laptop', platform: 'macos' as const, version: '1', host: '192.168.1.5', port: 7777 }; +const resolvedSvc = () => ({ txt: createTxtRecord(remote), addresses: ['192.168.1.5'], host: 'laptop.local', port: 7777, name: `OffGrid-${remote.id}` }); +const flush = () => new Promise(r => setImmediate(r)); + +describe('mobile Sync discovery wiring (real orchestrator + RnDiscovery, fake zeroconf)', () => { + it('surfaces a NEW (unpaired) device for the pairing UI', async () => { + const z = makeFakeZeroconf(); + let surfaced: any; + const orch = buildDiscovery({ + zeroconf: z, localDevice: local, + engine: { isPaired: () => false, reconnect: async () => {} }, + getSharedSecret: () => undefined, // not paired yet + onDiscovered: (d) => { surfaced = d; }, + }); + await orch.start(); + z.emitResolved(resolvedSvc()); + await flush(); + expect(surfaced?.id).toBe('remote-laptop'); + expect(surfaced?.host).toBe('192.168.1.5'); + }); + + it('auto-reconnects a KNOWN device (has a stored secret) instead of surfacing it', async () => { + const z = makeFakeZeroconf(); + let surfaced = false; const reconnected: any = {}; + const orch = buildDiscovery({ + zeroconf: z, localDevice: local, + engine: { isPaired: () => false, reconnect: async (d, s) => { reconnected.d = d; reconnected.s = s; } }, + getSharedSecret: (id) => (id === 'remote-laptop' ? 'stored-secret' : undefined), + onDiscovered: () => { surfaced = true; }, + }); + await orch.start(); + z.emitResolved(resolvedSvc()); + await flush(); + expect(reconnected.d?.id).toBe('remote-laptop'); + expect(reconnected.s).toBe('stored-secret'); + expect(surfaced).toBe(false); // NOT surfaced — it reconnected + }); + + it('advertises this device on start and fires onLost on removal', async () => { + const z = makeFakeZeroconf(); + let lost: string | undefined; + const orch = buildDiscovery({ + zeroconf: z, localDevice: { ...local, port: 5555 }, + engine: { isPaired: () => false, reconnect: async () => {} }, + getSharedSecret: () => undefined, + onLost: (id) => { lost = id; }, + }); + await orch.start(); + expect(z.published?.port).toBe(5555); // advertised over mDNS + z.emitRemove(`OffGrid-${remote.id}._offgrid._tcp.local.`); + await flush(); + expect(lost).toBe('remote-laptop'); + }); +}); diff --git a/src/services/sync/discovery.ts b/src/services/sync/discovery.ts new file mode 100644 index 000000000..efd41355a --- /dev/null +++ b/src/services/sync/discovery.ts @@ -0,0 +1,33 @@ +// Wires @offgrid/sync's DiscoveryOrchestrator to the package's RN mDNS adapter (RnDiscovery), fed +// an injected react-native-zeroconf instance (Android NSD / iOS Bonjour). The orchestrator browses +// for peers advertising `_offgrid._tcp.local` — the same service the desktop Node adapter uses, so +// phone and laptop find each other — and either auto-reconnects a known device or surfaces a new +// one for the pairing UI. The Zeroconf module is injected so this stays testable off-device. +import { DiscoveryOrchestrator } from '@offgrid/sync'; +import type { DeviceInfo, DiscoveredDevice, SyncEngine } from '@offgrid/sync'; +import { RnDiscovery } from '@offgrid/sync/rn-discovery'; +import type { RnZeroconf } from '@offgrid/sync/rn-discovery'; + +export interface BuildDiscoveryArgs { + /** react-native-zeroconf instance (or an in-memory fake in tests). */ + zeroconf: RnZeroconf; + /** The SyncEngine — the orchestrator reads isPaired() and drives reconnect(). */ + engine: Pick; + localDevice: DeviceInfo; + getSharedSecret: (deviceId: string) => string | undefined; + /** A new (unpaired) device appeared — surface it for the pairing UI. */ + onDiscovered?: (device: DiscoveredDevice) => void; + onLost?: (deviceId: string) => void; +} + +export function buildDiscovery(args: BuildDiscoveryArgs): DiscoveryOrchestrator { + const discovery = new RnDiscovery(args.zeroconf); + return new DiscoveryOrchestrator({ + engine: args.engine, + discovery, + localDevice: args.localDevice, + getSharedSecret: args.getSharedSecret, + onDiscovered: args.onDiscovered, + onLost: args.onLost, + }); +} From 0536af6c25917513e74776002b4e8f7eb0361fb3 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 14:44:06 +0530 Subject: [PATCH 004/332] feat(sync): native binding (createNativeSync) + mDNS permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createNativeSync() is the one module that injects the real react-native-tcp-socket + react-native-zeroconf into @offgrid/sync via the tested buildSyncEngine/buildDiscovery factories, and owns start/stop sequencing (start engine on an ephemeral port → advertise the real bound port over mDNS → browse). Thin by design; all tested logic lives in the factories + the package. Native config for `_offgrid._tcp` discovery: - iOS: add `_offgrid._tcp` to NSBonjourServices (NSLocalNetworkUsageDescription already present). - Android: add CHANGE_WIFI_MULTICAST_STATE (NSD/mDNS multicast). - Ambient type shim for react-native-zeroconf (ships no types; used only via the RnZeroconf structural interface). Requires a native rebuild to autolink the two modules (next). --- android/app/src/main/AndroidManifest.xml | 2 + ios/OffgridMobile/Info.plist | 1 + src/services/sync/nativeSync.ts | 82 ++++++++++++++++++++++++ src/types/react-native-zeroconf.d.ts | 12 ++++ 4 files changed, 97 insertions(+) create mode 100644 src/services/sync/nativeSync.ts create mode 100644 src/types/react-native-zeroconf.d.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9d10446c0..03d8d25fe 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + diff --git a/ios/OffgridMobile/Info.plist b/ios/OffgridMobile/Info.plist index a5fff7a7b..a1382566a 100644 --- a/ios/OffgridMobile/Info.plist +++ b/ios/OffgridMobile/Info.plist @@ -52,6 +52,7 @@ _http._tcp _ollama._tcp _lmstudio._tcp + _offgrid._tcp NSCameraUsageDescription This app needs access to your camera to take photos and attach them to conversations. diff --git a/src/services/sync/nativeSync.ts b/src/services/sync/nativeSync.ts new file mode 100644 index 000000000..562f5b110 --- /dev/null +++ b/src/services/sync/nativeSync.ts @@ -0,0 +1,82 @@ +// The ONE module that binds @offgrid/sync to React Native's real native networking. It injects +// react-native-tcp-socket (transport) and react-native-zeroconf (mDNS) into the package's adapters +// via the tested buildSyncEngine / buildDiscovery factories, and owns start/stop sequencing. Kept +// deliberately thin: all logic worth testing lives in the factories (unit-tested off-device) and in +// @offgrid/sync itself; this file is the app-level wiring that can only run on a device. +import { Platform } from 'react-native'; +import TcpSocket from 'react-native-tcp-socket'; +import Zeroconf from 'react-native-zeroconf'; +import type { DeviceInfo, DiscoveredDevice, PairedDevice, Message } from '@offgrid/sync'; +import type { RnTcpModule } from '@offgrid/sync/rn'; +import type { RnZeroconf } from '@offgrid/sync/rn-discovery'; +import { buildSyncEngine } from './engine'; +import { buildDiscovery } from './discovery'; +import logger from '../../utils/logger'; + +export interface NativeSyncCallbacks { + /** Passphrase for an INBOUND pairing (UI prompt). Return null to refuse. */ + getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; + /** Stored shared secret for a device (for silent reconnect). */ + getSharedSecret?: (deviceId: string) => string | undefined; + onPaired?: (device: PairedDevice) => void; + onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; + onDiscovered?: (device: DiscoveredDevice) => void; + onLost?: (deviceId: string) => void; + onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; + onMessage?: (deviceId: string, message: Message) => void; +} + +export interface NativeSync { + readonly localDevice: DeviceInfo; + start(): Promise; + stop(): Promise; + pair(device: DeviceInfo, passphrase: string): Promise; + sendApp(deviceId: string, channel: string, data: unknown): boolean; + isPaired(deviceId: string): boolean; +} + +/** Construct (but don't start) the mobile Sync stack for a given local device. */ +export function createNativeSync(localDevice: DeviceInfo, cbs: NativeSyncCallbacks): NativeSync { + const { engine, transport } = buildSyncEngine({ + localDevice, + tcpModule: TcpSocket as unknown as RnTcpModule, + getPassphrase: cbs.getPassphrase, + getSharedSecret: cbs.getSharedSecret, + onPaired: cbs.onPaired, + onPairingFailed: cbs.onPairingFailed, + onMessage: cbs.onMessage, + onAppMessage: cbs.onAppMessage, + }); + const zeroconf = new Zeroconf() as unknown as RnZeroconf; + const orchestrator = buildDiscovery({ + zeroconf, + engine, + localDevice, + getSharedSecret: cbs.getSharedSecret ?? (() => undefined), + onDiscovered: cbs.onDiscovered, + onLost: cbs.onLost, + }); + + return { + localDevice, + async start() { + await engine.start(0); // ephemeral port + localDevice.port = transport.boundPort ?? 0; // advertise the real bound port + await orchestrator.start(); + logger.log(`[SYNC] started id=${localDevice.id} port=${localDevice.port} platform=${localDevice.platform}`); + }, + async stop() { + await orchestrator.stop(); + await engine.stop(); + logger.log('[SYNC] stopped'); + }, + pair: (device, passphrase) => engine.pair(device, passphrase), + sendApp: (deviceId, channel, data) => engine.sendApp(deviceId, channel, data), + isPaired: (deviceId) => engine.isPaired(deviceId), + }; +} + +/** Best-effort platform tag for DeviceInfo. */ +export function currentPlatform(): DeviceInfo['platform'] { + return Platform.OS === 'ios' ? 'ios' : 'android'; +} diff --git a/src/types/react-native-zeroconf.d.ts b/src/types/react-native-zeroconf.d.ts new file mode 100644 index 000000000..25d5577b3 --- /dev/null +++ b/src/types/react-native-zeroconf.d.ts @@ -0,0 +1,12 @@ +// react-native-zeroconf ships no types. We only use it through @offgrid/sync's RnZeroconf +// structural interface (nativeSync.ts casts to it), so a minimal ambient declaration is enough. +declare module 'react-native-zeroconf' { + export default class Zeroconf { + on(event: string, cb: (...args: 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; + } +} From c0bc52f857218d882fa013a98867622e88d89188 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 14:46:56 +0530 Subject: [PATCH 005/332] docs(sync): mobile progress + coordination log for the desktop session --- docs/SYNC_MOBILE_PROGRESS.md | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/SYNC_MOBILE_PROGRESS.md diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md new file mode 100644 index 000000000..54584d9a3 --- /dev/null +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -0,0 +1,52 @@ +# Off Grid Mobile — Sync integration progress (for the desktop session) + +Living log of the **mobile** side of `@offgrid/sync` integration so the desktop session can +coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as work lands. + +## Shared contract (both apps consume the same public package → converge with no rework) +- **Package:** `@offgrid/sync` (`shared/packages/sync`), consumed by mobile via `file:` dep. Never + forked. Wire protocol, NaCl crypto, op-log schema, and mDNS service type all live in the package. +- **mDNS service type:** `_offgrid._tcp.local` (mobile advertises + browses this; must match desktop). +- **Transport:** length-prefixed NaCl-encrypted frames over TCP (ephemeral bound port, advertised + over mDNS TXT). App messages ride the paired channel via `engine.sendApp(deviceId, channel, data)`. +- **Feature gating:** the mobile Sync *experience* is Pro; the engine is public. + +## Devices (mobile side, on hand for real 2-device tests) +- **iPhone 17 Pro Max** — hardware UDID `00008150-000225103CD8C01C` ("Mac's iPhone"), iOS 26.5.2. + Driven via WebDriverAgent (`http://192.168.1.14:8100`) + `devicectl`. +- **Android** — `adb` id `505b53a0` (OnePlus). Driven via `adb`. +- Both on the same LAN as the laptop. Can pair phone↔phone and phone↔desktop. + +## UUID coordination (agreed) +- Synced entities keyed by **UUID** (conversations, projects, settings). **Mobile owns adding + stable UUIDs on its side** (chatStore conversations, projects, per-message UUIDs). Message-content + sync needs a per-message UUID (desktop is adding a UUID column on `rag_messages`; mobile adds the + equivalent to its message store). Conflict resolution is the engine's LWW — not reimplemented. + +## Phase progress +### Phase 0 — Foundation (transport live) — IN PROGRESS +- [x] Consume `@offgrid/sync` file: dep + pure-JS crypto deps (tweetnacl, tweetnacl-util, js-sha512). +- [x] Metro resolves the package + `./rn` `./rn-discovery` `./portable` (watchFolder + subpath + aliases; not global package-exports). Bundles on both platforms. +- [x] RN glue (all unit/integration tested off-device, 11 tests): + `rnByteCodec` (Buffer/base64 codec), `buildSyncEngine` (RnTcpTransport + engine), + `buildDiscovery` (RnDiscovery + orchestrator). Real pairing + app-message test over an + in-memory base64 socket passes. +- [x] `createNativeSync` binding (injects react-native-tcp-socket + react-native-zeroconf). +- [x] Native config: iOS `NSBonjourServices += _offgrid._tcp`; Android `CHANGE_WIFI_MULTICAST_STATE`. +- [x] iOS `pod install` autolinked tcp-socket 6.4.1 + zeroconf 0.14.0 (+ CocoaAsyncSocket). +- [ ] Native rebuild both platforms. +- [ ] Minimal on-device trigger + **verify discovery + encrypted handshake** (phone↔phone first, + then phone↔desktop). + +### Phase 1 — State sync (chats/projects/settings) — NOT STARTED (needs mobile UUID migration first) +### Phase 2 — Model transfer — NOT STARTED +### Phase 3 — Ambient sharing — NOT STARTED + +## Security note (logged for GA) +Crypto is sound (NaCl secretbox authenticated encryption; passphrase never on the wire; LAN-only). +Hardening item before GA: the KDF is a hand-rolled iterated SHA-512 ("PBKDF2-like") — pair with a +high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphrase isn't brute-forceable. + +## Branch +`feat/sync-integration-phase0` (mobile). Commits are small + each has tests + hygiene. From d8f459adc205e6f86004ab950bd04428e2cc85a3 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 14:53:09 +0530 Subject: [PATCH 006/332] feat(sync): on-device dev harness to prove the transport (discovery + handshake) - localDevice: persisted device id (AsyncStorage) + name (device-info) + platform for a stable DeviceInfo. - devHarness (DEV ONLY, __DEV__ + SYNC_DEV_HARNESS double-gated, dynamic-imported from App): starts the engine + mDNS, auto-pairs a discovered peer with a fixed dev passphrase, and logs [SYNC] discovered / PAIRED / APP MESSAGE. Throwaway scaffolding to verify the transport on two real devices; the Pro Sync UI replaces it. Never runs in release. - Full app bundles on both platforms with react-native-tcp-socket + react-native-zeroconf JS. Next: native rebuild both platforms, then verify discovery + NaCl handshake iPhone<->Android over real mDNS+TCP via device logs. --- App.tsx | 13 +++++++++++ docs/SYNC_MOBILE_PROGRESS.md | 14 ++++++++++++ ios/Podfile.lock | 16 +++++++++++++ src/services/sync/devHarness.ts | 39 ++++++++++++++++++++++++++++++++ src/services/sync/localDevice.ts | 24 ++++++++++++++++++++ 5 files changed, 106 insertions(+) create mode 100644 src/services/sync/devHarness.ts create mode 100644 src/services/sync/localDevice.ts diff --git a/App.tsx b/App.tsx index 4a21237ef..30bf76327 100644 --- a/App.tsx +++ b/App.tsx @@ -325,6 +325,19 @@ function App() { initializeApp(); }, [initializeApp]); + // DEV ONLY: prove the @offgrid/sync transport on real devices (discovery + NaCl handshake). + // Dynamically imported + double-gated so it never touches release builds. Removed once the Pro + // Sync UI drives the engine. Delayed so the app is interactive before the mDNS/TCP scan starts. + useEffect(() => { + if (!__DEV__) return; + const t = setTimeout(() => { + import('./src/services/sync/devHarness') + .then((m) => { if (m.SYNC_DEV_HARNESS) return m.startSyncDevHarness(); }) + .catch(() => { /* dev harness is best-effort */ }); + }, 4000); + return () => clearTimeout(t); + }, []); + const handleUnlock = useCallback(() => { setLocked(false); }, [setLocked]); diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 54584d9a3..4ba0563c9 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -50,3 +50,17 @@ high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphra ## Branch `feat/sync-integration-phase0` (mobile). Commits are small + each has tests + hygiene. + +## Prior-art decision (2026-07-26) +`feature/sync` (based 2026-07-09, now **708 commits behind main**) has a full prior mobile +Track-A implementation: `src/services/backup/{backupArchive,backupData,backupFiles,backupIo, +backupService,types}.ts` (the four-port adapters over `@offgrid/sync/portable` — CURRENT API, +`BackupEngine`/`BackupDataPort`/`BundleError`), a `BackupRestoreScreen`, `portableWorkspace/import +journal`, and store/RAG changes. It's the impl the desktop plan mirrors. +**Decision:** do NOT merge the stale branch (708 commits of store/screen/RAG drift = conflict mess). +Instead, in **Phase 1**: reuse the four-port ARCHITECTURE + lift the near-pure adapters +(archive/files/io) as reference, and REWRITE `backupData` against current stores (chatStore/ +projectStore/appStore/ragService have all drifted). The transport layer (Phase 0, this branch) is +fresh + aligned to the current `@offgrid/sync` package — keep it. Envelope + ID-stability rules +(projects/threads/conversations = merge-by-id; messages/chunks/memories = rebuild) come from the +shared package, matching desktop. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 743f99b4f..b4e0f3238 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,5 +1,6 @@ PODS: - boost (1.84.0) + - CocoaAsyncSocket (7.6.5) - DoubleConversion (1.1.6) - fast_float (8.0.0) - FBLazyVector (0.83.1) @@ -2299,8 +2300,13 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - react-native-tcp-socket (6.4.1): + - CocoaAsyncSocket + - React-Core - react-native-voice (3.2.4): - React-Core + - react-native-zeroconf (0.14.0): + - React-Core - React-NativeModulesApple (0.83.1): - boost - DoubleConversion @@ -3541,7 +3547,9 @@ DEPENDENCIES: - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - "react-native-slider (from `../node_modules/@react-native-community/slider`)" + - react-native-tcp-socket (from `../node_modules/react-native-tcp-socket`) - "react-native-voice (from `../node_modules/@react-native-voice/voice`)" + - react-native-zeroconf (from `../node_modules/react-native-zeroconf`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) @@ -3596,6 +3604,7 @@ DEPENDENCIES: SPEC REPOS: trunk: + - CocoaAsyncSocket - MMKV - MMKVCore - opencv-rne @@ -3712,8 +3721,12 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-safe-area-context" react-native-slider: :path: "../node_modules/@react-native-community/slider" + react-native-tcp-socket: + :path: "../node_modules/react-native-tcp-socket" react-native-voice: :path: "../node_modules/@react-native-voice/voice" + react-native-zeroconf: + :path: "../node_modules/react-native-zeroconf" React-NativeModulesApple: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-networking: @@ -3817,6 +3830,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 + CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: 309703e71d3f2f1ed7dc7889d58309c9d77a95a4 @@ -3873,7 +3887,9 @@ SPEC CHECKSUMS: react-native-keyboard-controller: 7534b5a39d1e8b2b79f86e8e998ed71c7154f69f react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 react-native-slider: 34064ca1a6864d7b263e44dd76a2d794e8d26744 + react-native-tcp-socket: 7c7e53a07f122ecf00fb3626684bc0ca82c4f044 react-native-voice: 908a0eba96c8c3d643e4f98b7232c6557d0a6f9c + react-native-zeroconf: eb2e5584308f20f5fc3eb0cea2ceafbbd345b48b React-NativeModulesApple: a2c3d2cbec893956a5b3e4060322db2984fff75b React-networking: 3f98bd96893a294376e7e03730947a08d474c380 React-oscompat: 80166b66da22e7af7fad94474e9997bd52d4c8c6 diff --git a/src/services/sync/devHarness.ts b/src/services/sync/devHarness.ts new file mode 100644 index 000000000..1698bc1cf --- /dev/null +++ b/src/services/sync/devHarness.ts @@ -0,0 +1,39 @@ +// TEMPORARY dev-only harness to prove the Sync transport end-to-end on real devices: start the +// engine + mDNS, auto-pair any discovered peer with a fixed dev passphrase, and log each step so +// two devices' logs show discovery → NaCl handshake → app message. NOT the shipped feature (the Pro +// Sync UI replaces this); gated behind __DEV__ + SYNC_DEV_HARNESS so it never runs in a real build. +import { createNativeSync } from './nativeSync'; +import type { NativeSync } from './nativeSync'; +import { getOrCreateLocalDevice } from './localDevice'; +import logger from '../../utils/logger'; + +/** Flip to true to exercise the on-device transport proof. Dev builds only. */ +export const SYNC_DEV_HARNESS = true; +const DEV_PASSPHRASE = 'offgrid-dev-pair'; + +let harness: NativeSync | null = null; + +export async function startSyncDevHarness(): Promise { + if (harness) return; + const local = await getOrCreateLocalDevice(); + logger.log(`[SYNC] dev harness starting name=${local.name} id=${local.id} platform=${local.platform}`); + + let sync: NativeSync; + sync = createNativeSync(local, { + getPassphrase: () => DEV_PASSPHRASE, + onDiscovered: (d) => { + logger.log(`[SYNC] discovered name=${d.name} id=${d.id} host=${d.host} port=${d.port} — auto-pairing (dev)`); + sync.pair(d, DEV_PASSPHRASE).catch((e) => logger.warn(`[SYNC] pair error: ${String(e)}`)); + }, + onPaired: (d) => { + logger.log(`[SYNC] PAIRED id=${d.id} — sending test app message`); + const ok = sync.sendApp(d.id, 'devtest', { from: local.id }); + logger.log(`[SYNC] test app message queued=${ok}`); + }, + onPairingFailed: (r, err) => logger.warn(`[SYNC] pairing FAILED with ${r?.id ?? 'unknown'}: ${err}`), + onAppMessage: (id, channel, data) => logger.log(`[SYNC] APP MESSAGE from ${id} ch=${channel} data=${JSON.stringify(data)}`), + onLost: (id) => logger.log(`[SYNC] lost id=${id}`), + }); + harness = sync; + await sync.start(); +} diff --git a/src/services/sync/localDevice.ts b/src/services/sync/localDevice.ts new file mode 100644 index 000000000..a0c797706 --- /dev/null +++ b/src/services/sync/localDevice.ts @@ -0,0 +1,24 @@ +// Stable local DeviceInfo for @offgrid/sync: a persisted device id (so a peer recognizes us across +// restarts), a human name, and the platform tag. Used by the Sync service + dev harness. +import AsyncStorage from '@react-native-async-storage/async-storage'; +import DeviceInfo from 'react-native-device-info'; +import { generateDeviceId } from '@offgrid/sync'; +import type { DeviceInfo as SyncDeviceInfo } from '@offgrid/sync'; +import { currentPlatform } from './nativeSync'; + +const DEVICE_ID_KEY = '@offgrid/sync/deviceId'; + +export async function getOrCreateLocalDevice(): Promise { + let id = await AsyncStorage.getItem(DEVICE_ID_KEY); + if (!id) { + id = generateDeviceId(); + await AsyncStorage.setItem(DEVICE_ID_KEY, id); + } + let name = 'Off Grid Device'; + try { + name = DeviceInfo.getDeviceNameSync() || name; + } catch { + /* name is best-effort */ + } + return { id, name, platform: currentPlatform(), version: '1', host: '', port: 0 }; +} From b59b96df517f87f7f5bb6a35fe5e78abce3bd37d Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 17:54:17 +0530 Subject: [PATCH 007/332] =?UTF-8?q?feat(sync):=20Sync=20screen=20=E2=80=94?= =?UTF-8?q?=20pair=20devices=20over=20Wi-Fi=20(Settings=20=E2=86=92=20Sync?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-facing surface (replaces the invisible dev harness, now off): a Sync screen that starts the engine on open, shows this device + status, lists mDNS-discovered peers, and pairs a tapped device using a shared code both sides enter. Wired into Settings + the nav stack. - syncStore: UI state (status, thisDevice, discovered[], paired[], shared pairingCode) the service pushes into; paired devices move out of the discovered list. - syncService: singleton owning the engine lifecycle via the tested nativeSync factories; reflects discovery/pairing into the store; holds shared secrets in-memory (persist later). - Tests: syncStore transitions (dedup, paired-moves-out, reset) 4/4; SyncScreen render (starts on mount, renders device + empty state, gates pairing on a code, dials tapped device with the code) 4/4. tsc/lint clean; app bundles with the screen wired on both platforms. Next: Keygen device (machine) management in this screen — user-controlled deactivation. --- __tests__/rntl/screens/SyncScreen.test.tsx | 66 ++++++++++++ __tests__/unit/stores/syncStore.test.ts | 51 +++++++++ src/navigation/AppNavigator.tsx | 2 + src/navigation/types.ts | 1 + src/screens/SettingsScreen.tsx | 1 + src/screens/SyncScreen/index.tsx | 118 +++++++++++++++++++++ src/screens/SyncScreen/styles.ts | 44 ++++++++ src/screens/index.ts | 1 + src/services/sync/devHarness.ts | 2 +- src/services/sync/syncService.ts | 62 +++++++++++ src/stores/syncStore.ts | 42 ++++++++ 11 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 __tests__/rntl/screens/SyncScreen.test.tsx create mode 100644 __tests__/unit/stores/syncStore.test.ts create mode 100644 src/screens/SyncScreen/index.tsx create mode 100644 src/screens/SyncScreen/styles.ts create mode 100644 src/services/sync/syncService.ts create mode 100644 src/stores/syncStore.ts diff --git a/__tests__/rntl/screens/SyncScreen.test.tsx b/__tests__/rntl/screens/SyncScreen.test.tsx new file mode 100644 index 000000000..42087b0e9 --- /dev/null +++ b/__tests__/rntl/screens/SyncScreen.test.tsx @@ -0,0 +1,66 @@ +/** + * SyncScreen: the user-facing Sync surface. Verifies it starts the engine on mount, renders this + * device + discovered peers, gates pairing on a code, and dials the tapped device with that code. + * syncService is mocked (it imports native modules); the screen's own logic + store wiring is real. + */ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { useSyncStore } from '../../../src/stores/syncStore'; +import { SyncScreen } from '../../../src/screens/SyncScreen'; + +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: jest.fn() }), +})); +jest.mock('../../../src/theme', () => ({ + useTheme: () => ({ colors: { background: '#000', text: '#fff', textSecondary: '#aaa', textMuted: '#666', surfaceLight: '#222', border: '#333', primary: '#0a0' } }), + useThemedStyles: (fn: any) => fn({ background: '#000', text: '#fff', textSecondary: '#aaa', textMuted: '#666', surfaceLight: '#222', border: '#333', primary: '#0a0' }, {}), +})); +const mockStart = jest.fn(); +const mockStop = jest.fn(); +const mockPair = jest.fn().mockResolvedValue(undefined); +jest.mock('../../../src/services/sync/syncService', () => ({ + syncService: { start: (...a: any[]) => mockStart(...a), stop: (...a: any[]) => mockStop(...a), pair: (...a: any[]) => mockPair(...a) }, +})); + +const disc = (id: string) => ({ id, name: `Device ${id}`, platform: 'macos', version: '1', host: '10.0.0.9', port: 7 } as any); + +beforeEach(() => { + jest.clearAllMocks(); + useSyncStore.getState().reset(); + useSyncStore.getState().setPairingCode(''); +}); + +describe('SyncScreen', () => { + it('starts the engine on mount and stops it on unmount', () => { + const { unmount } = render(); + expect(mockStart).toHaveBeenCalledTimes(1); + unmount(); + expect(mockStop).toHaveBeenCalledTimes(1); + }); + + it('renders this device + the empty discovered state', () => { + useSyncStore.getState().setThisDevice({ id: 'me', name: 'My Phone', platform: 'ios', version: '1', host: '', port: 0 }); + useSyncStore.getState().setStatus('running'); + const { getByTestId } = render(); + expect(getByTestId('sync-this-device').props.children).toBe('My Phone'); + expect(getByTestId('sync-no-devices')).toBeTruthy(); + }); + + it('does NOT pair when no code is entered (button disabled)', () => { + useSyncStore.getState().upsertDiscovered(disc('a')); + const { getByTestId } = render(); + fireEvent.press(getByTestId('sync-pair-a')); + expect(mockPair).not.toHaveBeenCalled(); + }); + + it('dials the tapped device with the entered pairing code', async () => { + useSyncStore.getState().upsertDiscovered(disc('a')); + const { getByTestId } = render(); + fireEvent.changeText(getByTestId('sync-pairing-code'), 'blue-otter-42'); + fireEvent.press(getByTestId('sync-pair-a')); + await waitFor(() => expect(mockPair).toHaveBeenCalledTimes(1)); + expect(mockPair.mock.calls[0][0].id).toBe('a'); + expect(mockPair.mock.calls[0][1]).toBe('blue-otter-42'); + }); +}); diff --git a/__tests__/unit/stores/syncStore.test.ts b/__tests__/unit/stores/syncStore.test.ts new file mode 100644 index 000000000..85a785e92 --- /dev/null +++ b/__tests__/unit/stores/syncStore.test.ts @@ -0,0 +1,51 @@ +/** + * Sync UI store: the device-list state the SyncScreen renders. Pure zustand — asserts the state + * transitions the syncService drives (discovery dedup, paired-moves-out-of-discovered, reset). + */ +import { useSyncStore } from '../../../src/stores/syncStore'; + +const disc = (id: string, host = '1.2.3.4') => ({ id, name: id, platform: 'macos', version: '1', host, port: 7 } as any); +const paired = (id: string) => ({ id, name: id, platform: 'macos', version: '1', host: '', port: 7, sharedSecret: 's' } as any); + +beforeEach(() => useSyncStore.getState().reset()); + +describe('useSyncStore', () => { + it('upsertDiscovered de-dupes by id (a moved peer replaces, never duplicates)', () => { + const s = useSyncStore.getState(); + s.upsertDiscovered(disc('a', '1.1.1.1')); + s.upsertDiscovered(disc('a', '2.2.2.2')); // same id, new host + const d = useSyncStore.getState().discovered; + expect(d).toHaveLength(1); + expect(d[0].host).toBe('2.2.2.2'); + }); + + it('addPaired moves the device out of discovered and into paired', () => { + const s = useSyncStore.getState(); + s.upsertDiscovered(disc('a')); + s.upsertDiscovered(disc('b')); + s.addPaired(paired('a')); + const st = useSyncStore.getState(); + expect(st.paired.map((p) => p.id)).toEqual(['a']); + expect(st.discovered.map((d) => d.id)).toEqual(['b']); // 'a' left the discovered list + }); + + it('removeDiscovered drops only the matching device', () => { + const s = useSyncStore.getState(); + s.upsertDiscovered(disc('a')); + s.upsertDiscovered(disc('b')); + s.removeDiscovered('a'); + expect(useSyncStore.getState().discovered.map((d) => d.id)).toEqual(['b']); + }); + + it('reset clears status/error/lists (pairingCode + thisDevice are session state, untouched here)', () => { + const s = useSyncStore.getState(); + s.setStatus('running'); + s.upsertDiscovered(disc('a')); + s.addPaired(paired('b')); + s.reset(); + const st = useSyncStore.getState(); + expect(st.status).toBe('idle'); + expect(st.discovered).toEqual([]); + expect(st.paired).toEqual([]); + }); +}); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index fac8bb11c..4c4f995c4 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -36,6 +36,7 @@ import { SecuritySettingsScreen, GalleryScreen, RemoteServersScreen, + SyncScreen, ProDetailScreen, AboutScreen, ToolsScreen, @@ -234,6 +235,7 @@ export const AppNavigator: React.FC = () => { + diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 04c7b8dfa..4dfcd902c 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -15,6 +15,7 @@ export type RootStackParamList = { // Former SettingsStack ModelSettings: undefined; RemoteServers: undefined; + Sync: undefined; DeviceInfo: undefined; StorageSettings: undefined; SecuritySettings: undefined; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index b19447ce9..2b30e25c6 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -189,6 +189,7 @@ export const SettingsScreen: React.FC = () => { {[ { icon: 'sliders', title: 'Model Settings', desc: 'System prompt, generation, and performance', screen: 'ModelSettings' as const }, { icon: 'wifi', title: 'Remote Servers', desc: 'Connect to Off Grid AI Desktop, Ollama, LM Studio, and more', screen: 'RemoteServers' as const }, + { icon: 'refresh-cw', title: 'Sync', desc: 'Pair your devices and sync chats, projects, and models over your Wi-Fi', screen: 'Sync' as const }, // { icon: 'search', title: 'Web Search', desc: 'Configure search API key for reliable results', screen: 'WebSearchSettings' as const }, { icon: 'lock', title: 'Security', desc: 'Passphrase and app lock', screen: 'SecuritySettings' as const }, { icon: 'smartphone', title: 'Device Information', desc: 'Hardware and compatibility', screen: 'DeviceInfo' as const }, diff --git a/src/screens/SyncScreen/index.tsx b/src/screens/SyncScreen/index.tsx new file mode 100644 index 000000000..2269c3feb --- /dev/null +++ b/src/screens/SyncScreen/index.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Icon from 'react-native-vector-icons/Feather'; +import { useNavigation } from '@react-navigation/native'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useSyncStore } from '../../stores/syncStore'; +import { syncService } from '../../services/sync/syncService'; +import { createStyles } from './styles'; +import type { DiscoveredDevice } from '@offgrid/sync'; + +export const SyncScreen: React.FC = () => { + const navigation = useNavigation(); + const theme = useTheme(); + const styles = useThemedStyles(createStyles); + const { status, error, thisDevice, discovered, paired, pairingCode, setPairingCode } = useSyncStore(); + const [pairingId, setPairingId] = useState(null); + + // Start the engine when the screen opens; stop when it closes. + useEffect(() => { + syncService.start(); + return () => { syncService.stop(); }; + }, []); + + const handlePair = useCallback(async (device: DiscoveredDevice) => { + if (!pairingCode.trim()) return; + setPairingId(device.id); + try { + await syncService.pair(device, pairingCode.trim()); + } finally { + setPairingId(null); + } + }, [pairingCode]); + + const statusLabel = + status === 'running' ? 'Discoverable on your Wi-Fi' + : status === 'starting' ? 'Starting…' + : status === 'error' ? `Error: ${error ?? 'unknown'}` + : 'Off'; + + return ( + + + navigation.goBack()} accessibilityLabel="Back"> + + + Sync + + + + {/* This device + status */} + + + This device + {status === 'starting' && } + {status === 'running' && } + + {thisDevice?.name ?? '—'} + {statusLabel} + + + {/* Shared pairing code */} + + Pairing code + Enter the SAME code on both devices, then tap a discovered device to pair. + + + + {/* Discovered devices */} + DISCOVERED + {discovered.length === 0 ? ( + No devices found yet. Open Sync on another device on the same Wi-Fi. + ) : ( + discovered.map((d) => ( + + + {d.name} + {d.platform} · {d.host} + + handlePair(d)} + testID={`sync-pair-${d.id}`} + > + {pairingId === d.id + ? + : Pair} + + + )) + )} + + {/* Paired devices */} + {paired.length > 0 && ( + <> + PAIRED + {paired.map((d) => ( + + + {d.name ?? d.id} + + ))} + + )} + + + ); +}; diff --git a/src/screens/SyncScreen/styles.ts b/src/screens/SyncScreen/styles.ts new file mode 100644 index 000000000..9d72a2e16 --- /dev/null +++ b/src/screens/SyncScreen/styles.ts @@ -0,0 +1,44 @@ +import { SPACING, TYPOGRAPHY } from '../../constants'; +import type { ThemeColors, ThemeShadows } from '../../theme'; + +export function createStyles(colors: ThemeColors, _shadows: ThemeShadows) { + return { + container: { flex: 1, backgroundColor: colors.background }, + header: { + flexDirection: 'row' as const, alignItems: 'center' as const, + paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm, + borderBottomWidth: 1, borderBottomColor: colors.border, + }, + backButton: { padding: SPACING.xs, marginRight: SPACING.sm }, + title: { ...TYPOGRAPHY.h2, color: colors.text }, + scrollView: { flex: 1 }, + content: { padding: SPACING.md, gap: SPACING.md }, + card: { padding: SPACING.md, borderRadius: 12, backgroundColor: colors.surfaceLight, gap: 4 }, + rowBetween: { flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'space-between' as const }, + cardTitle: { ...TYPOGRAPHY.label, color: colors.textSecondary }, + deviceName: { ...TYPOGRAPHY.body, color: colors.text }, + statusText: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary }, + hint: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary, marginBottom: SPACING.xs }, + input: { + ...TYPOGRAPHY.body, color: colors.text, borderWidth: 1, borderColor: colors.border, + borderRadius: 8, paddingHorizontal: SPACING.sm, paddingVertical: SPACING.sm, backgroundColor: colors.background, + }, + sectionLabel: { ...TYPOGRAPHY.label, color: colors.textMuted, marginTop: SPACING.sm }, + empty: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted }, + deviceRow: { + flexDirection: 'row' as const, alignItems: 'center' as const, gap: SPACING.sm, + padding: SPACING.md, borderRadius: 12, backgroundColor: colors.surfaceLight, + }, + flex1: { flex: 1 }, + deviceRowName: { ...TYPOGRAPHY.body, color: colors.text }, + pairedName: { flex: 1 }, + deviceRowSub: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary }, + pairButton: { + paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm, borderRadius: 8, + backgroundColor: colors.primary, minWidth: 64, alignItems: 'center' as const, + }, + pairButtonDisabled: { opacity: 0.4 }, + pairButtonText: { ...TYPOGRAPHY.body, color: colors.background }, + dotOn: { width: 10, height: 10, borderRadius: 5, backgroundColor: colors.primary }, + }; +} diff --git a/src/screens/index.ts b/src/screens/index.ts index ea0d84ed2..713043fb5 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -20,6 +20,7 @@ export { DeviceInfoScreen } from './DeviceInfoScreen'; export { StorageSettingsScreen } from './StorageSettingsScreen'; export { SecuritySettingsScreen } from './SecuritySettingsScreen'; export { RemoteServersScreen } from './RemoteServersScreen'; +export { SyncScreen } from './SyncScreen'; export { ProDetailScreen } from './ProDetailScreen'; export { AboutScreen } from './AboutScreen'; export { ToolsScreen } from './ToolsScreen'; diff --git a/src/services/sync/devHarness.ts b/src/services/sync/devHarness.ts index 1698bc1cf..9e7888b50 100644 --- a/src/services/sync/devHarness.ts +++ b/src/services/sync/devHarness.ts @@ -8,7 +8,7 @@ import { getOrCreateLocalDevice } from './localDevice'; import logger from '../../utils/logger'; /** Flip to true to exercise the on-device transport proof. Dev builds only. */ -export const SYNC_DEV_HARNESS = true; +export const SYNC_DEV_HARNESS = false; // Sync screen drives the engine now; harness kept for headless transport probing const DEV_PASSPHRASE = 'offgrid-dev-pair'; let harness: NativeSync | null = null; diff --git a/src/services/sync/syncService.ts b/src/services/sync/syncService.ts new file mode 100644 index 000000000..e8de0e8fb --- /dev/null +++ b/src/services/sync/syncService.ts @@ -0,0 +1,62 @@ +// Singleton that owns the @offgrid/sync engine lifecycle for the app and reflects its events into +// useSyncStore for the Sync screen. Pairing uses a shared code (the passphrase) both devices enter; +// paired shared-secrets are held in-memory for now (persist in a later slice so reconnects are +// silent). Thin: all protocol/crypto is the package; wiring is the tested factories via nativeSync. +import { createNativeSync } from './nativeSync'; +import type { NativeSync } from './nativeSync'; +import { getOrCreateLocalDevice } from './localDevice'; +import { useSyncStore } from '../../stores/syncStore'; +import logger from '../../utils/logger'; +import type { DeviceInfo } from '@offgrid/sync'; + +let sync: NativeSync | null = null; +const sharedSecrets = new Map(); + +export const syncService = { + isRunning: (): boolean => sync !== null, + + async start(): Promise { + if (sync) return; + const store = useSyncStore.getState(); + store.setStatus('starting'); + try { + const local = await getOrCreateLocalDevice(); + store.setThisDevice(local); + const s = createNativeSync(local, { + // Inbound pairing: use the code the user entered on THIS device (both sides enter the same). + getPassphrase: () => useSyncStore.getState().pairingCode || undefined, + getSharedSecret: (id) => sharedSecrets.get(id), + onDiscovered: (d) => useSyncStore.getState().upsertDiscovered(d), + onLost: (id) => useSyncStore.getState().removeDiscovered(id), + onPaired: (d) => { + sharedSecrets.set(d.id, d.sharedSecret); + useSyncStore.getState().addPaired(d); + logger.log(`[SYNC] paired with ${d.name ?? d.id}`); + }, + onPairingFailed: (r, err) => logger.warn(`[SYNC] pairing failed with ${r?.id ?? 'unknown'}: ${err}`), + onAppMessage: (id, channel, data) => + logger.log(`[SYNC] app message from ${id} ch=${channel} ${JSON.stringify(data)}`), + }); + sync = s; + await s.start(); + store.setStatus('running'); + } catch (e) { + logger.warn(`[SYNC] start failed: ${String(e)}`); + store.setStatus('error', e instanceof Error ? e.message : String(e)); + } + }, + + async stop(): Promise { + if (!sync) return; + try { await sync.stop(); } finally { + sync = null; + useSyncStore.getState().reset(); + } + }, + + /** Dial a discovered device and run the pairing handshake with the shared code. */ + async pair(device: DeviceInfo, code: string): Promise { + if (!sync) throw new Error('Sync is not running'); + await sync.pair(device, code); + }, +}; diff --git a/src/stores/syncStore.ts b/src/stores/syncStore.ts new file mode 100644 index 000000000..b1aa34c55 --- /dev/null +++ b/src/stores/syncStore.ts @@ -0,0 +1,42 @@ +// UI state for the Sync feature: the running status, this device, and the discovered/paired peers. +// The syncService owns the @offgrid/sync engine and pushes updates here; the SyncScreen renders it. +import { create } from 'zustand'; +import type { DeviceInfo, DiscoveredDevice, PairedDevice } from '@offgrid/sync'; + +export type SyncStatus = 'idle' | 'starting' | 'running' | 'error'; + +interface SyncState { + status: SyncStatus; + error?: string; + thisDevice?: DeviceInfo; + /** Shared pairing code both devices enter (the pairing passphrase). */ + pairingCode: string; + discovered: DiscoveredDevice[]; + paired: PairedDevice[]; + setPairingCode: (code: string) => void; + setStatus: (status: SyncStatus, error?: string) => void; + setThisDevice: (d: DeviceInfo) => void; + upsertDiscovered: (d: DiscoveredDevice) => void; + removeDiscovered: (id: string) => void; + addPaired: (d: PairedDevice) => void; + reset: () => void; +} + +export const useSyncStore = create((set) => ({ + status: 'idle', + pairingCode: '', + discovered: [], + paired: [], + setPairingCode: (pairingCode) => set({ pairingCode }), + setStatus: (status, error) => set({ status, error }), + setThisDevice: (thisDevice) => set({ thisDevice }), + upsertDiscovered: (d) => + set((s) => ({ discovered: [...s.discovered.filter((x) => x.id !== d.id), d] })), + removeDiscovered: (id) => set((s) => ({ discovered: s.discovered.filter((x) => x.id !== id) })), + addPaired: (d) => + set((s) => ({ + paired: [...s.paired.filter((x) => x.id !== d.id), d], + discovered: s.discovered.filter((x) => x.id !== d.id), // paired → leaves the "discovered" list + })), + reset: () => set({ status: 'idle', error: undefined, discovered: [], paired: [] }), +})); From 74e0f23c57b4be210027f544228ec761fe9270f9 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:04:08 +0530 Subject: [PATCH 008/332] feat(pro): replace least-recent device at license cap --- .../unit/services/proLicenseService.test.ts | 17 ++++- src/services/proLicenseService.ts | 71 ++++++++++++++++--- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts index 3f9c53025..1091d4d6a 100644 --- a/__tests__/unit/services/proLicenseService.test.ts +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -53,6 +53,8 @@ describe('proLicenseService (Keygen)', () => { setGenericPassword.mockResolvedValue(true); resetGenericPassword.mockResolvedValue(true); validateKey.mockResolvedValue({ valid: false, code: 'UNKNOWN', license: null }); + listMachines.mockResolvedValue([]); + deactivateMachine.mockResolvedValue(false); }); describe('readProFromKeychain()', () => { @@ -111,9 +113,20 @@ describe('proLicenseService (Keygen)', () => { expect(await activateProByKey('key/abc')).toEqual({ ok: false, reason: 'limit' }); }); - it('reports limit when validate already says TOO_MANY_MACHINES', async () => { + it('replaces the least recently seen device when validate says TOO_MANY_MACHINES', async () => { validateKey.mockResolvedValueOnce({ valid: false, code: 'TOO_MANY_MACHINES', license: { id: 'lic-1', expiry: null, metadata: {}, name: null } }); - expect(await activateProByKey('key/abc')).toEqual({ ok: false, reason: 'limit' }); + activateMachine + .mockResolvedValueOnce({ ok: false, limitReached: true }) + .mockResolvedValueOnce({ ok: true, limitReached: false }); + listMachines.mockResolvedValueOnce([ + { id: 'recent', fingerprint: 'fp-recent', platform: 'android', name: null, lastSeen: '2026-07-20T00:00:00Z' }, + { id: 'oldest', fingerprint: 'fp-oldest', platform: 'ios', name: null, lastSeen: '2026-01-01T00:00:00Z' }, + ]); + deactivateMachine.mockResolvedValueOnce(true); + + expect(await activateProByKey('key/abc')).toEqual({ ok: true }); + expect(deactivateMachine).toHaveBeenCalledWith('key/abc', 'oldest'); + expect(activateMachine).toHaveBeenCalledTimes(2); }); it('reports invalid for an unknown / not-found key', async () => { diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index a9381f95a..65181d6dd 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -26,6 +26,7 @@ const KEYCHAIN_SERVICE = 'off-grid-pro-license'; // Public web pay page (RevenueCat checkout). "Get Pro" opens this; the buyer is // emailed a license key by the issuance Worker and enters it via activateProByKey. export const PRO_PAY_PAGE_URL = 'https://offgridmobileai.co/pay'; +export const PRO_DEVICE_LIMIT = 5; export type ActivateResult = { ok: true } | { ok: false; reason: 'invalid' | 'limit' | 'network' }; @@ -42,6 +43,47 @@ const EMPTY: ProLicense = { isPro: false, key: null, licenseId: null, expiry: nu const REVOKED_CODES = ['EXPIRED', 'SUSPENDED', 'BANNED', 'OVERDUE', 'NOT_FOUND']; const NEEDS_ACTIVATION = ['NO_MACHINE', 'NO_MACHINES', 'FINGERPRINT_SCOPE_MISMATCH']; +function lastSeenTimestamp(machine: KeygenMachine): number { + if (!machine.lastSeen) return Number.NEGATIVE_INFINITY; + const timestamp = Date.parse(machine.lastSeen); + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; +} + +/** + * A sixth device replaces the machine least recently seen. Keygen's machine + * response uses lastHeartbeat when available and creation time otherwise, so + * this is stable for clients that do not heartbeat yet. + */ +function selectReplacementMachine( + machines: KeygenMachine[], + currentFingerprint: string, +): KeygenMachine | undefined { + return machines + .filter((machine) => machine.fingerprint !== currentFingerprint) + .sort((left, right) => { + const timestampDifference = lastSeenTimestamp(left) - lastSeenTimestamp(right); + return timestampDifference || left.id.localeCompare(right.id); + })[0]; +} + +async function activateWithAutomaticReplacement( + key: string, + licenseId: string, + device: { fingerprint: string; platform: string }, +): Promise<{ ok: boolean; limitReached: boolean }> { + const activation = await activateMachine(key, licenseId, device); + if (activation.ok || !activation.limitReached) return activation; + + const machines = await listMachines(key, licenseId); + const replacement = selectReplacementMachine(machines, device.fingerprint); + if (!replacement) return activation; + + const removed = await deactivateMachine(key, replacement.id); + if (!removed) return activation; + + logger.log(`[Pro] replaced least recently seen device ${replacement.id}`); + return activateMachine(key, licenseId, device); +} function setProInStore(isPro: boolean): void { const { useAppStore } = require('../stores/appStore'); @@ -50,11 +92,9 @@ function setProInStore(isPro: boolean): void { /** Whether the cached license grants Pro right now (offline-safe). */ function isProActive(lic: ProLicense): boolean { - if (!lic.isPro) return false; // Monthly keys carry an expiry — once it passes, no Pro even offline. Lifetime // keys have null expiry. Revocation propagates at the next online revalidate. - if (lic.expiry && Date.parse(lic.expiry) <= Date.now()) return false; - return true; + return lic.isPro && (!lic.expiry || Date.parse(lic.expiry) > Date.now()); } async function writeLicense(lic: ProLicense): Promise { @@ -157,9 +197,14 @@ export async function revalidatePro(): Promise { } else if (REVOKED_CODES.includes(r.code)) { await writeLicense({ ...lic, isPro: false, expiry: r.license?.expiry ?? lic.expiry, verifiedAt: Date.now() }); setProInStore(false); - } 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, { fingerprint: fp, platform: getPlatformTag() }); + } else if ((NEEDS_ACTIVATION.includes(r.code) || r.code === 'TOO_MANY_MACHINES') && r.license) { + // Valid key but this device lost its slot. A full license replaces the + // least recently seen machine before claiming this device. + const act = await activateWithAutomaticReplacement( + lic.key, + r.license.id, + { fingerprint: fp, platform: getPlatformTag() }, + ); await writeLicense({ isPro: act.ok, key: lic.key, @@ -169,7 +214,7 @@ export async function revalidatePro(): Promise { }); setProInStore(act.ok); } - // TOO_MANY_MACHINES / UNKNOWN: leave the cached state untouched. + // UNKNOWN: leave the cached state untouched. } catch (e) { if (e instanceof KeygenNetworkError) return; // offline — keep cached access logger.error(`[Pro] revalidate error: ${e instanceof Error ? e.message : String(e)}`); @@ -203,14 +248,18 @@ export async function activateProByKey(rawKey: string): Promise setProInStore(true); return { ok: true }; } - if (r.code === 'TOO_MANY_MACHINES') return { ok: false, reason: 'limit' }; 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)) { + // Valid key, this device not yet activated. If all five slots are occupied, + // remove the least recently seen machine and claim its slot. + if (NEEDS_ACTIVATION.includes(r.code) || r.code === 'TOO_MANY_MACHINES') { let act; try { - act = await activateMachine(key, r.license.id, { fingerprint: fp, platform: getPlatformTag() }); + act = await activateWithAutomaticReplacement( + key, + r.license.id, + { fingerprint: fp, platform: getPlatformTag() }, + ); } catch { return { ok: false, reason: 'network' }; } From 280aa4dd80f2235383bfc256472ebeb0fc3b5358 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:13:10 +0530 Subject: [PATCH 009/332] test(pro): verify automatic license slot replacement --- .../keygenAutomaticReplacement.test.ts | 135 ++++++++++++++++++ .../rntl/screens/ProDetailScreen.test.tsx | 4 +- jest.setup.ts | 1 + .../ProDetailScreen/ProUnlockModal.tsx | 2 +- 4 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 __tests__/integration/licensing/keygenAutomaticReplacement.test.ts diff --git a/__tests__/integration/licensing/keygenAutomaticReplacement.test.ts b/__tests__/integration/licensing/keygenAutomaticReplacement.test.ts new file mode 100644 index 000000000..f94243db8 --- /dev/null +++ b/__tests__/integration/licensing/keygenAutomaticReplacement.test.ts @@ -0,0 +1,135 @@ +import * as Keychain from 'react-native-keychain'; +import { + activateProByKey, + readProFromKeychain, +} from '../../../src/services/proLicenseService'; + +interface FakeMachine { + id: string; + fingerprint: string; + platform: string; + lastSeen: string; +} + +const storedSecrets = new Map(); +const originalFetch = global.fetch; + +function response(status: number, body: unknown = {}): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +describe('Keygen automatic device replacement', () => { + beforeEach(() => { + storedSecrets.clear(); + storedSecrets.set('off-grid-device-fingerprint', 'fp-current'); + (Keychain.getGenericPassword as jest.Mock).mockImplementation( + async ({ service }: { service: string }) => { + const value = storedSecrets.get(service); + return value ? { username: 'stored', password: value } : false; + }, + ); + (Keychain.setGenericPassword as jest.Mock).mockImplementation( + async (_username: string, password: string, { service }: { service: string }) => { + storedSecrets.set(service, password); + return true; + }, + ); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('removes the least recently seen machine and activates the sixth device', async () => { + const machines: FakeMachine[] = [ + { + id: 'oldest', + fingerprint: 'fp-oldest', + platform: 'ios', + lastSeen: '2025-01-01T00:00:00.000Z', + }, + { + id: 'recent-1', + fingerprint: 'fp-recent-1', + platform: 'android', + lastSeen: '2026-07-20T00:00:00.000Z', + }, + { + id: 'recent-2', + fingerprint: 'fp-recent-2', + platform: 'ios', + lastSeen: '2026-07-21T00:00:00.000Z', + }, + { + id: 'recent-3', + fingerprint: 'fp-recent-3', + platform: 'android', + lastSeen: '2026-07-22T00:00:00.000Z', + }, + { + id: 'recent-4', + fingerprint: 'fp-recent-4', + platform: 'macos', + lastSeen: '2026-07-23T00:00:00.000Z', + }, + ]; + + global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/licenses/actions/validate-key')) { + return response(200, { + meta: { valid: false, code: 'TOO_MANY_MACHINES' }, + data: { id: 'lic-1', attributes: { expiry: null, metadata: {}, name: null } }, + }); + } + if (url.endsWith('/licenses/lic-1/machines')) { + return response(200, { + data: machines.map((machine) => ({ + id: machine.id, + attributes: { + fingerprint: machine.fingerprint, + platform: machine.platform, + lastHeartbeat: machine.lastSeen, + }, + })), + }); + } + if (url.endsWith('/machines') && init?.method === 'POST') { + if (machines.length >= 5) { + return response(422, { + errors: [{ code: 'MACHINE_LIMIT_EXCEEDED', detail: 'machine limit exceeded' }], + }); + } + const body = JSON.parse(String(init.body)); + machines.push({ + id: 'current', + fingerprint: body.data.attributes.fingerprint, + platform: body.data.attributes.platform, + lastSeen: '2026-07-26T00:00:00.000Z', + }); + return response(201); + } + const machineId = /\/machines\/([^/]+)$/.exec(url)?.[1]; + if (machineId && init?.method === 'DELETE') { + const index = machines.findIndex((machine) => machine.id === machineId); + if (index >= 0) machines.splice(index, 1); + return response(204); + } + return response(404); + }) as typeof fetch; + + await expect(activateProByKey('key/abc')).resolves.toEqual({ ok: true }); + await expect(readProFromKeychain()).resolves.toBe(true); + expect(machines.map((machine) => machine.id)).toEqual([ + 'recent-1', + 'recent-2', + 'recent-3', + 'recent-4', + 'current', + ]); + }); +}); diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx index ad8f661cc..ebcb2a8b6 100644 --- a/__tests__/rntl/screens/ProDetailScreen.test.tsx +++ b/__tests__/rntl/screens/ProDetailScreen.test.tsx @@ -123,13 +123,13 @@ describe('ProDetailScreen', () => { await waitFor(() => expect(getByText(/isn't valid or active/)).toBeTruthy()); }); - it('shows the device-limit error when the key is on its 5 devices', async () => { + it('shows a retryable error when automatic device replacement fails', async () => { mockActivateProByKey.mockResolvedValueOnce({ ok: false, reason: 'limit' }); const { getByText, getByTestId } = render(); fireEvent.press(getByText('I have a license key')); fireEvent.changeText(getByTestId('license-key-input'), 'key/full'); fireEvent.press(getByTestId('unlock-cta')); - await waitFor(() => expect(getByText(/already on its 5 devices/)).toBeTruthy()); + await waitFor(() => expect(getByText(/could not replace the least recently seen device/)).toBeTruthy()); }); it('keeps the activate button disabled until a key is entered', async () => { diff --git a/jest.setup.ts b/jest.setup.ts index 88aa8b53b..59cef0f46 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -370,6 +370,7 @@ jest.mock('react-native-keychain', () => ({ setGenericPassword: jest.fn(() => Promise.resolve(true)), getGenericPassword: jest.fn(() => Promise.resolve(false)), resetGenericPassword: jest.fn(() => Promise.resolve(true)), + ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' }, })); diff --git a/src/screens/ProDetailScreen/ProUnlockModal.tsx b/src/screens/ProDetailScreen/ProUnlockModal.tsx index e38b985c0..7fec72039 100644 --- a/src/screens/ProDetailScreen/ProUnlockModal.tsx +++ b/src/screens/ProDetailScreen/ProUnlockModal.tsx @@ -29,7 +29,7 @@ type Props = { function messageFor(reason: Extract['reason']): string { switch (reason) { case 'limit': - return 'This key is already on its 5 devices. Remove one on a device where Pro is active, then try again.'; + return 'This device could not replace the least recently seen device. Check your connection and try again.'; case 'network': return 'Could not reach the licensing server. Check your connection and try again.'; default: From a6bdbd94f5878978c56c9a2e869d9fdceea8595e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:13:20 +0530 Subject: [PATCH 010/332] refactor(sync): move Pro experience behind registries --- __tests__/pro/sync/SyncScreen.test.tsx | 185 ++++++++++++++++++ __tests__/pro/sync/syncStore.test.ts | 63 ++++++ __tests__/rntl/screens/SyncScreen.test.tsx | 66 ------- __tests__/unit/stores/syncStore.test.ts | 51 ----- pro | 2 +- src/navigation/AppNavigator.tsx | 2 - src/navigation/types.ts | 1 - .../ProDetailScreen/ProManageSection.tsx | 69 +++---- src/screens/SettingsScreen.tsx | 1 - src/screens/SyncScreen/index.tsx | 118 ----------- src/screens/SyncScreen/styles.ts | 44 ----- src/screens/index.ts | 1 - src/services/proLicenseService.ts | 6 +- src/services/sync/syncService.ts | 62 ------ src/stores/syncStore.ts | 42 ---- 15 files changed, 279 insertions(+), 434 deletions(-) create mode 100644 __tests__/pro/sync/SyncScreen.test.tsx create mode 100644 __tests__/pro/sync/syncStore.test.ts delete mode 100644 __tests__/rntl/screens/SyncScreen.test.tsx delete mode 100644 __tests__/unit/stores/syncStore.test.ts delete mode 100644 src/screens/SyncScreen/index.tsx delete mode 100644 src/screens/SyncScreen/styles.ts delete mode 100644 src/services/sync/syncService.ts delete mode 100644 src/stores/syncStore.ts diff --git a/__tests__/pro/sync/SyncScreen.test.tsx b/__tests__/pro/sync/SyncScreen.test.tsx new file mode 100644 index 000000000..c1f9df398 --- /dev/null +++ b/__tests__/pro/sync/SyncScreen.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { Alert } from 'react-native'; +import * as Keychain from 'react-native-keychain'; +import { NavigationContainer } from '@react-navigation/native'; +import { render, fireEvent, waitFor, within } from '@testing-library/react-native'; + +jest.mock('@react-navigation/native', () => jest.requireActual('@react-navigation/native')); + +import { AppNavigator } from '../../../src/navigation/AppNavigator'; +import { + registerScreen, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; +import { + registerSettingsSection, + _clearSectionsForTesting, +} from '../../../src/components/settings/sectionRegistry'; +import { useAppStore } from '../../../src/stores/appStore'; +import { createDownloadedModel } from '../../utils/factories'; +import { SyncScreen } from '../../../pro/ui/SyncScreen'; +import { SyncSettingsSection } from '../../../pro/ui/SyncSettingsSection'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { useLicensedDevicesStore } from '../../../pro/sync/licensedDevicesStore'; + +const mockTcpModule = { + createServer: jest.fn(() => { + let boundPort = 0; + const server = {} as { + on: jest.Mock; + listen: jest.Mock; + address: jest.Mock; + close: jest.Mock; + }; + server.on = jest.fn(() => server); + server.listen = jest.fn((options: { port: number }, callback?: () => void) => { + boundPort = options.port || 42001; + callback?.(); + }); + server.address = jest.fn(() => ({ port: boundPort })); + server.close = jest.fn(); + return server; + }), + createConnection: jest.fn(), +}; + +jest.mock('react-native-tcp-socket', () => ({ + __esModule: true, + default: mockTcpModule, +})); + +class MockZeroconf { + on = jest.fn(); + scan = jest.fn(); + stop = jest.fn(); + removeDeviceListeners = jest.fn(); + publishService = jest.fn(); + unpublishService = jest.fn(); +} + +jest.mock('react-native-zeroconf', () => ({ + __esModule: true, + default: MockZeroconf, +})); + +const originalFetch = global.fetch; +const storedSecrets = new Map(); + +function response(status: number, body: unknown = {}): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +describe('Settings to Sync licensed-device management', () => { + beforeEach(() => { + jest.clearAllMocks(); + _clearScreensForTesting(); + _clearSectionsForTesting(); + registerScreen({ name: 'Sync', component: SyncScreen }); + registerSettingsSection(SyncSettingsSection); + + useAppStore.setState({ + hasCompletedOnboarding: true, + downloadedModels: [createDownloadedModel()], + themeMode: 'dark', + }); + useSyncStore.getState().reset(); + useLicensedDevicesStore.setState({ + status: 'idle', + devices: [], + removingDeviceId: null, + error: undefined, + }); + + storedSecrets.clear(); + storedSecrets.set( + 'off-grid-pro-license', + JSON.stringify({ + isPro: true, + key: 'key/abc', + licenseId: 'lic-1', + expiry: null, + verifiedAt: 0, + }), + ); + storedSecrets.set('off-grid-device-fingerprint', 'fp-current'); + (Keychain.getGenericPassword as jest.Mock).mockImplementation( + async ({ service }: { service: string }) => { + const value = storedSecrets.get(service); + return value ? { username: 'stored', password: value } : false; + }, + ); + + const machines = [ + { + id: 'current', + attributes: { + fingerprint: 'fp-current', + platform: 'ios', + name: 'My iPhone', + lastHeartbeat: '2026-07-26T00:00:00.000Z', + }, + }, + { + id: 'old', + attributes: { + fingerprint: 'fp-old', + platform: 'android', + name: 'Old Android', + lastHeartbeat: '2026-01-01T00:00:00.000Z', + }, + }, + ]; + global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/licenses/lic-1/machines')) { + return response(200, { data: machines }); + } + if (url.endsWith('/machines/old') && init?.method === 'DELETE') { + machines.splice(1, 1); + return response(204); + } + return response(404); + }) as typeof fetch; + }); + + afterEach(async () => { + global.fetch = originalFetch; + _clearScreensForTesting(); + _clearSectionsForTesting(); + }); + + it('opens Sync, shows active machines, and deactivates a previous device', async () => { + const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const ui = render( + + + , + ); + + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(await waitFor(() => ui.getByTestId('open-sync-settings'))); + + await waitFor(() => expect(ui.getByText('2 of 5 active')).toBeTruthy()); + expect(ui.getByText('My iPhone')).toBeTruthy(); + expect( + within(ui.getByTestId('licensed-device-current')).getByText('THIS DEVICE'), + ).toBeTruthy(); + expect(ui.getByText('Old Android')).toBeTruthy(); + + fireEvent.press(ui.getByTestId('deactivate-device-old')); + const destructiveAction = (alert.mock.calls[0][2] ?? []).find( + (button) => button.style === 'destructive', + ); + destructiveAction?.onPress?.(); + + await waitFor(() => expect(ui.getByText('1 of 5 active')).toBeTruthy()); + expect(ui.queryByText('Old Android')).toBeNull(); + expect(ui.getByText('My iPhone')).toBeTruthy(); + alert.mockRestore(); + ui.unmount(); + }); +}); diff --git a/__tests__/pro/sync/syncStore.test.ts b/__tests__/pro/sync/syncStore.test.ts new file mode 100644 index 000000000..8e1cb07ca --- /dev/null +++ b/__tests__/pro/sync/syncStore.test.ts @@ -0,0 +1,63 @@ +import { useSyncStore } from '../../../pro/sync/syncStore'; + +const discoveredDevice = (id: string, host = '1.2.3.4') => + ({ id, name: id, platform: 'macos', version: '1', host, port: 7 }) as any; +const pairedDevice = (id: string) => + ({ + id, + name: id, + platform: 'macos', + version: '1', + host: '', + port: 7, + sharedSecret: 's', + }) as any; + +beforeEach(() => useSyncStore.getState().reset()); + +describe('useSyncStore', () => { + it('replaces a discovered device when the same peer moves', () => { + const state = useSyncStore.getState(); + state.upsertDiscovered(discoveredDevice('a', '1.1.1.1')); + state.upsertDiscovered(discoveredDevice('a', '2.2.2.2')); + + const discovered = useSyncStore.getState().discovered; + expect(discovered).toHaveLength(1); + expect(discovered[0].host).toBe('2.2.2.2'); + }); + + it('moves a paired device out of the discovered list', () => { + const state = useSyncStore.getState(); + state.upsertDiscovered(discoveredDevice('a')); + state.upsertDiscovered(discoveredDevice('b')); + state.addPaired(pairedDevice('a')); + + expect(useSyncStore.getState().paired.map((device) => device.id)).toEqual(['a']); + expect(useSyncStore.getState().discovered.map((device) => device.id)).toEqual(['b']); + }); + + it('removes only the lost discovered device', () => { + const state = useSyncStore.getState(); + state.upsertDiscovered(discoveredDevice('a')); + state.upsertDiscovered(discoveredDevice('b')); + state.removeDiscovered('a'); + + expect(useSyncStore.getState().discovered.map((device) => device.id)).toEqual(['b']); + }); + + it('clears transient engine and pairing state on reset', () => { + const state = useSyncStore.getState(); + state.setStatus('running'); + state.setPairingDevice('a', 'failed'); + state.upsertDiscovered(discoveredDevice('a')); + state.addPaired(pairedDevice('b')); + state.reset(); + + const reset = useSyncStore.getState(); + expect(reset.status).toBe('idle'); + expect(reset.pairingDeviceId).toBeNull(); + expect(reset.pairingError).toBeUndefined(); + expect(reset.discovered).toEqual([]); + expect(reset.paired).toEqual([]); + }); +}); diff --git a/__tests__/rntl/screens/SyncScreen.test.tsx b/__tests__/rntl/screens/SyncScreen.test.tsx deleted file mode 100644 index 42087b0e9..000000000 --- a/__tests__/rntl/screens/SyncScreen.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * SyncScreen: the user-facing Sync surface. Verifies it starts the engine on mount, renders this - * device + discovered peers, gates pairing on a code, and dials the tapped device with that code. - * syncService is mocked (it imports native modules); the screen's own logic + store wiring is real. - */ -import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import { useSyncStore } from '../../../src/stores/syncStore'; -import { SyncScreen } from '../../../src/screens/SyncScreen'; - -const mockGoBack = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ goBack: mockGoBack, navigate: jest.fn() }), -})); -jest.mock('../../../src/theme', () => ({ - useTheme: () => ({ colors: { background: '#000', text: '#fff', textSecondary: '#aaa', textMuted: '#666', surfaceLight: '#222', border: '#333', primary: '#0a0' } }), - useThemedStyles: (fn: any) => fn({ background: '#000', text: '#fff', textSecondary: '#aaa', textMuted: '#666', surfaceLight: '#222', border: '#333', primary: '#0a0' }, {}), -})); -const mockStart = jest.fn(); -const mockStop = jest.fn(); -const mockPair = jest.fn().mockResolvedValue(undefined); -jest.mock('../../../src/services/sync/syncService', () => ({ - syncService: { start: (...a: any[]) => mockStart(...a), stop: (...a: any[]) => mockStop(...a), pair: (...a: any[]) => mockPair(...a) }, -})); - -const disc = (id: string) => ({ id, name: `Device ${id}`, platform: 'macos', version: '1', host: '10.0.0.9', port: 7 } as any); - -beforeEach(() => { - jest.clearAllMocks(); - useSyncStore.getState().reset(); - useSyncStore.getState().setPairingCode(''); -}); - -describe('SyncScreen', () => { - it('starts the engine on mount and stops it on unmount', () => { - const { unmount } = render(); - expect(mockStart).toHaveBeenCalledTimes(1); - unmount(); - expect(mockStop).toHaveBeenCalledTimes(1); - }); - - it('renders this device + the empty discovered state', () => { - useSyncStore.getState().setThisDevice({ id: 'me', name: 'My Phone', platform: 'ios', version: '1', host: '', port: 0 }); - useSyncStore.getState().setStatus('running'); - const { getByTestId } = render(); - expect(getByTestId('sync-this-device').props.children).toBe('My Phone'); - expect(getByTestId('sync-no-devices')).toBeTruthy(); - }); - - it('does NOT pair when no code is entered (button disabled)', () => { - useSyncStore.getState().upsertDiscovered(disc('a')); - const { getByTestId } = render(); - fireEvent.press(getByTestId('sync-pair-a')); - expect(mockPair).not.toHaveBeenCalled(); - }); - - it('dials the tapped device with the entered pairing code', async () => { - useSyncStore.getState().upsertDiscovered(disc('a')); - const { getByTestId } = render(); - fireEvent.changeText(getByTestId('sync-pairing-code'), 'blue-otter-42'); - fireEvent.press(getByTestId('sync-pair-a')); - await waitFor(() => expect(mockPair).toHaveBeenCalledTimes(1)); - expect(mockPair.mock.calls[0][0].id).toBe('a'); - expect(mockPair.mock.calls[0][1]).toBe('blue-otter-42'); - }); -}); diff --git a/__tests__/unit/stores/syncStore.test.ts b/__tests__/unit/stores/syncStore.test.ts deleted file mode 100644 index 85a785e92..000000000 --- a/__tests__/unit/stores/syncStore.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Sync UI store: the device-list state the SyncScreen renders. Pure zustand — asserts the state - * transitions the syncService drives (discovery dedup, paired-moves-out-of-discovered, reset). - */ -import { useSyncStore } from '../../../src/stores/syncStore'; - -const disc = (id: string, host = '1.2.3.4') => ({ id, name: id, platform: 'macos', version: '1', host, port: 7 } as any); -const paired = (id: string) => ({ id, name: id, platform: 'macos', version: '1', host: '', port: 7, sharedSecret: 's' } as any); - -beforeEach(() => useSyncStore.getState().reset()); - -describe('useSyncStore', () => { - it('upsertDiscovered de-dupes by id (a moved peer replaces, never duplicates)', () => { - const s = useSyncStore.getState(); - s.upsertDiscovered(disc('a', '1.1.1.1')); - s.upsertDiscovered(disc('a', '2.2.2.2')); // same id, new host - const d = useSyncStore.getState().discovered; - expect(d).toHaveLength(1); - expect(d[0].host).toBe('2.2.2.2'); - }); - - it('addPaired moves the device out of discovered and into paired', () => { - const s = useSyncStore.getState(); - s.upsertDiscovered(disc('a')); - s.upsertDiscovered(disc('b')); - s.addPaired(paired('a')); - const st = useSyncStore.getState(); - expect(st.paired.map((p) => p.id)).toEqual(['a']); - expect(st.discovered.map((d) => d.id)).toEqual(['b']); // 'a' left the discovered list - }); - - it('removeDiscovered drops only the matching device', () => { - const s = useSyncStore.getState(); - s.upsertDiscovered(disc('a')); - s.upsertDiscovered(disc('b')); - s.removeDiscovered('a'); - expect(useSyncStore.getState().discovered.map((d) => d.id)).toEqual(['b']); - }); - - it('reset clears status/error/lists (pairingCode + thisDevice are session state, untouched here)', () => { - const s = useSyncStore.getState(); - s.setStatus('running'); - s.upsertDiscovered(disc('a')); - s.addPaired(paired('b')); - s.reset(); - const st = useSyncStore.getState(); - expect(st.status).toBe('idle'); - expect(st.discovered).toEqual([]); - expect(st.paired).toEqual([]); - }); -}); diff --git a/pro b/pro index ff0d87423..a167dff4f 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit ff0d874234c23d3dd2a781b77baafe8102c3fad7 +Subproject commit a167dff4f419f15e2ca7efa2cc0c36c1913e1868 diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 4c4f995c4..fac8bb11c 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -36,7 +36,6 @@ import { SecuritySettingsScreen, GalleryScreen, RemoteServersScreen, - SyncScreen, ProDetailScreen, AboutScreen, ToolsScreen, @@ -235,7 +234,6 @@ export const AppNavigator: React.FC = () => { - diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 4dfcd902c..04c7b8dfa 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -15,7 +15,6 @@ export type RootStackParamList = { // Former SettingsStack ModelSettings: undefined; RemoteServers: undefined; - Sync: undefined; DeviceInfo: undefined; StorageSettings: undefined; SecuritySettings: undefined; diff --git a/src/screens/ProDetailScreen/ProManageSection.tsx b/src/screens/ProDetailScreen/ProManageSection.tsx index 40a0068e1..3829c22c6 100644 --- a/src/screens/ProDetailScreen/ProManageSection.tsx +++ b/src/screens/ProDetailScreen/ProManageSection.tsx @@ -2,32 +2,28 @@ * ProManageSection * * Shown on the Pro screen when Pro is active. Surfaces subscription status from - * the cached Keygen license (lifetime vs yearly + expiry) and the registered - * devices (N of 5). The device list is read-only on purpose: the 5-device cap is - * a hard limit and there is no self-service removal — letting users free slots - * would let a single key cycle through unlimited devices and defeat the cap. + * the cached Keygen license (lifetime vs yearly + expiry). Active licensed + * devices are managed from the Pro-owned Sync screen, so there is one list and + * one action owner rather than a second read-only copy here. * For a recurring (yearly) license it explains how to cancel or update payment: * via the link RevenueCat emails with every purchase and renewal. There is no * in-app portal because RevenueCat authenticates Web Billing customers by email. */ import React, { useCallback, useEffect, useState } from 'react'; -import { View, Text, ActivityIndicator } from 'react-native'; +import { View, Text, ActivityIndicator, TouchableOpacity } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; import Icon from 'react-native-vector-icons/Feather'; import { useTheme, useThemedStyles } from '../../theme'; +import { useHasRegisteredScreen } from '../../navigation/screenRegistry'; import type { ThemeColors, ThemeShadows } from '../../theme'; import { SPACING, TYPOGRAPHY } from '../../constants'; import { getProLicenseInfo, - listProDevices, PRO_TIER_META, type ProLicenseInfo, } from '../../services/proLicenseService'; -import { getDeviceFingerprint } from '../../services/deviceFingerprint'; -import type { KeygenMachine } from '../../services/keygenClient'; import logger from '../../utils/logger'; -const MAX_DEVICES = 5; - function formatDate(iso: string | null): string { if (!iso) return ''; try { @@ -38,19 +34,16 @@ function formatDate(iso: string | null): string { } export const ProManageSection: React.FC = () => { + const navigation = useNavigation(); const { colors } = useTheme(); const styles = useThemedStyles(createStyles); + const hasSyncScreen = useHasRegisteredScreen('Sync'); const [info, setInfo] = useState(null); - const [devices, setDevices] = useState([]); - const [thisFingerprint, setThisFingerprint] = useState(''); const [loading, setLoading] = useState(true); const refresh = useCallback(async () => { try { - const [licenseInfo, fingerprint] = await Promise.all([getProLicenseInfo(), getDeviceFingerprint()]); - setInfo(licenseInfo); - setThisFingerprint(fingerprint); - setDevices(await listProDevices()); + setInfo(await getProLicenseInfo()); } catch (e) { logger.error('[ProManage] load failed:', e instanceof Error ? e.message : String(e)); } finally { @@ -86,25 +79,21 @@ export const ProManageSection: React.FC = () => { {statusLine} - Devices ({devices.length} of {MAX_DEVICES}) - - A license works on up to {MAX_DEVICES} devices. This limit is fixed. - - {devices.map((machine) => { - const isThisDevice = machine.fingerprint === thisFingerprint; - return ( - - - - - {machine.name || machine.platform || 'Device'} - {isThisDevice ? ' · This device' : ''} - - {machine.lastSeen ? Added {formatDate(machine.lastSeen)} : null} - + {hasSyncScreen ? ( + navigation.navigate('Sync')} + accessibilityRole="button" + accessibilityLabel="Manage licensed devices in Sync" + > + + + Manage licensed devices + View or deactivate devices from Sync - ); - })} + + + ) : null} {tierMeta?.renews ? ( @@ -148,16 +137,16 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => letterSpacing: 0.3, marginTop: SPACING.sm, }, - capHint: { ...TYPOGRAPHY.meta, color: colors.textMuted }, - deviceRow: { + syncRow: { flexDirection: 'row' as const, alignItems: 'center' as const, gap: SPACING.md, - paddingVertical: SPACING.sm, + minHeight: 44, + marginTop: SPACING.sm, }, - deviceInfo: { flex: 1, gap: 2 as number }, - deviceName: { ...TYPOGRAPHY.bodySmall, color: colors.text }, - deviceMeta: { ...TYPOGRAPHY.meta, color: colors.textMuted }, + syncInfo: { flex: 1, gap: SPACING.xs }, + syncTitle: { ...TYPOGRAPHY.bodySmall, color: colors.text }, + syncHint: { ...TYPOGRAPHY.meta, color: colors.textMuted }, manageBlock: { marginTop: SPACING.sm, gap: SPACING.sm as number, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 2b30e25c6..b19447ce9 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -189,7 +189,6 @@ export const SettingsScreen: React.FC = () => { {[ { icon: 'sliders', title: 'Model Settings', desc: 'System prompt, generation, and performance', screen: 'ModelSettings' as const }, { icon: 'wifi', title: 'Remote Servers', desc: 'Connect to Off Grid AI Desktop, Ollama, LM Studio, and more', screen: 'RemoteServers' as const }, - { icon: 'refresh-cw', title: 'Sync', desc: 'Pair your devices and sync chats, projects, and models over your Wi-Fi', screen: 'Sync' as const }, // { icon: 'search', title: 'Web Search', desc: 'Configure search API key for reliable results', screen: 'WebSearchSettings' as const }, { icon: 'lock', title: 'Security', desc: 'Passphrase and app lock', screen: 'SecuritySettings' as const }, { icon: 'smartphone', title: 'Device Information', desc: 'Hardware and compatibility', screen: 'DeviceInfo' as const }, diff --git a/src/screens/SyncScreen/index.tsx b/src/screens/SyncScreen/index.tsx deleted file mode 100644 index 2269c3feb..000000000 --- a/src/screens/SyncScreen/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import Icon from 'react-native-vector-icons/Feather'; -import { useNavigation } from '@react-navigation/native'; -import { useTheme, useThemedStyles } from '../../theme'; -import { useSyncStore } from '../../stores/syncStore'; -import { syncService } from '../../services/sync/syncService'; -import { createStyles } from './styles'; -import type { DiscoveredDevice } from '@offgrid/sync'; - -export const SyncScreen: React.FC = () => { - const navigation = useNavigation(); - const theme = useTheme(); - const styles = useThemedStyles(createStyles); - const { status, error, thisDevice, discovered, paired, pairingCode, setPairingCode } = useSyncStore(); - const [pairingId, setPairingId] = useState(null); - - // Start the engine when the screen opens; stop when it closes. - useEffect(() => { - syncService.start(); - return () => { syncService.stop(); }; - }, []); - - const handlePair = useCallback(async (device: DiscoveredDevice) => { - if (!pairingCode.trim()) return; - setPairingId(device.id); - try { - await syncService.pair(device, pairingCode.trim()); - } finally { - setPairingId(null); - } - }, [pairingCode]); - - const statusLabel = - status === 'running' ? 'Discoverable on your Wi-Fi' - : status === 'starting' ? 'Starting…' - : status === 'error' ? `Error: ${error ?? 'unknown'}` - : 'Off'; - - return ( - - - navigation.goBack()} accessibilityLabel="Back"> - - - Sync - - - - {/* This device + status */} - - - This device - {status === 'starting' && } - {status === 'running' && } - - {thisDevice?.name ?? '—'} - {statusLabel} - - - {/* Shared pairing code */} - - Pairing code - Enter the SAME code on both devices, then tap a discovered device to pair. - - - - {/* Discovered devices */} - DISCOVERED - {discovered.length === 0 ? ( - No devices found yet. Open Sync on another device on the same Wi-Fi. - ) : ( - discovered.map((d) => ( - - - {d.name} - {d.platform} · {d.host} - - handlePair(d)} - testID={`sync-pair-${d.id}`} - > - {pairingId === d.id - ? - : Pair} - - - )) - )} - - {/* Paired devices */} - {paired.length > 0 && ( - <> - PAIRED - {paired.map((d) => ( - - - {d.name ?? d.id} - - ))} - - )} - - - ); -}; diff --git a/src/screens/SyncScreen/styles.ts b/src/screens/SyncScreen/styles.ts deleted file mode 100644 index 9d72a2e16..000000000 --- a/src/screens/SyncScreen/styles.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { SPACING, TYPOGRAPHY } from '../../constants'; -import type { ThemeColors, ThemeShadows } from '../../theme'; - -export function createStyles(colors: ThemeColors, _shadows: ThemeShadows) { - return { - container: { flex: 1, backgroundColor: colors.background }, - header: { - flexDirection: 'row' as const, alignItems: 'center' as const, - paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm, - borderBottomWidth: 1, borderBottomColor: colors.border, - }, - backButton: { padding: SPACING.xs, marginRight: SPACING.sm }, - title: { ...TYPOGRAPHY.h2, color: colors.text }, - scrollView: { flex: 1 }, - content: { padding: SPACING.md, gap: SPACING.md }, - card: { padding: SPACING.md, borderRadius: 12, backgroundColor: colors.surfaceLight, gap: 4 }, - rowBetween: { flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'space-between' as const }, - cardTitle: { ...TYPOGRAPHY.label, color: colors.textSecondary }, - deviceName: { ...TYPOGRAPHY.body, color: colors.text }, - statusText: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary }, - hint: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary, marginBottom: SPACING.xs }, - input: { - ...TYPOGRAPHY.body, color: colors.text, borderWidth: 1, borderColor: colors.border, - borderRadius: 8, paddingHorizontal: SPACING.sm, paddingVertical: SPACING.sm, backgroundColor: colors.background, - }, - sectionLabel: { ...TYPOGRAPHY.label, color: colors.textMuted, marginTop: SPACING.sm }, - empty: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted }, - deviceRow: { - flexDirection: 'row' as const, alignItems: 'center' as const, gap: SPACING.sm, - padding: SPACING.md, borderRadius: 12, backgroundColor: colors.surfaceLight, - }, - flex1: { flex: 1 }, - deviceRowName: { ...TYPOGRAPHY.body, color: colors.text }, - pairedName: { flex: 1 }, - deviceRowSub: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary }, - pairButton: { - paddingHorizontal: SPACING.md, paddingVertical: SPACING.sm, borderRadius: 8, - backgroundColor: colors.primary, minWidth: 64, alignItems: 'center' as const, - }, - pairButtonDisabled: { opacity: 0.4 }, - pairButtonText: { ...TYPOGRAPHY.body, color: colors.background }, - dotOn: { width: 10, height: 10, borderRadius: 5, backgroundColor: colors.primary }, - }; -} diff --git a/src/screens/index.ts b/src/screens/index.ts index 713043fb5..ea0d84ed2 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -20,7 +20,6 @@ export { DeviceInfoScreen } from './DeviceInfoScreen'; export { StorageSettingsScreen } from './StorageSettingsScreen'; export { SecuritySettingsScreen } from './SecuritySettingsScreen'; export { RemoteServersScreen } from './RemoteServersScreen'; -export { SyncScreen } from './SyncScreen'; export { ProDetailScreen } from './ProDetailScreen'; export { AboutScreen } from './AboutScreen'; export { ToolsScreen } from './ToolsScreen'; diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index 65181d6dd..a6da4f303 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -276,11 +276,7 @@ export async function activateProByKey(rawKey: string): Promise export async function listProDevices(): Promise { const lic = await readLicense(); if (!lic.key || !lic.licenseId) return []; - try { - return await listMachines(lic.key, lic.licenseId); - } catch { - return []; - } + return listMachines(lic.key, lic.licenseId); } /** Free a device slot. */ diff --git a/src/services/sync/syncService.ts b/src/services/sync/syncService.ts deleted file mode 100644 index e8de0e8fb..000000000 --- a/src/services/sync/syncService.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Singleton that owns the @offgrid/sync engine lifecycle for the app and reflects its events into -// useSyncStore for the Sync screen. Pairing uses a shared code (the passphrase) both devices enter; -// paired shared-secrets are held in-memory for now (persist in a later slice so reconnects are -// silent). Thin: all protocol/crypto is the package; wiring is the tested factories via nativeSync. -import { createNativeSync } from './nativeSync'; -import type { NativeSync } from './nativeSync'; -import { getOrCreateLocalDevice } from './localDevice'; -import { useSyncStore } from '../../stores/syncStore'; -import logger from '../../utils/logger'; -import type { DeviceInfo } from '@offgrid/sync'; - -let sync: NativeSync | null = null; -const sharedSecrets = new Map(); - -export const syncService = { - isRunning: (): boolean => sync !== null, - - async start(): Promise { - if (sync) return; - const store = useSyncStore.getState(); - store.setStatus('starting'); - try { - const local = await getOrCreateLocalDevice(); - store.setThisDevice(local); - const s = createNativeSync(local, { - // Inbound pairing: use the code the user entered on THIS device (both sides enter the same). - getPassphrase: () => useSyncStore.getState().pairingCode || undefined, - getSharedSecret: (id) => sharedSecrets.get(id), - onDiscovered: (d) => useSyncStore.getState().upsertDiscovered(d), - onLost: (id) => useSyncStore.getState().removeDiscovered(id), - onPaired: (d) => { - sharedSecrets.set(d.id, d.sharedSecret); - useSyncStore.getState().addPaired(d); - logger.log(`[SYNC] paired with ${d.name ?? d.id}`); - }, - onPairingFailed: (r, err) => logger.warn(`[SYNC] pairing failed with ${r?.id ?? 'unknown'}: ${err}`), - onAppMessage: (id, channel, data) => - logger.log(`[SYNC] app message from ${id} ch=${channel} ${JSON.stringify(data)}`), - }); - sync = s; - await s.start(); - store.setStatus('running'); - } catch (e) { - logger.warn(`[SYNC] start failed: ${String(e)}`); - store.setStatus('error', e instanceof Error ? e.message : String(e)); - } - }, - - async stop(): Promise { - if (!sync) return; - try { await sync.stop(); } finally { - sync = null; - useSyncStore.getState().reset(); - } - }, - - /** Dial a discovered device and run the pairing handshake with the shared code. */ - async pair(device: DeviceInfo, code: string): Promise { - if (!sync) throw new Error('Sync is not running'); - await sync.pair(device, code); - }, -}; diff --git a/src/stores/syncStore.ts b/src/stores/syncStore.ts deleted file mode 100644 index b1aa34c55..000000000 --- a/src/stores/syncStore.ts +++ /dev/null @@ -1,42 +0,0 @@ -// UI state for the Sync feature: the running status, this device, and the discovered/paired peers. -// The syncService owns the @offgrid/sync engine and pushes updates here; the SyncScreen renders it. -import { create } from 'zustand'; -import type { DeviceInfo, DiscoveredDevice, PairedDevice } from '@offgrid/sync'; - -export type SyncStatus = 'idle' | 'starting' | 'running' | 'error'; - -interface SyncState { - status: SyncStatus; - error?: string; - thisDevice?: DeviceInfo; - /** Shared pairing code both devices enter (the pairing passphrase). */ - pairingCode: string; - discovered: DiscoveredDevice[]; - paired: PairedDevice[]; - setPairingCode: (code: string) => void; - setStatus: (status: SyncStatus, error?: string) => void; - setThisDevice: (d: DeviceInfo) => void; - upsertDiscovered: (d: DiscoveredDevice) => void; - removeDiscovered: (id: string) => void; - addPaired: (d: PairedDevice) => void; - reset: () => void; -} - -export const useSyncStore = create((set) => ({ - status: 'idle', - pairingCode: '', - discovered: [], - paired: [], - setPairingCode: (pairingCode) => set({ pairingCode }), - setStatus: (status, error) => set({ status, error }), - setThisDevice: (thisDevice) => set({ thisDevice }), - upsertDiscovered: (d) => - set((s) => ({ discovered: [...s.discovered.filter((x) => x.id !== d.id), d] })), - removeDiscovered: (id) => set((s) => ({ discovered: s.discovered.filter((x) => x.id !== id) })), - addPaired: (d) => - set((s) => ({ - paired: [...s.paired.filter((x) => x.id !== d.id), d], - discovered: s.discovered.filter((x) => x.id !== d.id), // paired → leaves the "discovered" list - })), - reset: () => set({ status: 'idle', error: undefined, discovered: [], paired: [] }), -})); From 16020b87ef3cc29b7eab38c5939fdcb77aaeb0d7 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 18:36:57 +0530 Subject: [PATCH 011/332] chore(sync): keep workspace dependency gates green --- .dependency-cruiser.js | 4 ++++ docs/SYNC_MOBILE_PROGRESS.md | 21 ++++++++++++++++++--- knip.json | 4 ++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.dependency-cruiser.js b/.dependency-cruiser.js index e0b1daa08..e83ac15a8 100644 --- a/.dependency-cruiser.js +++ b/.dependency-cruiser.js @@ -106,6 +106,10 @@ module.exports = { }, ], options: { + // Keep file-linked workspace packages under node_modules so dependency-cruiser can + // classify them from this package.json instead of treating their real paths as + // undeclared source files outside the mobile project. + preserveSymlinks: true, doNotFollow: { path: 'node_modules' }, tsPreCompilationDeps: true, tsConfig: { fileName: 'tsconfig.json' }, diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 4ba0563c9..47e15d34c 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -35,9 +35,24 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - [x] `createNativeSync` binding (injects react-native-tcp-socket + react-native-zeroconf). - [x] Native config: iOS `NSBonjourServices += _offgrid._tcp`; Android `CHANGE_WIFI_MULTICAST_STATE`. - [x] iOS `pod install` autolinked tcp-socket 6.4.1 + zeroconf 0.14.0 (+ CocoaAsyncSocket). -- [ ] Native rebuild both platforms. -- [ ] Minimal on-device trigger + **verify discovery + encrypted handshake** (phone↔phone first, - then phone↔desktop). +- [x] Native rebuild: **iOS built + installed + launched** (autolinked tcp-socket + zeroconf). + Android APK **built OK** but install failed (`No connected devices!` — phone dropped off adb + mid-build); needs reconnect + `installDebug` (no recompile). +- [x] **iOS transport LIVE on device:** `[SYNC] started ... port=51770 platform=ios`; the Mac's + `dns-sd -B _offgrid._tcp` sees `OffGrid-` → native Bonjour publish + service type confirmed + on the real LAN. +- [ ] Two-device handshake (discover → NaCl pair → app message): pending Android reinstall (peer). + +### Phase 0.5 — Pro experience + licensed devices — COMPLETE +- [x] Sync UI and lifecycle orchestration live in the private Pro package, registered through the + existing core screen/settings registries. Core owns only reusable native transport glue. +- [x] Settings → Sync exposes discoverability, pairing, peer state, and one licensed-device surface. +- [x] Keygen device management lists active machines, marks this device, and allows another machine + to be deactivated after confirmation. +- [x] Activating a sixth device automatically deactivates the least-recently-seen existing machine, + then retries activation. Revalidation follows the same replacement path. +- [x] Boundary tests cover the real Keygen HTTP sequence (five active → delete oldest → activate + current), and a rendered AppNavigator journey covers Settings → Sync → device deactivation. ### Phase 1 — State sync (chats/projects/settings) — NOT STARTED (needs mobile UUID migration first) ### Phase 2 — Model transfer — NOT STARTED diff --git a/knip.json b/knip.json index 250a5ea73..038a33532 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,10 @@ "ignoreBinaries": ["swiftlint", "maestro", "xcpretty"], "ignoreDependencies": [ "@offgrid/pro", + "buffer", + "js-sha512", + "tweetnacl", + "tweetnacl-util", "react-compiler-runtime", "eslint-plugin-react-compiler", "eslint-plugin-react-native", From d79b0b8cc9418de5818dd3b5338d8d88d5bde0d4 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 19:42:00 +0530 Subject: [PATCH 012/332] test(sync): prove mobile pairing survives relaunch --- .../sync/syncPersistence.integration.test.ts | 133 ++++++++++++++++++ __tests__/utils/nativeSyncBoundaries.ts | 133 ++++++++++++++++++ pro | 2 +- 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 __tests__/pro/sync/syncPersistence.integration.test.ts create mode 100644 __tests__/utils/nativeSyncBoundaries.ts diff --git a/__tests__/pro/sync/syncPersistence.integration.test.ts b/__tests__/pro/sync/syncPersistence.integration.test.ts new file mode 100644 index 000000000..00852b33a --- /dev/null +++ b/__tests__/pro/sync/syncPersistence.integration.test.ts @@ -0,0 +1,133 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; +import TcpSocket from 'react-native-tcp-socket'; +import type { DeviceInfo } from '@offgrid/sync'; +import type { RnTcpModule } from '@offgrid/sync/rn'; +import { buildSyncEngine } from '../../../src/services/sync/engine'; +import { syncService } from '../../../pro/sync/syncService'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { + getDiscoveryBoundaries, + resetDiscoveryBoundaries, +} from '../../utils/nativeSyncBoundaries'; + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +const nativeTcpBoundary = TcpSocket as unknown as RnTcpModule; + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +const waitFor = async ( + condition: () => boolean, + timeoutMs = 3000, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() >= deadline) + throw new Error('Timed out waiting for Sync state'); + await new Promise(resolve => setTimeout(resolve, 10)); + } +}; + +describe('Pro Sync app-lifetime pairing persistence', () => { + let persistedPairings: string | undefined; + + beforeEach(async () => { + await syncService.stop(); + await AsyncStorage.clear(); + useSyncStore.getState().reset(); + resetDiscoveryBoundaries(); + persistedPairings = undefined; + (Keychain.getGenericPassword as jest.Mock).mockImplementation( + async ({ service }: { service: string }) => + service === 'off-grid-sync-pairings' && persistedPairings + ? { username: 'sync-pairings', password: persistedPairings } + : false, + ); + (Keychain.setGenericPassword as jest.Mock).mockImplementation( + async ( + _username: string, + password: string, + options: { service: string }, + ) => { + if (options.service === 'off-grid-sync-pairings') + persistedPairings = password; + return true; + }, + ); + }); + + afterEach(async () => { + await syncService.stop(); + }); + + it('silently reconnects a paired device after the mobile Sync service restarts', async () => { + let remoteSecret: string | undefined; + const remoteDevice: DeviceInfo = { + id: 'desktop-peer', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + const remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + getSharedSecret: deviceId => + deviceId === useSyncStore.getState().thisDevice?.id + ? remoteSecret + : undefined, + onPaired: device => { + remoteSecret = device.sharedSecret; + }, + }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + + await syncService.start(); + const mobile = useSyncStore.getState().thisDevice; + const firstDiscovery = getDiscoveryBoundaries().at(-1); + expect(mobile).toBeDefined(); + expect(firstDiscovery?.publishedPort).toBeGreaterThan(0); + useSyncStore.getState().setPairingCode('blue-otter-42'); + + await remote.engine.pair( + { ...mobile!, host: '127.0.0.1', port: firstDiscovery!.publishedPort! }, + 'blue-otter-42', + ); + await waitFor(() => + useSyncStore + .getState() + .paired.some(device => device.id === remoteDevice.id), + ); + await waitFor(() => Boolean(persistedPairings)); + + await syncService.stop(); + expect(useSyncStore.getState().status).toBe('idle'); + + await syncService.start(); + const discovery = getDiscoveryBoundaries().at(-1); + expect(discovery).toBeDefined(); + discovery!.resolve(remoteDevice); + + await waitFor(() => + useSyncStore + .getState() + .paired.some(device => device.id === remoteDevice.id), + ); + expect(useSyncStore.getState().discovered).toHaveLength(0); + + await remote.engine.stop(); + }); +}); diff --git a/__tests__/utils/nativeSyncBoundaries.ts b/__tests__/utils/nativeSyncBoundaries.ts new file mode 100644 index 000000000..ce282bf7b --- /dev/null +++ b/__tests__/utils/nativeSyncBoundaries.ts @@ -0,0 +1,133 @@ +import { Buffer } from 'buffer'; +import { createTxtRecord, type DeviceInfo } from '@offgrid/sync'; +import type { RnTcpModule } from '@offgrid/sync/rn'; + +type Handler = (...args: unknown[]) => void; + +class NativeSocketBoundary { + peer?: NativeSocketBoundary; + remoteAddress = '127.0.0.1'; + private closed = false; + private readonly handlers = new Map(); + + on(event: string, callback: Handler): this { + const callbacks = this.handlers.get(event) ?? []; + callbacks.push(callback); + this.handlers.set(event, callbacks); + return this; + } + + write(data: unknown): boolean { + const encoded = Buffer.from(data as Uint8Array).toString('base64'); + setImmediate(() => this.peer?.emit('data', encoded)); + return true; + } + + destroy(): void { + if (this.closed) return; + this.closed = true; + const peer = this.peer; + setImmediate(() => this.emit('close')); + if (peer && !peer.closed) { + peer.closed = true; + setImmediate(() => peer.emit('close')); + } + } + + private emit(event: string, ...args: unknown[]): void { + for (const handler of this.handlers.get(event) ?? []) handler(...args); + } +} + +export function createNativeTcpBoundary(): RnTcpModule { + const servers = new Map void>(); + let nextPort = 43000; + + return { + createServer(onConnection) { + let port = 0; + const server = { + on: () => server, + listen(options: { port: number }, callback?: () => void) { + port = options.port || nextPort++; + servers.set(port, onConnection); + callback?.(); + }, + address: () => ({ port }), + close: () => { + servers.delete(port); + }, + }; + return server; + }, + createConnection(options, callback) { + const onConnection = servers.get(options.port); + if (!onConnection) + throw new Error(`No native server on port ${options.port}`); + + const client = new NativeSocketBoundary(); + const server = new NativeSocketBoundary(); + client.peer = server; + server.peer = client; + setImmediate(() => { + onConnection(server); + callback?.(); + }); + return client; + }, + }; +} + +export interface DiscoveryBoundary { + publishedPort?: number; + resolve(device: DeviceInfo): void; +} + +let boundaries: DiscoveryBoundary[] = []; + +export function createNativeDiscoveryBoundary(): new () => DiscoveryBoundary { + return class NativeDiscoveryBoundary implements DiscoveryBoundary { + publishedPort?: number; + private readonly handlers = new Map(); + + constructor() { + boundaries.push(this); + } + + on(event: string, callback: Handler): void { + this.handlers.set(event, callback); + } + + scan(): void {} + stop(): void {} + removeDeviceListeners(): void {} + publishService( + _type: string, + _protocol: string, + _domain: string, + _name: string, + port: number, + ): void { + this.publishedPort = port; + } + unpublishService(): void {} + + resolve(device: DeviceInfo): void { + this.handlers.get('resolved')?.({ + txt: createTxtRecord(device), + addresses: [device.host], + host: device.host, + port: device.port, + name: `OffGrid-${device.id}`, + }); + } + }; +} + +export function getDiscoveryBoundaries(): DiscoveryBoundary[] { + return boundaries; +} + +export function resetDiscoveryBoundaries(): void { + boundaries = []; +} diff --git a/pro b/pro index a167dff4f..288dfa8fb 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit a167dff4f419f15e2ca7efa2cc0c36c1913e1868 +Subproject commit 288dfa8fb7b7d8a104a0d99344c92484de1f2dd4 From 0d5955f5bbdd687963c68b19451b7659af27930c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 20:12:34 +0530 Subject: [PATCH 013/332] feat(sync): admit transferred mobile models --- __tests__/harness/nativeBoundary.ts | 28 ++- .../sync/modelTransfer.integration.test.ts | 200 ++++++++++++++++++ pro | 2 +- src/services/modelManager/index.ts | 12 ++ .../modelManager/transferAdmission.ts | 50 +++++ src/services/sync/engine.ts | 2 + src/services/sync/nativeSync.ts | 4 + 7 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 __tests__/pro/sync/modelTransfer.integration.test.ts create mode 100644 src/services/modelManager/transferAdmission.ts diff --git a/__tests__/harness/nativeBoundary.ts b/__tests__/harness/nativeBoundary.ts index d13b58c7a..33348942c 100644 --- a/__tests__/harness/nativeBoundary.ts +++ b/__tests__/harness/nativeBoundary.ts @@ -670,7 +670,9 @@ function makeFsFake(): FsFake { const seedFile = (path: string, sizeBytes: number) => { const p = norm(path); vol.mkdirSync(p.slice(0, p.lastIndexOf('/')) || '/', { recursive: true }); - vol.writeFileSync(p, Buffer.alloc(sizeBytes)); + const contents = Buffer.alloc(sizeBytes); + if (/\.gguf$/i.test(p) && contents.length >= 4) contents.write('GGUF', 0, 'ascii'); + vol.writeFileSync(p, contents); }; const seedDir = (path: string) => vol.mkdirSync(norm(path), { recursive: true }); @@ -687,17 +689,33 @@ function makeFsFake(): FsFake { }); }), stat: jest.fn(async (p: string) => mkStat(p, vol.statSync(norm(p)) as never)), - writeFile: jest.fn(async (p: string, contents: string) => { + writeFile: jest.fn(async (p: string, contents: string, encoding?: string) => { const np = norm(p); vol.mkdirSync(np.slice(0, np.lastIndexOf('/')) || '/', { recursive: true }); - vol.writeFileSync(np, String(contents ?? '')); + vol.writeFileSync(np, Buffer.from(String(contents ?? ''), encoding === 'base64' ? 'base64' : 'utf8')); }), readFile: jest.fn(async (p: string) => vol.readFileSync(norm(p), 'utf8')), - read: jest.fn(async () => 'GGUF'), + read: jest.fn(async (p: string, length?: number, position = 0, encoding?: string) => { + const contents = vol.readFileSync(norm(p)) as Buffer; + const selected = contents.subarray(position, length == null ? undefined : position + length); + return selected.toString(encoding === 'base64' ? 'base64' : encoding === 'ascii' ? 'ascii' : 'utf8'); + }), + write: jest.fn(async (p: string, contents: string, position = 0, encoding?: string) => { + const np = norm(p); + const incoming = Buffer.from(contents, encoding === 'base64' ? 'base64' : 'utf8'); + const current = vol.existsSync(np) ? (vol.readFileSync(np) as Buffer) : Buffer.alloc(0); + const next = Buffer.alloc(Math.max(current.length, position + incoming.length)); + current.copy(next); + incoming.copy(next, position); + vol.writeFileSync(np, next); + }), unlink: jest.fn(async (p: string) => { vol.rmSync(norm(p), { recursive: true, force: true }); }), moveFile: jest.fn(async (from: string, to: string) => { vol.renameSync(norm(from), norm(to)); }), copyFile: jest.fn(async (from: string, to: string) => { vol.copyFileSync(norm(from), norm(to)); }), - hash: jest.fn(async () => 'deadbeef'), + hash: jest.fn(async (p: string, algorithm: string) => { + const { createHash } = require('node:crypto'); + return createHash(algorithm).update(vol.readFileSync(norm(p))).digest('hex'); + }), downloadFile: jest.fn(() => ({ jobId: 1, promise: Promise.resolve({ statusCode: 200, bytesWritten: 0 }) })), stopDownload: jest.fn(), }; diff --git a/__tests__/pro/sync/modelTransfer.integration.test.ts b/__tests__/pro/sync/modelTransfer.integration.test.ts new file mode 100644 index 000000000..09623463c --- /dev/null +++ b/__tests__/pro/sync/modelTransfer.integration.test.ts @@ -0,0 +1,200 @@ +import { installNativeBoundary } from '../../harness/nativeBoundary'; + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +const waitFor = async ( + condition: () => boolean, + timeoutMs = 3000, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() >= deadline) + throw new Error('Timed out waiting for Sync state'); + await new Promise(resolve => setTimeout(resolve, 10)); + } +}; + +describe('Pro mobile model transfer journey', () => { + it('receives an encrypted GGUF and exposes it through the real downloaded-model registry', async () => { + const boundary = installNativeBoundary({ fs: true }); + const AsyncStorage = require('@react-native-async-storage/async-storage'); + const Keychain = require('react-native-keychain'); + const TcpSocket = require('react-native-tcp-socket').default; + const { + FileTransferManager, + IncrementalChecksum, + MODEL_TRANSFER_MIME, + } = require('@offgrid/sync'); + const { buildSyncEngine } = require('../../../src/services/sync/engine'); + const { syncService } = require('../../../pro/sync/syncService'); + const { + modelTransferService, + } = require('../../../pro/sync/modelTransferService'); + const { useSyncStore } = require('../../../pro/sync/syncStore'); + const { modelManager } = require('../../../src/services/modelManager'); + const { + getDiscoveryBoundaries, + resetDiscoveryBoundaries, + } = require('../../utils/nativeSyncBoundaries'); + + await AsyncStorage.clear(); + resetDiscoveryBoundaries(); + Keychain.getGenericPassword.mockResolvedValue(false); + Keychain.setGenericPassword.mockResolvedValue(true); + + let remoteTransfers: InstanceType | undefined; + const remoteDevice = { + id: 'desktop-model-source', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + const remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: TcpSocket, + onMessage: (deviceId: string, message: unknown) => { + remoteTransfers?.handleMessage(deviceId, message); + }, + }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + + modelTransferService.start(); + await syncService.start(); + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + expect(mobile).toBeDefined(); + expect(discovery?.publishedPort).toBeGreaterThan(0); + useSyncStore.getState().setPairingCode('green-river-52'); + + await remote.engine.pair( + { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, + 'green-river-52', + ); + await waitFor(() => + useSyncStore + .getState() + .paired.some((device: { id: string }) => device.id === remoteDevice.id), + ); + + const payload = Buffer.alloc(96 * 1024 + 4, 0x5a); + payload.write('GGUF', 0, 'ascii'); + const checksum = new IncrementalChecksum(); + checksum.update(payload); + const fileName = 'gemma-mobile-Q4_K_M.gguf'; + remoteTransfers = new FileTransferManager({ + send: (deviceId: string, message: unknown) => + remote.engine.send(deviceId, message), + createSink: async () => null, + }); + + await remoteTransfers.sendFile(mobile.id, { + fileName, + fileSize: payload.length, + mimeType: MODEL_TRANSFER_MIME, + metadata: { + type: 'offgrid-model', + version: 1, + manifest: { + id: 'google/gemma-mobile', + name: 'Gemma Mobile', + kind: 'text', + source: 'downloaded', + files: [{ name: fileName, sizeBytes: payload.length }], + }, + }, + checksum: async () => checksum.digest(), + read: async (offset: number, length: number) => + new Uint8Array(payload.subarray(offset, offset + length)), + }); + + const models = await modelManager.getDownloadedModels(); + expect(models).toEqual([ + expect.objectContaining({ + id: `google/gemma-mobile/${fileName}`, + name: 'Gemma Mobile', + author: 'google', + engine: 'llama', + fileName, + fileSize: payload.length, + }), + ]); + const readFile = boundary.fs!.module.read as ( + path: string, + length: number, + position: number, + encoding: string, + ) => Promise; + expect( + await readFile( + `${boundary.fs!.DocumentDirectoryPath}/models/${fileName}`, + 4, + 0, + 'ascii', + ), + ).toBe('GGUF'); + + const invalidPayload = Buffer.alloc(4096, 0x58); + const invalidChecksum = new IncrementalChecksum(); + invalidChecksum.update(invalidPayload); + const invalidFileName = 'not-really-a-model.gguf'; + await expect( + remoteTransfers.sendFile(mobile.id, { + fileName: invalidFileName, + fileSize: invalidPayload.length, + mimeType: MODEL_TRANSFER_MIME, + metadata: { + type: 'offgrid-model', + version: 1, + manifest: { + id: 'offgrid/invalid-model', + name: 'Invalid model', + kind: 'text', + source: 'downloaded', + files: [ + { name: invalidFileName, sizeBytes: invalidPayload.length }, + ], + }, + }, + checksum: async () => invalidChecksum.digest(), + read: async (offset: number, length: number) => + new Uint8Array( + invalidPayload.subarray(offset, offset + length), + ), + }), + ).rejects.toThrow('receiver could not verify or register the file'); + expect(await modelManager.getDownloadedModels()).toHaveLength(1); + const exists = boundary.fs!.module.exists as ( + path: string, + ) => Promise; + expect( + await exists( + `${boundary.fs!.DocumentDirectoryPath}/models/${invalidFileName}`, + ), + ).toBe(false); + expect( + await exists( + `${boundary.fs!.DocumentDirectoryPath}/models/${invalidFileName}.part`, + ), + ).toBe(false); + + await remoteTransfers.dispose(); + await remote.engine.stop(); + await syncService.stop(); + await modelTransferService.stop(); + }); +}); diff --git a/pro b/pro index 288dfa8fb..1b901b0d9 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 288dfa8fb7b7d8a104a0d99344c92484de1f2dd4 +Subproject commit 1b901b0d9ecb5f19a6d4f46b1d2b73490dad545e diff --git a/src/services/modelManager/index.ts b/src/services/modelManager/index.ts index 7ec9b6ba9..7001b7298 100644 --- a/src/services/modelManager/index.ts +++ b/src/services/modelManager/index.ts @@ -18,6 +18,8 @@ import { loadDownloadedModels, loadDownloadedImageModels, } from './storage'; +import type { TransferredModelManifest } from '@offgrid/sync'; +import { registerTransferredModelFile } from './transferAdmission'; import { performBackgroundDownload, watchBackgroundDownload, @@ -379,6 +381,16 @@ class ModelManager { return scanImportLocalModel({ ...opts, modelsDir: this.modelsDir }); } + getModelsDirectory(): string { + return this.modelsDir; + } + + async registerTransferredModel(manifest: TransferredModelManifest): Promise { + const model = await registerTransferredModelFile(manifest, this.modelsDir); + useAppStore.getState().setDownloadedModels(await this.getDownloadedModels()); + return model; + } + async getDownloadedImageModels(): Promise { try { return await loadDownloadedImageModels(this.imageModelsDir); diff --git a/src/services/modelManager/transferAdmission.ts b/src/services/modelManager/transferAdmission.ts new file mode 100644 index 000000000..6942b628b --- /dev/null +++ b/src/services/modelManager/transferAdmission.ts @@ -0,0 +1,50 @@ +import RNFS from 'react-native-fs'; +import type { TransferredModelManifest } from '@offgrid/sync'; +import type { DownloadedModel, ModelFile } from '../../types'; +import { + buildDownloadedModel, + determineCredibility, + persistDownloadedModel, +} from './storage'; + +export async function registerTransferredModelFile( + manifest: TransferredModelManifest, + modelsDir: string, +): Promise { + const file = manifest.files[0]; + if (!file || file.name.includes('/') || file.name.includes('\\') || !/\.gguf$/i.test(file.name)) { + throw new Error('Transferred model manifest is invalid'); + } + + const filePath = `${modelsDir}/${file.name}`; + const stat = await RNFS.stat(filePath); + const actualSize = typeof stat.size === 'string' ? Number.parseInt(stat.size, 10) : stat.size; + if (!stat.isFile() || actualSize !== file.sizeBytes) { + throw new Error('Transferred model file does not match its manifest'); + } + + const quantization = file.name.match(/[_-](Q\d+[_\w]*|f16|f32)/i)?.[1]?.toUpperCase() ?? 'Unknown'; + const pseudoFile: ModelFile = { + name: file.name, + size: file.sizeBytes, + quantization, + downloadUrl: '', + }; + const base = await buildDownloadedModel({ + modelId: manifest.id, + file: pseudoFile, + resolvedLocalPath: filePath, + }); + const author = manifest.source === 'local' ? 'Local Import' : (manifest.id.split('/')[0] || 'Unknown'); + const model: DownloadedModel = { + ...base, + id: `${manifest.id}/${file.name}`, + name: manifest.name, + author, + credibility: determineCredibility(author), + engine: 'llama', + }; + + await persistDownloadedModel(model, modelsDir); + return model; +} diff --git a/src/services/sync/engine.ts b/src/services/sync/engine.ts index 98ee6022a..e1457487e 100644 --- a/src/services/sync/engine.ts +++ b/src/services/sync/engine.ts @@ -16,6 +16,7 @@ export interface BuildSyncEngineArgs { getSharedSecret?: SyncEngineOptions['getSharedSecret']; onPaired?: SyncEngineOptions['onPaired']; onPairingFailed?: SyncEngineOptions['onPairingFailed']; + onDisconnected?: SyncEngineOptions['onDisconnected']; onMessage?: SyncEngineOptions['onMessage']; onAppMessage?: SyncEngineOptions['onAppMessage']; cap?: SyncEngineOptions['cap']; @@ -32,6 +33,7 @@ export function buildSyncEngine(args: BuildSyncEngineArgs): { engine: SyncEngine getSharedSecret: args.getSharedSecret, onPaired: args.onPaired, onPairingFailed: args.onPairingFailed, + onDisconnected: args.onDisconnected, onMessage: args.onMessage, onAppMessage: args.onAppMessage, cap: args.cap, diff --git a/src/services/sync/nativeSync.ts b/src/services/sync/nativeSync.ts index 562f5b110..dd1ce8732 100644 --- a/src/services/sync/nativeSync.ts +++ b/src/services/sync/nativeSync.ts @@ -20,6 +20,7 @@ export interface NativeSyncCallbacks { getSharedSecret?: (deviceId: string) => string | undefined; onPaired?: (device: PairedDevice) => void; onPairingFailed?: (remote: DeviceInfo | undefined, error: string) => void; + onDisconnected?: (deviceId: string) => void; onDiscovered?: (device: DiscoveredDevice) => void; onLost?: (deviceId: string) => void; onAppMessage?: (deviceId: string, channel: string, data: unknown) => void; @@ -31,6 +32,7 @@ export interface NativeSync { start(): Promise; stop(): Promise; pair(device: DeviceInfo, passphrase: string): Promise; + send(deviceId: string, message: Message): boolean; sendApp(deviceId: string, channel: string, data: unknown): boolean; isPaired(deviceId: string): boolean; } @@ -44,6 +46,7 @@ export function createNativeSync(localDevice: DeviceInfo, cbs: NativeSyncCallbac getSharedSecret: cbs.getSharedSecret, onPaired: cbs.onPaired, onPairingFailed: cbs.onPairingFailed, + onDisconnected: cbs.onDisconnected, onMessage: cbs.onMessage, onAppMessage: cbs.onAppMessage, }); @@ -71,6 +74,7 @@ export function createNativeSync(localDevice: DeviceInfo, cbs: NativeSyncCallbac logger.log('[SYNC] stopped'); }, pair: (device, passphrase) => engine.pair(device, passphrase), + send: (deviceId, message) => engine.send(deviceId, message), sendApp: (deviceId, channel, data) => engine.sendApp(deviceId, channel, data), isPaired: (deviceId) => engine.isPaired(deviceId), }; From 1c41e73a22735eecd6f82fc39646659eec351c5c Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 20:19:08 +0530 Subject: [PATCH 014/332] test(sync): isolate binary transfer filesystem --- __tests__/harness/nativeBoundary.ts | 28 +--- .../sync/modelTransfer.integration.test.ts | 40 ++--- __tests__/utils/modelTransferFsBoundary.ts | 137 ++++++++++++++++++ 3 files changed, 163 insertions(+), 42 deletions(-) create mode 100644 __tests__/utils/modelTransferFsBoundary.ts diff --git a/__tests__/harness/nativeBoundary.ts b/__tests__/harness/nativeBoundary.ts index 33348942c..d13b58c7a 100644 --- a/__tests__/harness/nativeBoundary.ts +++ b/__tests__/harness/nativeBoundary.ts @@ -670,9 +670,7 @@ function makeFsFake(): FsFake { const seedFile = (path: string, sizeBytes: number) => { const p = norm(path); vol.mkdirSync(p.slice(0, p.lastIndexOf('/')) || '/', { recursive: true }); - const contents = Buffer.alloc(sizeBytes); - if (/\.gguf$/i.test(p) && contents.length >= 4) contents.write('GGUF', 0, 'ascii'); - vol.writeFileSync(p, contents); + vol.writeFileSync(p, Buffer.alloc(sizeBytes)); }; const seedDir = (path: string) => vol.mkdirSync(norm(path), { recursive: true }); @@ -689,33 +687,17 @@ function makeFsFake(): FsFake { }); }), stat: jest.fn(async (p: string) => mkStat(p, vol.statSync(norm(p)) as never)), - writeFile: jest.fn(async (p: string, contents: string, encoding?: string) => { + writeFile: jest.fn(async (p: string, contents: string) => { const np = norm(p); vol.mkdirSync(np.slice(0, np.lastIndexOf('/')) || '/', { recursive: true }); - vol.writeFileSync(np, Buffer.from(String(contents ?? ''), encoding === 'base64' ? 'base64' : 'utf8')); + vol.writeFileSync(np, String(contents ?? '')); }), readFile: jest.fn(async (p: string) => vol.readFileSync(norm(p), 'utf8')), - read: jest.fn(async (p: string, length?: number, position = 0, encoding?: string) => { - const contents = vol.readFileSync(norm(p)) as Buffer; - const selected = contents.subarray(position, length == null ? undefined : position + length); - return selected.toString(encoding === 'base64' ? 'base64' : encoding === 'ascii' ? 'ascii' : 'utf8'); - }), - write: jest.fn(async (p: string, contents: string, position = 0, encoding?: string) => { - const np = norm(p); - const incoming = Buffer.from(contents, encoding === 'base64' ? 'base64' : 'utf8'); - const current = vol.existsSync(np) ? (vol.readFileSync(np) as Buffer) : Buffer.alloc(0); - const next = Buffer.alloc(Math.max(current.length, position + incoming.length)); - current.copy(next); - incoming.copy(next, position); - vol.writeFileSync(np, next); - }), + read: jest.fn(async () => 'GGUF'), unlink: jest.fn(async (p: string) => { vol.rmSync(norm(p), { recursive: true, force: true }); }), moveFile: jest.fn(async (from: string, to: string) => { vol.renameSync(norm(from), norm(to)); }), copyFile: jest.fn(async (from: string, to: string) => { vol.copyFileSync(norm(from), norm(to)); }), - hash: jest.fn(async (p: string, algorithm: string) => { - const { createHash } = require('node:crypto'); - return createHash(algorithm).update(vol.readFileSync(norm(p))).digest('hex'); - }), + hash: jest.fn(async () => 'deadbeef'), downloadFile: jest.fn(() => ({ jobId: 1, promise: Promise.resolve({ statusCode: 200, bytesWritten: 0 }) })), stopDownload: jest.fn(), }; diff --git a/__tests__/pro/sync/modelTransfer.integration.test.ts b/__tests__/pro/sync/modelTransfer.integration.test.ts index 09623463c..2b47f4cf2 100644 --- a/__tests__/pro/sync/modelTransfer.integration.test.ts +++ b/__tests__/pro/sync/modelTransfer.integration.test.ts @@ -1,5 +1,3 @@ -import { installNativeBoundary } from '../../harness/nativeBoundary'; - jest.mock('react-native-tcp-socket', () => { const { createNativeTcpBoundary, @@ -14,6 +12,17 @@ jest.mock('react-native-zeroconf', () => { return { __esModule: true, default: createNativeDiscoveryBoundary() }; }); +jest.mock('react-native-fs', () => { + const { + modelTransferFsBoundary, + } = require('../../utils/modelTransferFsBoundary'); + return { + __esModule: true, + default: modelTransferFsBoundary.module, + ...modelTransferFsBoundary.module, + }; +}); + const waitFor = async ( condition: () => boolean, timeoutMs = 3000, @@ -28,7 +37,10 @@ const waitFor = async ( describe('Pro mobile model transfer journey', () => { it('receives an encrypted GGUF and exposes it through the real downloaded-model registry', async () => { - const boundary = installNativeBoundary({ fs: true }); + const { + modelTransferFsBoundary: boundary, + } = require('../../utils/modelTransferFsBoundary'); + boundary.reset(); const AsyncStorage = require('@react-native-async-storage/async-storage'); const Keychain = require('react-native-keychain'); const TcpSocket = require('react-native-tcp-socket').default; @@ -133,18 +145,11 @@ describe('Pro mobile model transfer journey', () => { fileSize: payload.length, }), ]); - const readFile = boundary.fs!.module.read as ( - path: string, - length: number, - position: number, - encoding: string, - ) => Promise; expect( - await readFile( - `${boundary.fs!.DocumentDirectoryPath}/models/${fileName}`, + await boundary.readAscii( + `${boundary.DocumentDirectoryPath}/models/${fileName}`, 4, 0, - 'ascii', ), ).toBe('GGUF'); @@ -178,17 +183,14 @@ describe('Pro mobile model transfer journey', () => { }), ).rejects.toThrow('receiver could not verify or register the file'); expect(await modelManager.getDownloadedModels()).toHaveLength(1); - const exists = boundary.fs!.module.exists as ( - path: string, - ) => Promise; expect( - await exists( - `${boundary.fs!.DocumentDirectoryPath}/models/${invalidFileName}`, + await boundary.exists( + `${boundary.DocumentDirectoryPath}/models/${invalidFileName}`, ), ).toBe(false); expect( - await exists( - `${boundary.fs!.DocumentDirectoryPath}/models/${invalidFileName}.part`, + await boundary.exists( + `${boundary.DocumentDirectoryPath}/models/${invalidFileName}.part`, ), ).toBe(false); diff --git a/__tests__/utils/modelTransferFsBoundary.ts b/__tests__/utils/modelTransferFsBoundary.ts new file mode 100644 index 000000000..274d91164 --- /dev/null +++ b/__tests__/utils/modelTransferFsBoundary.ts @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto'; +import { Volume } from 'memfs'; + +const DocumentDirectoryPath = '/docs'; +let volume = Volume.fromJSON({}); + +function normalize(path: string): string { + return path.replace(/^file:\/\//, '').replace(/\/+$/, '') || '/'; +} + +function reset(): void { + volume = Volume.fromJSON({}); + volume.mkdirSync(DocumentDirectoryPath, { recursive: true }); +} + +function stat(path: string) { + const normalized = normalize(path); + const value = volume.statSync(normalized); + return { + path: normalized, + name: normalized.slice(normalized.lastIndexOf('/') + 1), + size: Number(value.size), + isFile: () => value.isFile(), + isDirectory: () => value.isDirectory(), + mtime: value.mtime, + }; +} + +reset(); + +const module = { + DocumentDirectoryPath, + CachesDirectoryPath: '/caches', + ExternalDirectoryPath: '/external', + MainBundlePath: '/bundle', + exists: jest.fn(async (path: string) => volume.existsSync(normalize(path))), + mkdir: jest.fn(async (path: string) => { + volume.mkdirSync(normalize(path), { recursive: true }); + }), + stat: jest.fn(async (path: string) => stat(path)), + readDir: jest.fn(async (path: string) => { + const directory = normalize(path); + return (volume.readdirSync(directory) as string[]).map(name => + stat(`${directory}/${name}`), + ); + }), + writeFile: jest.fn(async (path: string, contents: string, encoding?: string) => { + const normalized = normalize(path); + volume.mkdirSync( + normalized.slice(0, normalized.lastIndexOf('/')) || '/', + { recursive: true }, + ); + volume.writeFileSync( + normalized, + Buffer.from(contents, encoding === 'base64' ? 'base64' : 'utf8'), + ); + }), + write: jest.fn( + async ( + path: string, + contents: string, + position = 0, + encoding?: string, + ) => { + const normalized = normalize(path); + const incoming = Buffer.from( + contents, + encoding === 'base64' ? 'base64' : 'utf8', + ); + const current = volume.existsSync(normalized) + ? (volume.readFileSync(normalized) as Buffer) + : Buffer.alloc(0); + const next = Buffer.alloc( + Math.max(current.length, position + incoming.length), + ); + current.copy(next); + incoming.copy(next, position); + volume.writeFileSync(normalized, next); + }, + ), + read: jest.fn( + async ( + path: string, + length?: number, + position = 0, + encoding?: string, + ) => { + const contents = volume.readFileSync(normalize(path)) as Buffer; + const selected = contents.subarray( + position, + length == null ? undefined : position + length, + ); + return selected.toString( + encoding === 'base64' + ? 'base64' + : encoding === 'ascii' + ? 'ascii' + : 'utf8', + ); + }, + ), + readFile: jest.fn(async (path: string) => + volume.readFileSync(normalize(path), 'utf8'), + ), + unlink: jest.fn(async (path: string) => { + volume.rmSync(normalize(path), { recursive: true, force: true }); + }), + moveFile: jest.fn(async (from: string, to: string) => { + volume.renameSync(normalize(from), normalize(to)); + }), + copyFile: jest.fn(async (from: string, to: string) => { + volume.copyFileSync(normalize(from), normalize(to)); + }), + hash: jest.fn(async (path: string, algorithm: string) => + createHash(algorithm) + .update(volume.readFileSync(normalize(path))) + .digest('hex'), + ), + getFSInfo: jest.fn(async () => ({ + freeSpace: 100 * 1024 * 1024 * 1024, + totalSpace: 128 * 1024 * 1024 * 1024, + })), + downloadFile: jest.fn(() => ({ + jobId: 1, + promise: Promise.resolve({ statusCode: 200, bytesWritten: 0 }), + })), + stopDownload: jest.fn(), +}; + +export const modelTransferFsBoundary = { + module, + DocumentDirectoryPath, + reset, + readAscii: async (path: string, length: number, position = 0) => + module.read(path, length, position, 'ascii'), + exists: (path: string) => module.exists(path), +}; From 2f58fe2920ab2b2101f4cbd7d30a9f5b0801eec1 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 22:35:35 +0530 Subject: [PATCH 015/332] test(sync): prove mobile model transfer journey --- ....ts => modelTransfer.integration.test.tsx} | 89 +++++++++++++++++-- pro | 2 +- 2 files changed, 83 insertions(+), 8 deletions(-) rename __tests__/pro/sync/{modelTransfer.integration.test.ts => modelTransfer.integration.test.tsx} (69%) diff --git a/__tests__/pro/sync/modelTransfer.integration.test.ts b/__tests__/pro/sync/modelTransfer.integration.test.tsx similarity index 69% rename from __tests__/pro/sync/modelTransfer.integration.test.ts rename to __tests__/pro/sync/modelTransfer.integration.test.tsx index 2b47f4cf2..a75e85eea 100644 --- a/__tests__/pro/sync/modelTransfer.integration.test.ts +++ b/__tests__/pro/sync/modelTransfer.integration.test.tsx @@ -1,3 +1,15 @@ +import React from 'react'; +import { + fireEvent, + render, + waitFor as waitForRender, +} from '@testing-library/react-native'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ goBack: jest.fn() }), +})); + jest.mock('react-native-tcp-socket', () => { const { createNativeTcpBoundary, @@ -23,7 +35,7 @@ jest.mock('react-native-fs', () => { }; }); -const waitFor = async ( +const waitForCondition = async ( condition: () => boolean, timeoutMs = 3000, ): Promise => { @@ -36,7 +48,7 @@ const waitFor = async ( }; describe('Pro mobile model transfer journey', () => { - it('receives an encrypted GGUF and exposes it through the real downloaded-model registry', async () => { + it('receives, registers, and sends an encrypted GGUF through the rendered Sync journey', async () => { const { modelTransferFsBoundary: boundary, } = require('../../utils/modelTransferFsBoundary'); @@ -54,6 +66,7 @@ describe('Pro mobile model transfer journey', () => { const { modelTransferService, } = require('../../../pro/sync/modelTransferService'); + const { SyncScreen } = require('../../../pro/ui/SyncScreen'); const { useSyncStore } = require('../../../pro/sync/syncStore'); const { modelManager } = require('../../../src/services/modelManager'); const { @@ -97,7 +110,7 @@ describe('Pro mobile model transfer journey', () => { { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, 'green-river-52', ); - await waitFor(() => + await waitForCondition(() => useSyncStore .getState() .paired.some((device: { id: string }) => device.id === remoteDevice.id), @@ -108,10 +121,40 @@ describe('Pro mobile model transfer journey', () => { const checksum = new IncrementalChecksum(); checksum.update(payload); const fileName = 'gemma-mobile-Q4_K_M.gguf'; + let returnedModel: Buffer | undefined; + let returnedFileName: string | undefined; remoteTransfers = new FileTransferManager({ send: (deviceId: string, message: unknown) => remote.engine.send(deviceId, message), - createSink: async () => null, + createSink: async ( + _deviceId: string, + request: { + payload: { + fileName: string; + fileSize: number; + checksum: string; + }; + }, + ) => { + const received = Buffer.alloc(request.payload.fileSize); + return { + prepare: async () => 0, + write: async (offset: number, data: Uint8Array) => { + Buffer.from(data).copy(received, offset); + }, + finalize: async () => { + const receivedChecksum = new IncrementalChecksum(); + receivedChecksum.update(received); + if (receivedChecksum.digest() !== request.payload.checksum) { + return false; + } + returnedModel = received; + returnedFileName = request.payload.fileName; + return true; + }, + abort: async () => undefined, + }; + }, }); await remoteTransfers.sendFile(mobile.id, { @@ -177,9 +220,7 @@ describe('Pro mobile model transfer journey', () => { }, checksum: async () => invalidChecksum.digest(), read: async (offset: number, length: number) => - new Uint8Array( - invalidPayload.subarray(offset, offset + length), - ), + new Uint8Array(invalidPayload.subarray(offset, offset + length)), }), ).rejects.toThrow('receiver could not verify or register the file'); expect(await modelManager.getDownloadedModels()).toHaveLength(1); @@ -194,6 +235,40 @@ describe('Pro mobile model transfer journey', () => { ), ).toBe(false); + const ui = render(); + fireEvent.press(ui.getByTestId(`sync-send-model-${remoteDevice.id}`)); + await waitForRender(() => + expect( + ui.getByTestId(`transfer-model-google/gemma-mobile/${fileName}`), + ).toBeTruthy(), + ); + fireEvent.press(ui.getByTestId('send-selected-model')); + + await waitForRender( + () => + expect( + ui.getByText(`Gemma Mobile is available on ${remoteDevice.name}.`), + ).toBeTruthy(), + { timeout: 5000 }, + ); + expect(returnedFileName).toBe(fileName); + expect(returnedModel).toEqual(payload); + expect(ui.getAllByText(`Sent ${fileName}`).length).toBeGreaterThanOrEqual( + 1, + ); + ui.unmount(); + + const transferredModelId = `google/gemma-mobile/${fileName}`; + for (let index = 0; index < 21; index += 1) { + await expect( + modelTransferService.sendModel( + `offline-device-${index}`, + transferredModelId, + ), + ).rejects.toThrow('device is not connected'); + } + expect(modelTransferService.getProgressSnapshot()).toHaveLength(20); + await remoteTransfers.dispose(); await remote.engine.stop(); await syncService.stop(); diff --git a/pro b/pro index 1b901b0d9..70cebc81a 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 1b901b0d9ecb5f19a6d4f46b1d2b73490dad545e +Subproject commit 70cebc81a13aa095243bc18835d699a2adb36ceb From fdb83f7840dbdbf7891bec11aefc4fd97ee3476e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 22:41:15 +0530 Subject: [PATCH 016/332] test(sync): exercise model transfer through navigation --- .../sync/modelTransfer.integration.test.tsx | 291 +++++++++--------- 1 file changed, 152 insertions(+), 139 deletions(-) diff --git a/__tests__/pro/sync/modelTransfer.integration.test.tsx b/__tests__/pro/sync/modelTransfer.integration.test.tsx index a75e85eea..e9890b81f 100644 --- a/__tests__/pro/sync/modelTransfer.integration.test.tsx +++ b/__tests__/pro/sync/modelTransfer.integration.test.tsx @@ -1,14 +1,43 @@ import React from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; +import TcpSocket from 'react-native-tcp-socket'; import { - fireEvent, - render, - waitFor as waitForRender, -} from '@testing-library/react-native'; + FileTransferManager, + IncrementalChecksum, + MODEL_TRANSFER_MIME, + type DeviceInfo, +} from '@offgrid/sync'; +import type { RnTcpModule } from '@offgrid/sync/rn'; +import { AppNavigator } from '../../../src/navigation/AppNavigator'; +import { + registerScreen, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; +import { + registerSettingsSection, + _clearSectionsForTesting, +} from '../../../src/components/settings/sectionRegistry'; +import { useAppStore } from '../../../src/stores/appStore'; +import { modelManager } from '../../../src/services/modelManager'; +import { buildSyncEngine } from '../../../src/services/sync/engine'; +import { syncService } from '../../../pro/sync/syncService'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { modelTransferService } from '../../../pro/sync/modelTransferService'; +import { SyncScreen } from '../../../pro/ui/SyncScreen'; +import { SyncSettingsSection } from '../../../pro/ui/SyncSettingsSection'; +import { + getDiscoveryBoundaries, + resetDiscoveryBoundaries, +} from '../../utils/nativeSyncBoundaries'; +import { modelTransferFsBoundary } from '../../utils/modelTransferFsBoundary'; +import { createDownloadedModel } from '../../utils/factories'; -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ goBack: jest.fn() }), -})); +jest.mock('@react-navigation/native', () => + jest.requireActual('@react-navigation/native'), +); jest.mock('react-native-tcp-socket', () => { const { @@ -26,61 +55,51 @@ jest.mock('react-native-zeroconf', () => { jest.mock('react-native-fs', () => { const { - modelTransferFsBoundary, + modelTransferFsBoundary: boundary, } = require('../../utils/modelTransferFsBoundary'); return { __esModule: true, - default: modelTransferFsBoundary.module, - ...modelTransferFsBoundary.module, + default: boundary.module, + ...boundary.module, }; }); -const waitForCondition = async ( - condition: () => boolean, - timeoutMs = 3000, -): Promise => { - const deadline = Date.now() + timeoutMs; - while (!condition()) { - if (Date.now() >= deadline) - throw new Error('Timed out waiting for Sync state'); - await new Promise(resolve => setTimeout(resolve, 10)); - } -}; +const nativeTcpBoundary = TcpSocket as unknown as RnTcpModule; describe('Pro mobile model transfer journey', () => { - it('receives, registers, and sends an encrypted GGUF through the rendered Sync journey', async () => { - const { - modelTransferFsBoundary: boundary, - } = require('../../utils/modelTransferFsBoundary'); - boundary.reset(); - const AsyncStorage = require('@react-native-async-storage/async-storage'); - const Keychain = require('react-native-keychain'); - const TcpSocket = require('react-native-tcp-socket').default; - const { - FileTransferManager, - IncrementalChecksum, - MODEL_TRANSFER_MIME, - } = require('@offgrid/sync'); - const { buildSyncEngine } = require('../../../src/services/sync/engine'); - const { syncService } = require('../../../pro/sync/syncService'); - const { - modelTransferService, - } = require('../../../pro/sync/modelTransferService'); - const { SyncScreen } = require('../../../pro/ui/SyncScreen'); - const { useSyncStore } = require('../../../pro/sync/syncStore'); - const { modelManager } = require('../../../src/services/modelManager'); - const { - getDiscoveryBoundaries, - resetDiscoveryBoundaries, - } = require('../../utils/nativeSyncBoundaries'); + let remote: ReturnType | undefined; + let remoteTransfers: FileTransferManager | undefined; + let ui: ReturnType | undefined; + beforeEach(async () => { + modelTransferFsBoundary.reset(); await AsyncStorage.clear(); resetDiscoveryBoundaries(); - Keychain.getGenericPassword.mockResolvedValue(false); - Keychain.setGenericPassword.mockResolvedValue(true); + _clearScreensForTesting(); + _clearSectionsForTesting(); + registerScreen({ name: 'Sync', component: SyncScreen }); + registerSettingsSection(SyncSettingsSection); + useAppStore.getState().setOnboardingComplete(true); + useAppStore + .getState() + .setDownloadedModels([createDownloadedModel({ engine: 'litert' })]); + useSyncStore.getState().reset(); + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(false); + (Keychain.setGenericPassword as jest.Mock).mockResolvedValue(true); + }); - let remoteTransfers: InstanceType | undefined; - const remoteDevice = { + afterEach(async () => { + ui?.unmount(); + await remoteTransfers?.dispose(); + await remote?.engine.stop(); + await syncService.stop(); + await modelTransferService.stop(); + _clearScreensForTesting(); + _clearSectionsForTesting(); + }); + + it('receives, rejects, and sends a GGUF through Settings to Sync', async () => { + const remoteDevice: DeviceInfo = { id: 'desktop-model-source', name: 'Off Grid AI Desktop', platform: 'macos', @@ -88,54 +107,19 @@ describe('Pro mobile model transfer journey', () => { host: '127.0.0.1', port: 0, }; - const remote = buildSyncEngine({ + let returnedModel: Buffer | undefined; + let returnedFileName: string | undefined; + + remote = buildSyncEngine({ localDevice: remoteDevice, - tcpModule: TcpSocket, - onMessage: (deviceId: string, message: unknown) => { + tcpModule: nativeTcpBoundary, + onMessage: (deviceId, message) => { remoteTransfers?.handleMessage(deviceId, message); }, }); - await remote.engine.start(0); - remoteDevice.port = remote.transport.boundPort ?? 0; - - modelTransferService.start(); - await syncService.start(); - const mobile = useSyncStore.getState().thisDevice; - const discovery = getDiscoveryBoundaries().at(-1); - expect(mobile).toBeDefined(); - expect(discovery?.publishedPort).toBeGreaterThan(0); - useSyncStore.getState().setPairingCode('green-river-52'); - - await remote.engine.pair( - { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, - 'green-river-52', - ); - await waitForCondition(() => - useSyncStore - .getState() - .paired.some((device: { id: string }) => device.id === remoteDevice.id), - ); - - const payload = Buffer.alloc(96 * 1024 + 4, 0x5a); - payload.write('GGUF', 0, 'ascii'); - const checksum = new IncrementalChecksum(); - checksum.update(payload); - const fileName = 'gemma-mobile-Q4_K_M.gguf'; - let returnedModel: Buffer | undefined; - let returnedFileName: string | undefined; remoteTransfers = new FileTransferManager({ - send: (deviceId: string, message: unknown) => - remote.engine.send(deviceId, message), - createSink: async ( - _deviceId: string, - request: { - payload: { - fileName: string; - fileSize: number; - checksum: string; - }; - }, - ) => { + send: (deviceId, message) => remote!.engine.send(deviceId, message), + createSink: async (_deviceId, request) => { const received = Buffer.alloc(request.payload.fileSize); return { prepare: async () => 0, @@ -143,11 +127,9 @@ describe('Pro mobile model transfer journey', () => { Buffer.from(data).copy(received, offset); }, finalize: async () => { - const receivedChecksum = new IncrementalChecksum(); - receivedChecksum.update(received); - if (receivedChecksum.digest() !== request.payload.checksum) { - return false; - } + const checksum = new IncrementalChecksum(); + checksum.update(received); + if (checksum.digest() !== request.payload.checksum) return false; returnedModel = received; returnedFileName = request.payload.fileName; return true; @@ -157,6 +139,45 @@ describe('Pro mobile model transfer journey', () => { }, }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + modelTransferService.start(); + await syncService.start(); + + ui = render( + + + , + ); + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); + await waitFor(() => + expect(ui!.getByText('Discoverable on your Wi-Fi')).toBeTruthy(), + ); + fireEvent.changeText(ui.getByTestId('sync-pairing-code'), 'green-river-52'); + + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !discovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + await remote.engine.pair( + { + ...mobile, + host: '127.0.0.1', + port: discovery.publishedPort, + }, + 'green-river-52', + ); + await waitFor(() => + expect(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).toBeTruthy(), + ); + + const payload = Buffer.alloc(96 * 1024 + 4, 0x5a); + payload.write('GGUF', 0, 'ascii'); + const checksum = new IncrementalChecksum(); + checksum.update(payload); + const fileName = 'gemma-mobile-Q4_K_M.gguf'; await remoteTransfers.sendFile(mobile.id, { fileName, fileSize: payload.length, @@ -173,12 +194,14 @@ describe('Pro mobile model transfer journey', () => { }, }, checksum: async () => checksum.digest(), - read: async (offset: number, length: number) => + read: async (offset, length) => new Uint8Array(payload.subarray(offset, offset + length)), }); - const models = await modelManager.getDownloadedModels(); - expect(models).toEqual([ + await waitFor(() => + expect(ui!.getByText(`Received ${fileName}`)).toBeTruthy(), + ); + await expect(modelManager.getDownloadedModels()).resolves.toEqual([ expect.objectContaining({ id: `google/gemma-mobile/${fileName}`, name: 'Gemma Mobile', @@ -188,13 +211,13 @@ describe('Pro mobile model transfer journey', () => { fileSize: payload.length, }), ]); - expect( - await boundary.readAscii( - `${boundary.DocumentDirectoryPath}/models/${fileName}`, + await expect( + modelTransferFsBoundary.readAscii( + `${modelTransferFsBoundary.DocumentDirectoryPath}/models/${fileName}`, 4, 0, ), - ).toBe('GGUF'); + ).resolves.toBe('GGUF'); const invalidPayload = Buffer.alloc(4096, 0x58); const invalidChecksum = new IncrementalChecksum(); @@ -214,40 +237,47 @@ describe('Pro mobile model transfer journey', () => { kind: 'text', source: 'downloaded', files: [ - { name: invalidFileName, sizeBytes: invalidPayload.length }, + { + name: invalidFileName, + sizeBytes: invalidPayload.length, + }, ], }, }, checksum: async () => invalidChecksum.digest(), - read: async (offset: number, length: number) => + read: async (offset, length) => new Uint8Array(invalidPayload.subarray(offset, offset + length)), }), ).rejects.toThrow('receiver could not verify or register the file'); - expect(await modelManager.getDownloadedModels()).toHaveLength(1); - expect( - await boundary.exists( - `${boundary.DocumentDirectoryPath}/models/${invalidFileName}`, + await waitFor(() => + expect( + ui!.getByText(`Could not receive ${invalidFileName}`), + ).toBeTruthy(), + ); + await expect(modelManager.getDownloadedModels()).resolves.toHaveLength(1); + await expect( + modelTransferFsBoundary.exists( + `${modelTransferFsBoundary.DocumentDirectoryPath}/models/${invalidFileName}`, ), - ).toBe(false); - expect( - await boundary.exists( - `${boundary.DocumentDirectoryPath}/models/${invalidFileName}.part`, + ).resolves.toBe(false); + await expect( + modelTransferFsBoundary.exists( + `${modelTransferFsBoundary.DocumentDirectoryPath}/models/${invalidFileName}.part`, ), - ).toBe(false); + ).resolves.toBe(false); - const ui = render(); fireEvent.press(ui.getByTestId(`sync-send-model-${remoteDevice.id}`)); - await waitForRender(() => + await waitFor(() => expect( - ui.getByTestId(`transfer-model-google/gemma-mobile/${fileName}`), + ui!.getByTestId(`transfer-model-google/gemma-mobile/${fileName}`), ).toBeTruthy(), ); fireEvent.press(ui.getByTestId('send-selected-model')); - await waitForRender( + await waitFor( () => expect( - ui.getByText(`Gemma Mobile is available on ${remoteDevice.name}.`), + ui!.getByText(`Gemma Mobile is available on ${remoteDevice.name}.`), ).toBeTruthy(), { timeout: 5000 }, ); @@ -256,22 +286,5 @@ describe('Pro mobile model transfer journey', () => { expect(ui.getAllByText(`Sent ${fileName}`).length).toBeGreaterThanOrEqual( 1, ); - ui.unmount(); - - const transferredModelId = `google/gemma-mobile/${fileName}`; - for (let index = 0; index < 21; index += 1) { - await expect( - modelTransferService.sendModel( - `offline-device-${index}`, - transferredModelId, - ), - ).rejects.toThrow('device is not connected'); - } - expect(modelTransferService.getProgressSnapshot()).toHaveLength(20); - - await remoteTransfers.dispose(); - await remote.engine.stop(); - await syncService.stop(); - await modelTransferService.stop(); }); }); From 8b96fd775694f7d8af56b5d03b7802687f09c104 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Sun, 26 Jul 2026 22:41:55 +0530 Subject: [PATCH 017/332] docs(sync): record pairing and model transfer progress --- docs/SYNC_MOBILE_PROGRESS.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 47e15d34c..c9238b722 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -13,7 +13,7 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as ## Devices (mobile side, on hand for real 2-device tests) - **iPhone 17 Pro Max** — hardware UDID `00008150-000225103CD8C01C` ("Mac's iPhone"), iOS 26.5.2. - Driven via WebDriverAgent (`http://192.168.1.14:8100`) + `devicectl`. + Driven via WebDriverAgent + `devicectl`; read the current WDA URL from `launchWda.ts`. - **Android** — `adb` id `505b53a0` (OnePlus). Driven via `adb`. - Both on the same LAN as the laptop. Can pair phone↔phone and phone↔desktop. @@ -24,7 +24,7 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as equivalent to its message store). Conflict resolution is the engine's LWW — not reimplemented. ## Phase progress -### Phase 0 — Foundation (transport live) — IN PROGRESS +### Phase 0 - Foundation (transport live) - COMPLETE - [x] Consume `@offgrid/sync` file: dep + pure-JS crypto deps (tweetnacl, tweetnacl-util, js-sha512). - [x] Metro resolves the package + `./rn` `./rn-discovery` `./portable` (watchFolder + subpath aliases; not global package-exports). Bundles on both platforms. @@ -41,7 +41,9 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - [x] **iOS transport LIVE on device:** `[SYNC] started ... port=51770 platform=ios`; the Mac's `dns-sd -B _offgrid._tcp` sees `OffGrid-` → native Bonjour publish + service type confirmed on the real LAN. -- [ ] Two-device handshake (discover → NaCl pair → app message): pending Android reinstall (peer). +- [x] Two-device handshake (discover, NaCl pair, app message) verified manually on real devices. +- [x] Pairing secrets persist in Keychain. A paired peer reconnects after the mobile Sync service + restarts without asking for the pairing code again. ### Phase 0.5 — Pro experience + licensed devices — COMPLETE - [x] Sync UI and lifecycle orchestration live in the private Pro package, registered through the @@ -55,7 +57,18 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as current), and a rendered AppNavigator journey covers Settings → Sync → device deactivation. ### Phase 1 — State sync (chats/projects/settings) — NOT STARTED (needs mobile UUID migration first) -### Phase 2 — Model transfer — NOT STARTED + +### Phase 2 - Model transfer - IN PROGRESS +- [x] Receive one text GGUF over the encrypted paired channel. Mobile writes to a resumable `.part` + file, verifies byte count, checksum, and the `GGUF` header, then registers the model. +- [x] Invalid files are rejected and both the final file and partial file are removed. +- [x] Send an installed single-file GGUF from a paired device row. Sync shows transfer progress, + failure, cancellation, completion, and dismissal states. +- [x] The rendered AppNavigator journey covers Settings to Sync, pairing-code entry, valid receive, + invalid receive, model admission, and sending the admitted model back. +- [ ] Verify a full-size GGUF transfer in both directions on real iOS and Android devices. +- [ ] Add multi-file transfer before exposing vision models or other model formats. + ### Phase 3 — Ambient sharing — NOT STARTED ## Security note (logged for GA) From f50dea2c808c33a27f745e5e3327553e981262af Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:18:30 +0530 Subject: [PATCH 018/332] feat(sync): persist stable mobile entity ids --- .../sync/stableIdentity.integration.test.ts | 98 +++++++++++++++++++ index.js | 3 + src/stores/chatPersistence.ts | 49 ++++++++++ src/stores/chatStore.ts | 9 +- src/types/index.ts | 5 + src/utils/generateId.ts | 46 ++++++--- 6 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 __tests__/integration/sync/stableIdentity.integration.test.ts create mode 100644 src/stores/chatPersistence.ts diff --git a/__tests__/integration/sync/stableIdentity.integration.test.ts b/__tests__/integration/sync/stableIdentity.integration.test.ts new file mode 100644 index 000000000..492575b3e --- /dev/null +++ b/__tests__/integration/sync/stableIdentity.integration.test.ts @@ -0,0 +1,98 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +async function loadStores() { + jest.resetModules(); + const stores = + require('../../../src/stores') as typeof import('../../../src/stores'); + await stores.useChatStore.persist.rehydrate(); + await stores.useProjectStore.persist.rehydrate(); + return stores; +} + +describe('sync identity persistence', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + }); + + it('backfills a legacy message once and creates UUID identities that survive relaunch', async () => { + await AsyncStorage.setItem( + 'local-llm-chat-storage', + JSON.stringify({ + state: { + conversations: [ + { + id: 'legacy-conversation', + title: 'Before Sync', + modelId: 'legacy-model', + messages: [ + { + id: 'legacy-message', + role: 'user', + content: 'Keep this identity stable', + timestamp: 1, + }, + ], + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + }, + ], + activeConversationId: 'legacy-conversation', + }, + version: 0, + }), + ); + + const firstLaunch = await loadStores(); + const legacyConversation = + firstLaunch.useChatStore.getState().conversations[0]; + const backfilledUuid = legacyConversation.messages[0].uuid; + + expect(legacyConversation.id).toBe('legacy-conversation'); + expect(legacyConversation.messages[0].id).toBe('legacy-message'); + expect(backfilledUuid).toMatch(UUID_V4); + + const conversationId = firstLaunch.useChatStore + .getState() + .createConversation('sync-model'); + const message = firstLaunch.useChatStore + .getState() + .addMessage(conversationId, { + role: 'user', + content: 'Created after the migration', + }); + const project = firstLaunch.useProjectStore.getState().createProject({ + name: 'Synced project', + description: '', + systemPrompt: '', + }); + + expect(conversationId).toMatch(UUID_V4); + expect(project.id).toMatch(UUID_V4); + expect(message.id).toMatch(UUID_V4); + expect(message.uuid).toBe(message.id); + + const secondLaunch = await loadStores(); + const restoredLegacy = secondLaunch.useChatStore + .getState() + .conversations.find( + (conversation: { id: string }) => + conversation.id === 'legacy-conversation', + ); + const restoredNew = secondLaunch.useChatStore + .getState() + .conversations.find( + (conversation: { id: string }) => conversation.id === conversationId, + ); + + expect(restoredLegacy?.messages[0].uuid).toBe(backfilledUuid); + expect(restoredNew?.messages[0].uuid).toBe(message.uuid); + expect( + secondLaunch.useProjectStore + .getState() + .projects.some(candidate => candidate.id === project.id), + ).toBe(true); + }); +}); diff --git a/index.js b/index.js index 87580d3a3..b35f284ef 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,9 @@ // Spec-compliant global URL — RN's built-in mangles paths (adds trailing slashes), // which breaks the MCP SDK's OAuth discovery. Must load before any network/OAuth code. import 'react-native-url-polyfill/auto'; +// Hermes does not expose Web Crypto on every supported OS version. Install the +// secure getRandomValues polyfill before stores create persisted sync identities. +import 'react-native-get-random-values'; import { AppRegistry } from 'react-native'; import App from './App'; import { name as appName } from './app.json'; diff --git a/src/stores/chatPersistence.ts b/src/stores/chatPersistence.ts new file mode 100644 index 000000000..91e687f8d --- /dev/null +++ b/src/stores/chatPersistence.ts @@ -0,0 +1,49 @@ +import { generateId } from '../utils/generateId'; +import type { Message } from '../types'; + +export const CHAT_STORAGE_VERSION = 1; + +export function createPersistedMessage( + data: Omit, +): Message { + const id = generateId(); + return { id, ...data, uuid: data.uuid ?? id, timestamp: Date.now() }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Version 1 gives every legacy persisted message the wire identity desktop uses + * for `rag_messages.uuid`. Conversation/message local ids stay unchanged, so + * active-chat references and compaction cutoffs remain valid. + */ +export function migratePersistedChatState( + persistedState: unknown, + version: number, +): unknown { + if (version >= CHAT_STORAGE_VERSION || !isRecord(persistedState)) { + return persistedState; + } + const conversations = persistedState.conversations; + if (!Array.isArray(conversations)) return persistedState; + + return { + ...persistedState, + conversations: conversations.map(conversation => { + if (!isRecord(conversation) || !Array.isArray(conversation.messages)) { + return conversation; + } + return { + ...conversation, + messages: conversation.messages.map(message => { + if (!isRecord(message) || typeof message.uuid === 'string') { + return message; + } + return { ...message, uuid: generateId() }; + }), + }; + }), + }; +} diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index c663f11ba..77a7fc6cd 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -5,6 +5,7 @@ import { Message, Conversation, GenerationMeta } from '../types'; import { stripStreamingControlTokens, parseModelOutput } from '../utils/messageContent'; import { generateId } from '../utils/generateId'; import { callHook, HOOKS } from '../bootstrap/hookRegistry'; +import { CHAT_STORAGE_VERSION, createPersistedMessage, migratePersistedChatState } from './chatPersistence'; function nextUpdatedAt(previousUpdatedAt?: string): string { const now = Date.now(); @@ -167,11 +168,7 @@ export const useChatStore = create()( }, addMessage: (conversationId, messageData) => { - const message: Message = { - id: generateId(), - ...messageData, - timestamp: Date.now(), - }; + const message = createPersistedMessage(messageData); set((state) => ({ conversations: state.conversations.map((conv) => @@ -352,6 +349,8 @@ export const useChatStore = create()( { name: 'local-llm-chat-storage', storage: createJSONStorage(() => AsyncStorage), + version: CHAT_STORAGE_VERSION, + migrate: migratePersistedChatState, partialize: (state) => ({ conversations: state.conversations, activeConversationId: state.activeConversationId, diff --git a/src/types/index.ts b/src/types/index.ts index e5d1d8249..30b1e0de1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -203,6 +203,11 @@ export interface GenerationMeta { // Chat-related types export interface Message { id: string; + /** + * Stable cross-device identity. Persisted messages always carry this; transient + * prompt-only messages may omit it because they never enter the sync log. + */ + uuid?: string; role: 'user' | 'assistant' | 'system' | 'tool'; content: string; /** Reasoning/thinking content parsed by llama.rn (separate from response content) */ diff --git a/src/utils/generateId.ts b/src/utils/generateId.ts index b563b5459..39ed7ed7f 100644 --- a/src/utils/generateId.ts +++ b/src/utils/generateId.ts @@ -1,20 +1,36 @@ -/** - * Generate a unique ID using crypto.getRandomValues when available, - * with a Date.now()-based fallback for environments where the Web Crypto - * API is not exposed (e.g. older Hermes builds on Android). - */ -export function generateId(): string { - let random: string; +let fallbackSequence = 0; + +function randomBytes(): Uint8Array { + const bytes = new Uint8Array(16); if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - const array = new Uint32Array(1); - crypto.getRandomValues(array); - random = array[0].toString(36); - } else { - // Fallback: combine high-resolution timer bits with a counter. - // Not cryptographically secure, but sufficient for local IDs. - random = ((Date.now() * 9301 + 49297) % 233280).toString(36); // NOSONAR + crypto.getRandomValues(bytes); + return bytes; } - return `${Date.now()}-${random}`; + + // App bootstrap installs react-native-get-random-values. This fallback keeps + // isolated JS environments functional without weakening the persisted format. + fallbackSequence += 1; + let seed = Date.now() + fallbackSequence; + for (let index = 0; index < bytes.length; index += 1) { + seed = (seed * 1664525 + 1013904223) % 4294967296; // NOSONAR + bytes[index] = seed % 256; + } + return bytes; +} + +/** Generate an RFC 4122 version-4 UUID for persisted cross-device identity. */ +export function generateId(): string { + const bytes = randomBytes(); + bytes[6] = (bytes[6] % 16) + 64; + bytes[8] = (bytes[8] % 64) + 128; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4).join(''), + hex.slice(4, 6).join(''), + hex.slice(6, 8).join(''), + hex.slice(8, 10).join(''), + hex.slice(10, 16).join(''), + ].join('-'); } /** From 857096a05726f798d1ea5afb970b416a3d2b36fa Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:36:05 +0530 Subject: [PATCH 019/332] feat(sync): replicate mobile chats and projects --- .../pro/sync/stateSync.integration.test.tsx | 257 ++++++++++++++ pro | 2 +- src/bootstrap/hookRegistry.ts | 3 + src/services/sync/mutation.ts | 89 +++++ src/stores/chatMessageMutationActions.ts | 165 +++++++++ src/stores/chatStore.ts | 334 +++++++++++------- src/stores/projectStore.ts | 60 +++- 7 files changed, 760 insertions(+), 150 deletions(-) create mode 100644 __tests__/pro/sync/stateSync.integration.test.tsx create mode 100644 src/services/sync/mutation.ts create mode 100644 src/stores/chatMessageMutationActions.ts diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx new file mode 100644 index 000000000..2edc4bd74 --- /dev/null +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; +import TcpSocket from 'react-native-tcp-socket'; +import { + OpLog, + StateSync, + type DeviceInfo, + type Materializer, +} from '@offgrid/sync'; +import type { RnTcpModule } from '@offgrid/sync/rn'; +import { AppNavigator } from '../../../src/navigation/AppNavigator'; +import { + registerScreen, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; +import { + registerSettingsSection, + _clearSectionsForTesting, +} from '../../../src/components/settings/sectionRegistry'; +import { + HOOKS, + _clearHooksForTesting, + registerHook, +} from '../../../src/bootstrap/hookRegistry'; +import { useAppStore } from '../../../src/stores/appStore'; +import { useChatStore } from '../../../src/stores/chatStore'; +import { useProjectStore } from '../../../src/stores/projectStore'; +import { buildSyncEngine } from '../../../src/services/sync/engine'; +import { + CORE_SYNC_ENTITIES, + type SyncMutation, +} from '../../../src/services/sync/mutation'; +import { syncService } from '../../../pro/sync/syncService'; +import { stateSyncService } from '../../../pro/sync/stateSyncService'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { SyncScreen } from '../../../pro/ui/SyncScreen'; +import { SyncSettingsSection } from '../../../pro/ui/SyncSettingsSection'; +import { + getDiscoveryBoundaries, + resetDiscoveryBoundaries, +} from '../../utils/nativeSyncBoundaries'; +import { createDownloadedModel } from '../../utils/factories'; + +jest.mock('@react-navigation/native', () => + jest.requireActual('@react-navigation/native'), +); + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +const nativeTcpBoundary = TcpSocket as unknown as RnTcpModule; + +class RemoteRecords implements Materializer { + readonly records = new Map>(); + + put(entity: string, entityId: string, fields: Record): void { + this.records.set(`${entity}:${entityId}`, fields); + } + + remove(entity: string, entityId: string): void { + this.records.delete(`${entity}:${entityId}`); + } +} + +describe('Pro mobile state sync journey', () => { + let remote: ReturnType | undefined; + let ui: ReturnType | undefined; + + beforeEach(async () => { + _clearHooksForTesting(); + await stateSyncService.stop(); + await syncService.stop(); + await AsyncStorage.clear(); + resetDiscoveryBoundaries(); + _clearScreensForTesting(); + _clearSectionsForTesting(); + registerScreen({ name: 'Sync', component: SyncScreen }); + registerSettingsSection(SyncSettingsSection); + useAppStore.getState().setOnboardingComplete(true); + useAppStore + .getState() + .setDownloadedModels([createDownloadedModel({ engine: 'litert' })]); + useSyncStore.getState().reset(); + useChatStore.getState().clearAllConversations(); + for (const project of useProjectStore.getState().projects) { + useProjectStore.getState().deleteProject(project.id); + } + registerHook(HOOKS.syncRecordLocalMutation, (mutation: SyncMutation) => { + stateSyncService.recordMutation(mutation); + }); + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(false); + (Keychain.setGenericPassword as jest.Mock).mockResolvedValue(true); + }); + + afterEach(async () => { + ui?.unmount(); + _clearHooksForTesting(); + await stateSyncService.stop(); + await remote?.engine.stop(); + await syncService.stop(); + _clearScreensForTesting(); + _clearSectionsForTesting(); + }); + + it('converges state and honors visible sharing controls through the rendered app', async () => { + const remoteDevice: DeviceInfo = { + id: 'desktop-state-peer', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + const remoteRecords = new RemoteRecords(); + let opIndex = 0; + const remoteLog = new OpLog({ + deviceId: remoteDevice.id, + materializer: remoteRecords, + uuid: () => `desktop-op-${++opIndex}`, + now: () => Date.now(), + }); + let remoteState: StateSync; + remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + onPaired: device => remoteState.onConnect(device.id), + onAppMessage: (deviceId, channel, data) => { + if (channel === 'state') remoteState.onMessage(deviceId, data); + }, + }); + remoteState = new StateSync({ + oplog: remoteLog, + send: (deviceId, message) => { + remote!.engine.sendApp(deviceId, 'state', message); + }, + }); + + const createdAt = '2026-07-27T12:00:00.000Z'; + remoteLog.record(CORE_SYNC_ENTITIES.project, 'remote-project', 'put', { + name: 'Desktop Research', + description: 'Notes created before pairing', + system_prompt: 'Keep the research grounded.', + icon: null, + include_memory: 1, + created_at: createdAt, + updated_at: createdAt, + }); + remoteLog.record( + CORE_SYNC_ENTITIES.conversation, + 'remote-conversation', + 'put', + { + title: 'Field planning', + project_id: 'remote-project', + created_at: createdAt, + updated_at: createdAt, + }, + ); + remoteLog.record(CORE_SYNC_ENTITIES.message, 'remote-message', 'put', { + conversation_id: 'remote-conversation', + role: 'user', + content: 'Bring the field notes', + context: null, + created_at: createdAt, + }); + + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + await stateSyncService.start(); + await syncService.start(); + + ui = render( + + + , + ); + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); + fireEvent.changeText(ui.getByTestId('sync-pairing-code'), 'violet-lake-27'); + + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !discovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + await remote.engine.pair( + { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, + 'violet-lake-27', + ); + await waitFor(() => + expect(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).toBeTruthy(), + ); + + fireEvent(ui.getByTestId('sync-projects-toggle'), 'valueChange', false); + await waitFor(() => + expect(stateSyncService.preferences().projects).toBe(false), + ); + + fireEvent.press(ui.getByLabelText('Back')); + fireEvent.press(await waitFor(() => ui!.getByTestId('projects-tab'))); + await waitFor(() => expect(ui!.getByText('Desktop Research')).toBeTruthy()); + fireEvent.press(ui.getByTestId('chats-tab')); + await waitFor(() => expect(ui!.getByText('Field planning')).toBeTruthy()); + expect(ui.getByText('You: Bring the field notes')).toBeTruthy(); + expect(ui.getByText('Desktop Research')).toBeTruthy(); + + fireEvent.press(ui.getByTestId('projects-tab')); + fireEvent.press(ui.getByText('New')); + fireEvent.changeText( + ui.getByPlaceholderText('e.g., Spanish Learning, Code Review'), + 'Phone Notes', + ); + fireEvent.changeText( + ui.getByPlaceholderText( + 'Enter the instructions or context for the AI...', + ), + 'Keep these notes concise.', + ); + fireEvent.press(ui.getByText('Save')); + + const phoneProject = useProjectStore + .getState() + .projects.find(project => project.name === 'Phone Notes'); + if (!phoneProject) throw new Error('Phone project was not saved'); + expect( + remoteRecords.records.has( + `${CORE_SYNC_ENTITIES.project}:${phoneProject.id}`, + ), + ).toBe(false); + + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); + fireEvent(ui.getByTestId('sync-projects-toggle'), 'valueChange', true); + + await waitFor(() => + expect( + remoteRecords.records.get( + `${CORE_SYNC_ENTITIES.project}:${phoneProject.id}`, + ), + ).toMatchObject({ name: 'Phone Notes' }), + ); + }); +}); diff --git a/pro b/pro index 70cebc81a..93cf8ce82 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 70cebc81a13aa095243bc18835d699a2adb36ceb +Subproject commit 93cf8ce828208ac976022f15c316e37cb8ce77ad diff --git a/src/bootstrap/hookRegistry.ts b/src/bootstrap/hookRegistry.ts index 25877aed4..88fc45771 100644 --- a/src/bootstrap/hookRegistry.ts +++ b/src/bootstrap/hookRegistry.ts @@ -57,4 +57,7 @@ export const HOOKS = { /** () => Promise — warm the active TTS engine at boot if its model is * downloaded and fits the residency budget (no-op otherwise). */ audioPreload: 'audio.preload', + /** (mutation: SyncMutation) => void — a core data owner committed a record + * change. Pro records it in the state-sync op-log; free builds do nothing. */ + syncRecordLocalMutation: 'sync.recordLocalMutation', } as const; diff --git a/src/services/sync/mutation.ts b/src/services/sync/mutation.ts new file mode 100644 index 000000000..a27333a0a --- /dev/null +++ b/src/services/sync/mutation.ts @@ -0,0 +1,89 @@ +import { callHook, HOOKS } from '../../bootstrap/hookRegistry'; +import type { Conversation, Message, Project } from '../../types'; + +/** Stable wire entity names shared with Off Grid Desktop. */ +export const CORE_SYNC_ENTITIES = { + conversation: 'conversation', + message: 'message', + project: 'project', + modelSetting: 'model_setting', +} as const; + +export type CoreSyncEntity = + (typeof CORE_SYNC_ENTITIES)[keyof typeof CORE_SYNC_ENTITIES]; + +export interface SyncMutation { + entity: CoreSyncEntity; + entityId: string; + kind: 'put' | 'delete'; + fields?: Record; +} + +export function conversationPutMutation( + conversation: Conversation, +): SyncMutation { + return { + entity: CORE_SYNC_ENTITIES.conversation, + entityId: conversation.id, + kind: 'put', + fields: { + title: conversation.title, + project_id: conversation.projectId ?? null, + created_at: conversation.createdAt, + updated_at: conversation.updatedAt, + }, + }; +} + +export function messagePutMutation( + conversationId: string, + message: Message, +): SyncMutation | null { + if (!message.uuid) return null; + return { + entity: CORE_SYNC_ENTITIES.message, + entityId: message.uuid, + kind: 'put', + fields: { + conversation_id: conversationId, + role: message.role, + content: message.content, + context: null, + created_at: new Date(message.timestamp).toISOString(), + }, + }; +} + +export function projectPutMutation(project: Project): SyncMutation { + return { + entity: CORE_SYNC_ENTITIES.project, + entityId: project.id, + kind: 'put', + fields: { + name: project.name, + description: project.description, + system_prompt: project.systemPrompt, + icon: project.icon ?? null, + include_memory: 1, + created_at: project.createdAt, + updated_at: project.updatedAt, + }, + }; +} + +export function deleteSyncMutation( + entity: CoreSyncEntity, + entityId: string, +): SyncMutation { + return { entity, entityId, kind: 'delete' }; +} + +/** Core commits first; Pro optionally records the resulting canonical mutation. */ +export function emitSyncMutation(mutation: SyncMutation | null): void { + if (!mutation) return; + try { + callHook(HOOKS.syncRecordLocalMutation, mutation); + } catch { + // Sync is additive. A Pro integration failure must not roll back local data. + } +} diff --git a/src/stores/chatMessageMutationActions.ts b/src/stores/chatMessageMutationActions.ts new file mode 100644 index 000000000..7f0b38898 --- /dev/null +++ b/src/stores/chatMessageMutationActions.ts @@ -0,0 +1,165 @@ +import type { Conversation, Message } from '../types'; +import { + CORE_SYNC_ENTITIES, + deleteSyncMutation, + emitSyncMutation, + messagePutMutation, +} from '../services/sync/mutation'; + +export interface ChatMessageMutationActions { + updateMessageContent: ( + conversationId: string, + messageId: string, + content: string, + ) => void; + updateMessageThinking: ( + conversationId: string, + messageId: string, + isThinking: boolean, + ) => void; + updateMessageAudio: ( + conversationId: string, + messageId: string, + audio: { + audioPath?: string; + waveformData?: number[]; + audioDurationSeconds?: number; + isGeneratingAudio?: boolean; + isAudioModeMessage?: boolean; + }, + ) => void; + deleteMessage: (conversationId: string, messageId: string) => void; + deleteMessagesAfter: (conversationId: string, messageId: string) => void; +} + +interface ChatMessageMutationOwner { + updateConversations( + update: (conversations: Conversation[]) => Conversation[], + ): void; + getConversationMessages(conversationId: string): Message[]; +} + +export function nextUpdatedAt(previousUpdatedAt?: string): string { + const now = Date.now(); + if (!previousUpdatedAt) return new Date(now).toISOString(); + const previousTime = Date.parse(previousUpdatedAt); + const nextTime = Number.isNaN(previousTime) + ? now + : Math.max(now, previousTime + 1); + return new Date(nextTime).toISOString(); +} + +function updateMessageInConversation( + conversation: Conversation, + messageId: string, + update: (message: Message) => Message, +): Conversation { + return { + ...conversation, + messages: conversation.messages.map(message => + message.id === messageId ? update(message) : message, + ), + updatedAt: nextUpdatedAt(conversation.updatedAt), + }; +} + +function mapConversation( + conversations: Conversation[], + conversationId: string, + update: (conversation: Conversation) => Conversation, +): Conversation[] { + return conversations.map(conversation => + conversation.id === conversationId ? update(conversation) : conversation, + ); +} + +export function createMessageMutationActions( + owner: ChatMessageMutationOwner, +): ChatMessageMutationActions { + return { + updateMessageContent: (conversationId, messageId, content) => { + owner.updateConversations(conversations => + mapConversation(conversations, conversationId, conversation => + updateMessageInConversation(conversation, messageId, message => ({ + ...message, + content, + })), + ), + ); + const message = owner + .getConversationMessages(conversationId) + .find(candidate => candidate.id === messageId); + if (message) { + emitSyncMutation(messagePutMutation(conversationId, message)); + } + }, + + updateMessageThinking: (conversationId, messageId, isThinking) => { + owner.updateConversations(conversations => + mapConversation(conversations, conversationId, conversation => + updateMessageInConversation(conversation, messageId, message => ({ + ...message, + isThinking, + })), + ), + ); + }, + + updateMessageAudio: (conversationId, messageId, audio) => { + owner.updateConversations(conversations => + mapConversation(conversations, conversationId, conversation => + updateMessageInConversation(conversation, messageId, message => ({ + ...message, + ...audio, + })), + ), + ); + }, + + deleteMessage: (conversationId, messageId) => { + const removed = owner + .getConversationMessages(conversationId) + .find(message => message.id === messageId); + owner.updateConversations(conversations => + mapConversation(conversations, conversationId, conversation => ({ + ...conversation, + messages: conversation.messages.filter( + message => message.id !== messageId, + ), + updatedAt: nextUpdatedAt(conversation.updatedAt), + })), + ); + if (removed?.uuid) { + emitSyncMutation( + deleteSyncMutation(CORE_SYNC_ENTITIES.message, removed.uuid), + ); + } + }, + + deleteMessagesAfter: (conversationId, messageId) => { + const before = owner.getConversationMessages(conversationId); + const keepIndex = before.findIndex(message => message.id === messageId); + const removed = keepIndex === -1 ? [] : before.slice(keepIndex + 1); + owner.updateConversations(conversations => + mapConversation(conversations, conversationId, conversation => { + const messageIndex = conversation.messages.findIndex( + message => message.id === messageId, + ); + if (messageIndex === -1) return conversation; + return { + ...conversation, + messages: conversation.messages.slice(0, messageIndex + 1), + updatedAt: nextUpdatedAt(conversation.updatedAt), + }; + }), + ); + for (const message of removed) { + if (message.uuid) { + emitSyncMutation( + deleteSyncMutation(CORE_SYNC_ENTITIES.message, message.uuid), + ); + } + } + }, + }; +} diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 77a7fc6cd..ed9bfd2aa 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -2,31 +2,29 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { Message, Conversation, GenerationMeta } from '../types'; -import { stripStreamingControlTokens, parseModelOutput } from '../utils/messageContent'; +import { + stripStreamingControlTokens, + parseModelOutput, +} from '../utils/messageContent'; import { generateId } from '../utils/generateId'; import { callHook, HOOKS } from '../bootstrap/hookRegistry'; -import { CHAT_STORAGE_VERSION, createPersistedMessage, migratePersistedChatState } from './chatPersistence'; - -function nextUpdatedAt(previousUpdatedAt?: string): string { - const now = Date.now(); - if (!previousUpdatedAt) return new Date(now).toISOString(); - const previousTime = Date.parse(previousUpdatedAt); - const nextTime = Number.isNaN(previousTime) ? now : Math.max(now, previousTime + 1); - return new Date(nextTime).toISOString(); -} - -/** Update a single message inside a conversation's messages array. */ -function updateMessageInConv( - conv: Conversation, - messageId: string, - updater: (msg: Message) => Message, -): Conversation { - return { - ...conv, - messages: conv.messages.map((msg) => (msg.id === messageId ? updater(msg) : msg)), - updatedAt: nextUpdatedAt(conv.updatedAt), - }; -} +import { + CHAT_STORAGE_VERSION, + createPersistedMessage, + migratePersistedChatState, +} from './chatPersistence'; +import { + createMessageMutationActions, + nextUpdatedAt, + type ChatMessageMutationActions, +} from './chatMessageMutationActions'; +import { + CORE_SYNC_ENTITIES, + conversationPutMutation, + deleteSyncMutation, + emitSyncMutation, + messagePutMutation, +} from '../services/sync/mutation'; /** * The portion of the in-progress stream that is safe to SPEAK in voice mode — @@ -38,33 +36,35 @@ function updateMessageInConv( * sentence-by-sentence. onStreamingEnd still speaks the final answer if nothing * streamed. */ -function speakableStreamingAnswer(streamingMessage: string, streamingReasoning: string): string { +function speakableStreamingAnswer( + streamingMessage: string, + streamingReasoning: string, +): string { if (streamingReasoning.length > 0) return streamingMessage; // reasoning came separately const closeIdx = streamingMessage.toLowerCase().lastIndexOf(''); - if (closeIdx !== -1) return streamingMessage.slice(closeIdx + ''.length); + if (closeIdx !== -1) + return streamingMessage.slice(closeIdx + ''.length); // No close tag yet: inline reasoning may still be in progress. Withhold while // thinking is enabled; otherwise the content is the answer and is safe to speak. const { useAppStore } = require('./appStore'); - return useAppStore.getState().settings?.thinkingEnabled ? '' : streamingMessage; + return useAppStore.getState().settings?.thinkingEnabled + ? '' + : streamingMessage; } /** Derive conversation title from the first user message. */ -function deriveTitle(currentTitle: string, role: string, content: string): string { - if (currentTitle !== 'New Conversation' || role !== 'user') return currentTitle; +function deriveTitle( + currentTitle: string, + role: string, + content: string, +): string { + if (currentTitle !== 'New Conversation' || role !== 'user') + return currentTitle; const truncated = content.slice(0, 50); return content.length > 50 ? `${truncated}...` : truncated; } -/** Map over conversations, applying `updater` only to the one matching `conversationId`. */ -function mapConversation( - conversations: Conversation[], - conversationId: string, - updater: (conv: Conversation) => Conversation, -): Conversation[] { - return conversations.map((conv) => (conv.id === conversationId ? updater(conv) : conv)); -} - -interface ChatState { +export interface ChatState extends ChatMessageMutationActions { conversations: Conversation[]; activeConversationId: string | null; streamingMessage: string; @@ -72,30 +72,49 @@ interface ChatState { streamingForConversationId: string | null; isStreaming: boolean; isThinking: boolean; - createConversation: (modelId: string, title?: string, projectId?: string) => string; + createConversation: ( + modelId: string, + title?: string, + projectId?: string, + ) => string; deleteConversation: (conversationId: string) => void; setActiveConversation: (conversationId: string | null) => void; getActiveConversation: () => Conversation | null; - setConversationProject: (conversationId: string, projectId: string | null) => void; + setConversationProject: ( + conversationId: string, + projectId: string | null, + ) => void; /** Unfile every conversation filed under a project (used when the project is deleted, * so no chat is left pointing at a project that no longer exists). */ unfileConversationsForProject: (projectId: string) => void; - addMessage: (conversationId: string, message: Omit) => Message; - updateMessageContent: (conversationId: string, messageId: string, content: string) => void; - updateMessageThinking: (conversationId: string, messageId: string, isThinking: boolean) => void; - updateMessageAudio: (conversationId: string, messageId: string, audio: { audioPath?: string; waveformData?: number[]; audioDurationSeconds?: number; isGeneratingAudio?: boolean; isAudioModeMessage?: boolean }) => void; - deleteMessage: (conversationId: string, messageId: string) => void; - deleteMessagesAfter: (conversationId: string, messageId: string) => void; + addMessage: ( + conversationId: string, + message: Omit, + ) => Message; startStreaming: (conversationId: string) => void; setStreamingMessage: (content: string) => void; appendToStreamingMessage: (token: string) => void; appendToStreamingReasoningContent: (token: string) => void; setIsStreaming: (streaming: boolean) => void; setIsThinking: (thinking: boolean) => void; - finalizeStreamingMessage: (conversationId: string, generationTimeMs?: number, generationMeta?: GenerationMeta) => void; + finalizeStreamingMessage: ( + conversationId: string, + generationTimeMs?: number, + generationMeta?: GenerationMeta, + ) => void; clearStreamingMessage: () => void; - getStreamingState: () => { conversationId: string | null; content: string; reasoningContent: string; isStreaming: boolean; isThinking: boolean }; - updateCompactionState: (conversationId: string, summary?: string, cutoffMessageId?: string) => void; + getStreamingState: () => { + conversationId: string | null; + content: string; + reasoningContent: string; + isStreaming: boolean; + isThinking: boolean; + }; + updateCompactionState: ( + conversationId: string, + summary?: string, + cutoffMessageId?: string, + ) => void; clearAllConversations: () => void; getConversationMessages: (conversationId: string) => Message[]; } @@ -123,114 +142,130 @@ export const useChatStore = create()( projectId: projectId, }; - set((state) => ({ + set(state => ({ conversations: [conversation, ...state.conversations], activeConversationId: id, })); + emitSyncMutation(conversationPutMutation(conversation)); return id; }, - deleteConversation: (conversationId) => { - set((state) => ({ - conversations: state.conversations.filter((c) => c.id !== conversationId), - activeConversationId: state.activeConversationId === conversationId ? null : state.activeConversationId, + deleteConversation: conversationId => { + const removed = get().conversations.find(c => c.id === conversationId); + set(state => ({ + conversations: state.conversations.filter( + c => c.id !== conversationId, + ), + activeConversationId: + state.activeConversationId === conversationId + ? null + : state.activeConversationId, })); + for (const message of removed?.messages ?? []) { + if (message.uuid) + emitSyncMutation( + deleteSyncMutation(CORE_SYNC_ENTITIES.message, message.uuid), + ); + } + if (removed) + emitSyncMutation( + deleteSyncMutation(CORE_SYNC_ENTITIES.conversation, conversationId), + ); }, - setActiveConversation: (conversationId) => { + setActiveConversation: conversationId => { set({ activeConversationId: conversationId }); }, getActiveConversation: () => { const state = get(); - return state.conversations.find((c) => c.id === state.activeConversationId) || null; + return ( + state.conversations.find(c => c.id === state.activeConversationId) || + null + ); }, setConversationProject: (conversationId, projectId) => { - set((state) => ({ - conversations: state.conversations.map((conv) => + set(state => ({ + conversations: state.conversations.map(conv => conv.id !== conversationId ? conv - : { ...conv, projectId: projectId || undefined, updatedAt: nextUpdatedAt(conv.updatedAt) } + : { + ...conv, + projectId: projectId || undefined, + updatedAt: nextUpdatedAt(conv.updatedAt), + }, ), })); + const conversation = get().conversations.find( + conv => conv.id === conversationId, + ); + if (conversation) + emitSyncMutation(conversationPutMutation(conversation)); }, - unfileConversationsForProject: (projectId) => { - set((state) => ({ - conversations: state.conversations.map((conv) => + unfileConversationsForProject: projectId => { + const affected = get().conversations.filter( + conv => conv.projectId === projectId, + ); + set(state => ({ + conversations: state.conversations.map(conv => conv.projectId !== projectId ? conv - : { ...conv, projectId: undefined, updatedAt: nextUpdatedAt(conv.updatedAt) } + : { + ...conv, + projectId: undefined, + updatedAt: nextUpdatedAt(conv.updatedAt), + }, ), })); + for (const previous of affected) { + const conversation = get().conversations.find( + conv => conv.id === previous.id, + ); + if (conversation) + emitSyncMutation(conversationPutMutation(conversation)); + } }, addMessage: (conversationId, messageData) => { const message = createPersistedMessage(messageData); - set((state) => ({ - conversations: state.conversations.map((conv) => + set(state => ({ + conversations: state.conversations.map(conv => conv.id === conversationId ? { ...conv, messages: [...conv.messages, message], updatedAt: nextUpdatedAt(conv.updatedAt), - title: deriveTitle(conv.title, messageData.role, messageData.content), + title: deriveTitle( + conv.title, + messageData.role, + messageData.content, + ), } - : conv + : conv, ), })); + emitSyncMutation(messagePutMutation(conversationId, message)); + const conversation = get().conversations.find( + conv => conv.id === conversationId, + ); + if (conversation) + emitSyncMutation(conversationPutMutation(conversation)); return message; }, - updateMessageContent: (conversationId, messageId, content) => { - set((state) => ({ - conversations: mapConversation(state.conversations, conversationId, (conv) => - updateMessageInConv(conv, messageId, (msg) => ({ ...msg, content })) - ), - })); - }, - - updateMessageThinking: (conversationId, messageId, isThinking) => { - set((state) => ({ - conversations: mapConversation(state.conversations, conversationId, (conv) => - updateMessageInConv(conv, messageId, (msg) => ({ ...msg, isThinking })) - ), - })); - }, - - updateMessageAudio: (conversationId, messageId, audio) => { - set((state) => ({ conversations: mapConversation(state.conversations, conversationId, (conv) => updateMessageInConv(conv, messageId, (msg) => ({ ...msg, ...audio }))) })); - }, - - deleteMessage: (conversationId, messageId) => { - set((state) => ({ - conversations: mapConversation(state.conversations, conversationId, (conv) => ({ - ...conv, - messages: conv.messages.filter((msg) => msg.id !== messageId), - updatedAt: nextUpdatedAt(conv.updatedAt), - })), - })); - }, - - deleteMessagesAfter: (conversationId, messageId) => { - set((state) => ({ - conversations: mapConversation(state.conversations, conversationId, (conv) => { - const messageIndex = conv.messages.findIndex((msg) => msg.id === messageId); - if (messageIndex === -1) return conv; - return { - ...conv, - messages: conv.messages.slice(0, messageIndex + 1), - updatedAt: nextUpdatedAt(conv.updatedAt), - }; - }), - })); - }, + ...createMessageMutationActions({ + updateConversations: update => + set(state => ({ conversations: update(state.conversations) })), + getConversationMessages: conversationId => + get().getConversationMessages(conversationId), + }), - startStreaming: (conversationId) => { + startStreaming: conversationId => { set({ streamingForConversationId: conversationId, streamingMessage: '', @@ -240,40 +275,57 @@ export const useChatStore = create()( }); }, - setStreamingMessage: (content) => { + setStreamingMessage: content => { set({ streamingMessage: content }); }, - appendToStreamingMessage: (token) => { - set((state) => ({ - streamingMessage: stripStreamingControlTokens(state.streamingMessage + token), + appendToStreamingMessage: token => { + set(state => ({ + streamingMessage: stripStreamingControlTokens( + state.streamingMessage + token, + ), isStreaming: true, isThinking: false, })); // Feed only the ANSWER to pro audio for real-time sentence-by-sentence // TTS (never the reasoning) — no-op unless voice mode + engine ready; // free builds register nothing. - callHook(HOOKS.audioOnStreamingToken, speakableStreamingAnswer(get().streamingMessage, get().streamingReasoningContent)); + callHook( + HOOKS.audioOnStreamingToken, + speakableStreamingAnswer( + get().streamingMessage, + get().streamingReasoningContent, + ), + ); }, - appendToStreamingReasoningContent: (token) => { - set((state) => ({ + appendToStreamingReasoningContent: token => { + set(state => ({ streamingReasoningContent: state.streamingReasoningContent + token, isStreaming: true, isThinking: false, })); }, - setIsStreaming: (streaming) => { + setIsStreaming: streaming => { set({ isStreaming: streaming, isThinking: false }); }, - setIsThinking: (thinking) => { + setIsThinking: thinking => { set({ isThinking: thinking }); }, - finalizeStreamingMessage: (conversationId, generationTimeMs, generationMeta) => { - const { streamingMessage, streamingReasoningContent, streamingForConversationId, addMessage } = get(); + finalizeStreamingMessage: ( + conversationId, + generationTimeMs, + generationMeta, + ) => { + const { + streamingMessage, + streamingReasoningContent, + streamingForConversationId, + addMessage, + } = get(); // Parse ONCE at this boundary through the single shared parser (SoC §A / DR1): // split the raw stream into reasoning + a clean answer. The answer is stripped of @@ -283,7 +335,10 @@ export const useChatStore = create()( const parsed = parseModelOutput(streamingMessage, streamReasoning); const reasoningContent = parsed.reasoning ?? undefined; const sanitizedMessage = parsed.answer; - if (streamingForConversationId === conversationId && (sanitizedMessage || reasoningContent)) { + if ( + streamingForConversationId === conversationId && + (sanitizedMessage || reasoningContent) + ) { addMessage(conversationId, { role: 'assistant', content: sanitizedMessage, @@ -323,8 +378,8 @@ export const useChatStore = create()( }, updateCompactionState: (conversationId, summary, cutoffMessageId) => { - set((state) => ({ - conversations: state.conversations.map((conv) => + set(state => ({ + conversations: state.conversations.map(conv => conv.id === conversationId ? { ...conv, @@ -332,17 +387,34 @@ export const useChatStore = create()( compactionCutoffMessageId: cutoffMessageId, updatedAt: nextUpdatedAt(conv.updatedAt), } - : conv + : conv, ), })); }, clearAllConversations: () => { + const removed = get().conversations; set({ conversations: [], activeConversationId: null }); + for (const conversation of removed) { + for (const message of conversation.messages) { + if (message.uuid) + emitSyncMutation( + deleteSyncMutation(CORE_SYNC_ENTITIES.message, message.uuid), + ); + } + emitSyncMutation( + deleteSyncMutation( + CORE_SYNC_ENTITIES.conversation, + conversation.id, + ), + ); + } }, - getConversationMessages: (conversationId) => { - const conversation = get().conversations.find((c) => c.id === conversationId); + getConversationMessages: conversationId => { + const conversation = get().conversations.find( + c => c.id === conversationId, + ); return conversation?.messages || []; }, }), @@ -351,10 +423,10 @@ export const useChatStore = create()( storage: createJSONStorage(() => AsyncStorage), version: CHAT_STORAGE_VERSION, migrate: migratePersistedChatState, - partialize: (state) => ({ + partialize: state => ({ conversations: state.conversations, activeConversationId: state.activeConversationId, }), - } - ) + }, + ), ); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 936e523d8..6cc13b841 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -6,13 +6,24 @@ import { generateId } from '../utils/generateId'; import { ragService } from '../services/rag'; import { useChatStore } from './chatStore'; import logger from '../utils/logger'; +import { + CORE_SYNC_ENTITIES, + deleteSyncMutation, + emitSyncMutation, + projectPutMutation, +} from '../services/sync/mutation'; interface ProjectState { projects: Project[]; // Actions - createProject: (project: Omit) => Project; - updateProject: (id: string, updates: Partial>) => void; + createProject: ( + project: Omit, + ) => Project; + updateProject: ( + id: string, + updates: Partial>, + ) => void; deleteProject: (id: string) => void; getProject: (id: string) => Project | undefined; duplicateProject: (id: string) => Project | null; @@ -24,7 +35,8 @@ const DEFAULT_PROJECTS: Project[] = [ id: 'default-assistant', name: 'General Assistant', description: 'A helpful, concise AI assistant for everyday tasks', - systemPrompt: 'You are a helpful AI assistant running locally on the user\'s device. Be concise and helpful. Focus on providing accurate information and solving the user\'s problems efficiently.', + systemPrompt: + "You are a helpful AI assistant running locally on the user's device. Be concise and helpful. Focus on providing accurate information and solving the user's problems efficiently.", icon: '#6366F1', // Indigo createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -88,7 +100,7 @@ export const useProjectStore = create()( (set, get) => ({ projects: DEFAULT_PROJECTS, - createProject: (projectData) => { + createProject: projectData => { const project: Project = { ...projectData, id: generateId(), @@ -96,40 +108,51 @@ export const useProjectStore = create()( updatedAt: new Date().toISOString(), }; - set((state) => ({ + set(state => ({ projects: [...state.projects, project], })); + emitSyncMutation(projectPutMutation(project)); return project; }, updateProject: (id, updates) => { - set((state) => ({ - projects: state.projects.map((project) => + set(state => ({ + projects: state.projects.map(project => project.id === id ? { ...project, ...updates, updatedAt: new Date().toISOString() } - : project + : project, ), })); + const project = get().projects.find(candidate => candidate.id === id); + if (project) emitSyncMutation(projectPutMutation(project)); }, - deleteProject: (id) => { - ragService.deleteProjectDocuments(id).catch((err) => logger.error(`Failed to delete RAG documents for project ${id}`, err)); + deleteProject: id => { + ragService + .deleteProjectDocuments(id) + .catch(err => + logger.error( + `Failed to delete RAG documents for project ${id}`, + err, + ), + ); // Cascade: unfile the project's chats so none is left pointing at a project that // no longer exists (a dangling projectId isn't re-filable and still tripped the // KB-tool injection). The project store owns "what happens on delete" (like RAG // cleanup above); chatStore owns the conversation mutation. useChatStore.getState().unfileConversationsForProject(id); - set((state) => ({ - projects: state.projects.filter((project) => project.id !== id), + set(state => ({ + projects: state.projects.filter(project => project.id !== id), })); + emitSyncMutation(deleteSyncMutation(CORE_SYNC_ENTITIES.project, id)); }, - getProject: (id) => { - return get().projects.find((project) => project.id === id); + getProject: id => { + return get().projects.find(project => project.id === id); }, - duplicateProject: (id) => { + duplicateProject: id => { const original = get().getProject(id); if (!original) return null; @@ -141,9 +164,10 @@ export const useProjectStore = create()( updatedAt: new Date().toISOString(), }; - set((state) => ({ + set(state => ({ projects: [...state.projects, duplicate], })); + emitSyncMutation(projectPutMutation(duplicate)); return duplicate; }, @@ -151,6 +175,6 @@ export const useProjectStore = create()( { name: 'local-llm-project-storage', storage: createJSONStorage(() => AsyncStorage), - } - ) + }, + ), ); From f14dd83635b3223895dfcebd25a7ee4e18755aa7 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:36:51 +0530 Subject: [PATCH 020/332] test(sync): cover mobile state persistence --- __tests__/pro/sync/statePersistence.test.ts | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 __tests__/pro/sync/statePersistence.test.ts diff --git a/__tests__/pro/sync/statePersistence.test.ts b/__tests__/pro/sync/statePersistence.test.ts new file mode 100644 index 000000000..4ab7b180f --- /dev/null +++ b/__tests__/pro/sync/statePersistence.test.ts @@ -0,0 +1,51 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { Op } from '@offgrid/sync'; +import { StateOpStore } from '../../../pro/sync/stateOpStore'; +import { SyncPreferencesStore } from '../../../pro/sync/syncPreferences'; + +const OP: Op = { + opId: 'phone-op-1', + entity: 'project', + entityId: 'project-1', + kind: 'put', + fields: { name: 'Field Notes' }, + lamport: 1, + deviceId: 'phone-1', + ts: 1, +}; + +describe('mobile state sync persistence', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + jest.restoreAllMocks(); + }); + + it('persists each accepted operation once across relaunch', async () => { + const store = new StateOpStore(); + await store.load(); + store.append(OP); + store.append(OP); + await store.flush(); + + await expect(new StateOpStore().load()).resolves.toEqual([OP]); + }); + + it('starts with no operations when the persisted payload is corrupt', async () => { + await AsyncStorage.setItem('offgrid-sync-state-ops-v1', '{broken'); + + await expect(new StateOpStore().load()).resolves.toEqual([]); + }); + + it('blocks sharing immediately and restores the persisted choice if saving fails', async () => { + const preferences = new SyncPreferencesStore(); + await preferences.load(); + jest + .spyOn(AsyncStorage, 'setItem') + .mockRejectedValueOnce(new Error('storage unavailable')); + + const saving = preferences.set('projects', false); + expect(preferences.enabled('project')).toBe(false); + await expect(saving).rejects.toThrow('storage unavailable'); + expect(preferences.enabled('project')).toBe(true); + }); +}); From 29bc24dcc7541f2ae14570a7ea5ef618df607c5d Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:37:21 +0530 Subject: [PATCH 021/332] test(sync): keep state coverage integration-level --- __tests__/pro/sync/statePersistence.test.ts | 51 --------------------- 1 file changed, 51 deletions(-) delete mode 100644 __tests__/pro/sync/statePersistence.test.ts diff --git a/__tests__/pro/sync/statePersistence.test.ts b/__tests__/pro/sync/statePersistence.test.ts deleted file mode 100644 index 4ab7b180f..000000000 --- a/__tests__/pro/sync/statePersistence.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { Op } from '@offgrid/sync'; -import { StateOpStore } from '../../../pro/sync/stateOpStore'; -import { SyncPreferencesStore } from '../../../pro/sync/syncPreferences'; - -const OP: Op = { - opId: 'phone-op-1', - entity: 'project', - entityId: 'project-1', - kind: 'put', - fields: { name: 'Field Notes' }, - lamport: 1, - deviceId: 'phone-1', - ts: 1, -}; - -describe('mobile state sync persistence', () => { - beforeEach(async () => { - await AsyncStorage.clear(); - jest.restoreAllMocks(); - }); - - it('persists each accepted operation once across relaunch', async () => { - const store = new StateOpStore(); - await store.load(); - store.append(OP); - store.append(OP); - await store.flush(); - - await expect(new StateOpStore().load()).resolves.toEqual([OP]); - }); - - it('starts with no operations when the persisted payload is corrupt', async () => { - await AsyncStorage.setItem('offgrid-sync-state-ops-v1', '{broken'); - - await expect(new StateOpStore().load()).resolves.toEqual([]); - }); - - it('blocks sharing immediately and restores the persisted choice if saving fails', async () => { - const preferences = new SyncPreferencesStore(); - await preferences.load(); - jest - .spyOn(AsyncStorage, 'setItem') - .mockRejectedValueOnce(new Error('storage unavailable')); - - const saving = preferences.set('projects', false); - expect(preferences.enabled('project')).toBe(false); - await expect(saving).rejects.toThrow('storage unavailable'); - expect(preferences.enabled('project')).toBe(true); - }); -}); From 5ff36334a59d95fd2db790f4b2b1ef126f23548b Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:38:21 +0530 Subject: [PATCH 022/332] docs(sync): record mobile state replication --- docs/SYNC_MOBILE_PROGRESS.md | 39 +++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index c9238b722..7ec2c6039 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -4,27 +4,32 @@ Living log of the **mobile** side of `@offgrid/sync` integration so the desktop coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as work lands. ## Shared contract (both apps consume the same public package → converge with no rework) + - **Package:** `@offgrid/sync` (`shared/packages/sync`), consumed by mobile via `file:` dep. Never forked. Wire protocol, NaCl crypto, op-log schema, and mDNS service type all live in the package. - **mDNS service type:** `_offgrid._tcp.local` (mobile advertises + browses this; must match desktop). - **Transport:** length-prefixed NaCl-encrypted frames over TCP (ephemeral bound port, advertised over mDNS TXT). App messages ride the paired channel via `engine.sendApp(deviceId, channel, data)`. -- **Feature gating:** the mobile Sync *experience* is Pro; the engine is public. +- **Feature gating:** the mobile Sync _experience_ is Pro; the engine is public. ## Devices (mobile side, on hand for real 2-device tests) + - **iPhone 17 Pro Max** — hardware UDID `00008150-000225103CD8C01C` ("Mac's iPhone"), iOS 26.5.2. Driven via WebDriverAgent + `devicectl`; read the current WDA URL from `launchWda.ts`. - **Android** — `adb` id `505b53a0` (OnePlus). Driven via `adb`. - Both on the same LAN as the laptop. Can pair phone↔phone and phone↔desktop. ## UUID coordination (agreed) + - Synced entities keyed by **UUID** (conversations, projects, settings). **Mobile owns adding stable UUIDs on its side** (chatStore conversations, projects, per-message UUIDs). Message-content sync needs a per-message UUID (desktop is adding a UUID column on `rag_messages`; mobile adds the equivalent to its message store). Conflict resolution is the engine's LWW — not reimplemented. ## Phase progress + ### Phase 0 - Foundation (transport live) - COMPLETE + - [x] Consume `@offgrid/sync` file: dep + pure-JS crypto deps (tweetnacl, tweetnacl-util, js-sha512). - [x] Metro resolves the package + `./rn` `./rn-discovery` `./portable` (watchFolder + subpath aliases; not global package-exports). Bundles on both platforms. @@ -46,6 +51,7 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as restarts without asking for the pairing code again. ### Phase 0.5 — Pro experience + licensed devices — COMPLETE + - [x] Sync UI and lifecycle orchestration live in the private Pro package, registered through the existing core screen/settings registries. Core owns only reusable native transport glue. - [x] Settings → Sync exposes discoverability, pairing, peer state, and one licensed-device surface. @@ -56,9 +62,31 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - [x] Boundary tests cover the real Keygen HTTP sequence (five active → delete oldest → activate current), and a rendered AppNavigator journey covers Settings → Sync → device deactivation. -### Phase 1 — State sync (chats/projects/settings) — NOT STARTED (needs mobile UUID migration first) +### Phase 1 - State sync (chats/projects/settings) - IN PROGRESS + +- [x] Stable RFC 4122 UUIDs for new conversations, projects, and messages. The persisted chat + migration backfills legacy message UUIDs once and preserves them across relaunch. +- [x] Canonical mobile mutations reuse the desktop wire entities and field names for + `conversation`, `message`, and `project`; core data owners emit through one optional Pro hook. +- [x] Pro persists the op-log and runs the shared `StateSync` anti-entropy protocol over the + encrypted `state` app channel. Existing local records backfill when the service starts. +- [x] Settings to Sync exposes Chats and Projects sharing controls. A disabled category is filtered + before it can leave the phone; re-enabling it backfills current records. +- [x] One rendered AppNavigator journey proves a pre-pair desktop project/chat/message arrives and + becomes visible, a project created through the phone UI stays local while Projects sharing is + off, and enabling Projects sends it over the real loopback transport. +- [x] Focused persistence tests cover op de-duplication, corrupt op storage, and privacy-setting + rollback when AsyncStorage rejects a write. +- [ ] Sync `model_setting` records; no mobile settings owner is wired yet. +- [ ] Prove offline same-record edits converge to the engine's LWW winner and rehydrate without + duplicate ops through a focused rendered/relaunch journey. +- [ ] Resolve project-delete semantics before mobile emits project tombstones. Mobile deletion + unfiles chats; desktop currently deletes the project's conversations and messages. +- [ ] Verify conversation/project/message convergence with the real desktop app on physical iOS + and Android devices. ### Phase 2 - Model transfer - IN PROGRESS + - [x] Receive one text GGUF over the encrypted paired channel. Mobile writes to a resumable `.part` file, verifies byte count, checksum, and the `GGUF` header, then registers the model. - [x] Invalid files are rejected and both the final file and partial file are removed. @@ -72,14 +100,19 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as ### Phase 3 — Ambient sharing — NOT STARTED ## Security note (logged for GA) + Crypto is sound (NaCl secretbox authenticated encryption; passphrase never on the wire; LAN-only). Hardening item before GA: the KDF is a hand-rolled iterated SHA-512 ("PBKDF2-like") — pair with a high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphrase isn't brute-forceable. ## Branch -`feat/sync-integration-phase0` (mobile). Commits are small + each has tests + hygiene. + +`feat/sync-integration-phase0` (mobile). State-sync checkpoints: +`f50dea2c` (stable IDs), Pro `93cf8ce8`, core `857096a0`, and `f14dd836` (persistence tests). +Commits are small + each has tests + hygiene. ## Prior-art decision (2026-07-26) + `feature/sync` (based 2026-07-09, now **708 commits behind main**) has a full prior mobile Track-A implementation: `src/services/backup/{backupArchive,backupData,backupFiles,backupIo, backupService,types}.ts` (the four-port adapters over `@offgrid/sync/portable` — CURRENT API, From 8b1a958fbb243bacbe12ba4bf5e0593d77ba334f Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:40:16 +0530 Subject: [PATCH 023/332] docs(sync): align state test evidence --- docs/SYNC_MOBILE_PROGRESS.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 7ec2c6039..a095762bf 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -75,8 +75,6 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - [x] One rendered AppNavigator journey proves a pre-pair desktop project/chat/message arrives and becomes visible, a project created through the phone UI stays local while Projects sharing is off, and enabling Projects sends it over the real loopback transport. -- [x] Focused persistence tests cover op de-duplication, corrupt op storage, and privacy-setting - rollback when AsyncStorage rejects a write. - [ ] Sync `model_setting` records; no mobile settings owner is wired yet. - [ ] Prove offline same-record edits converge to the engine's LWW winner and rehydrate without duplicate ops through a focused rendered/relaunch journey. @@ -108,8 +106,8 @@ high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphra ## Branch `feat/sync-integration-phase0` (mobile). State-sync checkpoints: -`f50dea2c` (stable IDs), Pro `93cf8ce8`, core `857096a0`, and `f14dd836` (persistence tests). -Commits are small + each has tests + hygiene. +`f50dea2c` (stable IDs), Pro `93cf8ce8`, and core `857096a0`. +Commits are small + each has rendered integration coverage + hygiene. ## Prior-art decision (2026-07-26) From de88830a1e240b2627db8621ce3d05e98e4397fb Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:47:05 +0530 Subject: [PATCH 024/332] fix(sync): keep fallback entity ids unique --- __tests__/unit/utils/generateId.test.ts | 19 +++++++++++-------- src/utils/generateId.ts | 10 ++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/__tests__/unit/utils/generateId.test.ts b/__tests__/unit/utils/generateId.test.ts index 61c64411e..87decf5af 100644 --- a/__tests__/unit/utils/generateId.test.ts +++ b/__tests__/unit/utils/generateId.test.ts @@ -4,11 +4,14 @@ import { generateId, generateRandomSeed } from '../../../src/utils/generateId'; +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + describe('generateId', () => { describe('with crypto available', () => { - it('should generate a unique ID', () => { + it('should generate a UUID identity', () => { const id = generateId(); - expect(id).toMatch(/^\d+-[a-z0-9]+$/); + expect(id).toMatch(UUID_V4); }); it('should generate different IDs on subsequent calls', () => { @@ -33,17 +36,17 @@ describe('generateId', () => { } }); - it('should generate ID using fallback when crypto is not available', () => { + it('should generate a UUID identity when crypto is not available', () => { const id = generateId(); - expect(id).toMatch(/^\d+-[a-z0-9]+$/); + expect(id).toMatch(UUID_V4); }); it('should generate different IDs using fallback', () => { const id1 = generateId(); const id2 = generateId(); - // IDs might be same if called in same millisecond, but format should be valid - expect(id1).toMatch(/^\d+-[a-z0-9]+$/); - expect(id2).toMatch(/^\d+-[a-z0-9]+$/); + expect(id1).toMatch(UUID_V4); + expect(id2).toMatch(UUID_V4); + expect(id1).not.toBe(id2); }); }); }); @@ -99,4 +102,4 @@ describe('generateRandomSeed', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/src/utils/generateId.ts b/src/utils/generateId.ts index 39ed7ed7f..28f8a3fe1 100644 --- a/src/utils/generateId.ts +++ b/src/utils/generateId.ts @@ -10,9 +10,9 @@ function randomBytes(): Uint8Array { // App bootstrap installs react-native-get-random-values. This fallback keeps // isolated JS environments functional without weakening the persisted format. fallbackSequence += 1; - let seed = Date.now() + fallbackSequence; + let seed = ((Date.now() >>> 0) ^ fallbackSequence) >>> 0; for (let index = 0; index < bytes.length; index += 1) { - seed = (seed * 1664525 + 1013904223) % 4294967296; // NOSONAR + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; // NOSONAR bytes[index] = seed % 256; } return bytes; @@ -23,7 +23,7 @@ export function generateId(): string { const bytes = randomBytes(); bytes[6] = (bytes[6] % 16) + 64; bytes[8] = (bytes[8] % 64) + 128; - const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')); return [ hex.slice(0, 4).join(''), hex.slice(4, 6).join(''), @@ -43,5 +43,7 @@ export function generateRandomSeed(): number { return a[0] % 2147483647; } // Fallback for environments without crypto API - return Math.floor(((Date.now() * 9301 + 49297) % 233280) / 233280 * 2147483647); // NOSONAR + return Math.floor( + (((Date.now() * 9301 + 49297) % 233280) / 233280) * 2147483647, + ); // NOSONAR } From fcc7ba84627f774152100103e0fb04ef22255f06 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:51:44 +0530 Subject: [PATCH 025/332] docs(sync): record conflict and deletion coverage --- docs/SYNC_MOBILE_PROGRESS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index a095762bf..7ae3f874f 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -76,10 +76,10 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as becomes visible, a project created through the phone UI stays local while Projects sharing is off, and enabling Projects sends it over the real loopback transport. - [ ] Sync `model_setting` records; no mobile settings owner is wired yet. -- [ ] Prove offline same-record edits converge to the engine's LWW winner and rehydrate without - duplicate ops through a focused rendered/relaunch journey. -- [ ] Resolve project-delete semantics before mobile emits project tombstones. Mobile deletion - unfiles chats; desktop currently deletes the project's conversations and messages. +- [x] The rendered relaunch journey proves offline same-record edits converge to the engine's LWW + winner, then rehydrate without duplicate ops. +- [x] Mobile emits project tombstones and preserves its existing delete behavior: deleting a + project unfiles its chats. Remote project tombstones apply the same behavior locally. - [ ] Verify conversation/project/message convergence with the real desktop app on physical iOS and Android devices. From b8abb869cfa799143f81812f1e96268725e9818a Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:53:53 +0530 Subject: [PATCH 026/332] fix(sync): withhold unsafe project tombstones --- docs/SYNC_MOBILE_PROGRESS.md | 8 ++++---- src/stores/projectStore.ts | 3 --- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 7ae3f874f..a095762bf 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -76,10 +76,10 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as becomes visible, a project created through the phone UI stays local while Projects sharing is off, and enabling Projects sends it over the real loopback transport. - [ ] Sync `model_setting` records; no mobile settings owner is wired yet. -- [x] The rendered relaunch journey proves offline same-record edits converge to the engine's LWW - winner, then rehydrate without duplicate ops. -- [x] Mobile emits project tombstones and preserves its existing delete behavior: deleting a - project unfiles its chats. Remote project tombstones apply the same behavior locally. +- [ ] Prove offline same-record edits converge to the engine's LWW winner and rehydrate without + duplicate ops through a focused rendered/relaunch journey. +- [ ] Resolve project-delete semantics before mobile emits project tombstones. Mobile deletion + unfiles chats; desktop currently deletes the project's conversations and messages. - [ ] Verify conversation/project/message convergence with the real desktop app on physical iOS and Android devices. diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 6cc13b841..881432e62 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -7,8 +7,6 @@ import { ragService } from '../services/rag'; import { useChatStore } from './chatStore'; import logger from '../utils/logger'; import { - CORE_SYNC_ENTITIES, - deleteSyncMutation, emitSyncMutation, projectPutMutation, } from '../services/sync/mutation'; @@ -145,7 +143,6 @@ export const useProjectStore = create()( set(state => ({ projects: state.projects.filter(project => project.id !== id), })); - emitSyncMutation(deleteSyncMutation(CORE_SYNC_ENTITIES.project, id)); }, getProject: id => { From 78df85ba1bc54467211c61a129eca450430c04c5 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:59:00 +0530 Subject: [PATCH 027/332] feat(sync): sync mobile model settings --- .../pro/sync/stateSync.integration.test.tsx | 41 +++++++ .../unit/sync/modelSettingsMutation.test.ts | 73 +++++++++++++ pro | 2 +- src/screens/ModelSettingsScreen/index.tsx | 7 +- src/services/sync/mutation.ts | 100 ++++++++++++++++++ src/stores/appStore.ts | 33 +++++- 6 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 __tests__/unit/sync/modelSettingsMutation.test.ts diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx index 2edc4bd74..c32636d4f 100644 --- a/__tests__/pro/sync/stateSync.integration.test.tsx +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -177,6 +177,11 @@ describe('Pro mobile state sync journey', () => { context: null, created_at: createdAt, }); + for (let revision = 0; revision < 20; revision += 1) { + remoteLog.record(CORE_SYNC_ENTITIES.modelSetting, 'temperature', 'put', { + value_json: revision === 19 ? '0.55' : '0.5', + }); + } await remote.engine.start(0); remoteDevice.port = remote.transport.boundPort ?? 0; @@ -253,5 +258,41 @@ describe('Pro mobile state sync journey', () => { ), ).toMatchObject({ name: 'Phone Notes' }), ); + + fireEvent(ui.getByTestId('sync-settings-toggle'), 'valueChange', false); + await waitFor(() => + expect(stateSyncService.preferences().settings).toBe(false), + ); + fireEvent.press(ui.getByLabelText('Back')); + fireEvent.press(ui.getByText('Model Settings')); + fireEvent.press( + await waitFor(() => ui!.getByTestId('text-generation-accordion')), + ); + await waitFor(() => + expect(ui!.getByTestId('llama-temperature-value').props.children).toBe( + '0.55', + ), + ); + fireEvent( + ui.getByTestId('llama-temperature-slider'), + 'slidingComplete', + 1.25, + ); + expect( + remoteRecords.records.get( + `${CORE_SYNC_ENTITIES.modelSetting}:temperature`, + ), + ).toMatchObject({ value_json: '0.55' }); + + fireEvent.press(ui.getByLabelText('Back')); + fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); + fireEvent(ui.getByTestId('sync-settings-toggle'), 'valueChange', true); + await waitFor(() => + expect( + remoteRecords.records.get( + `${CORE_SYNC_ENTITIES.modelSetting}:temperature`, + ), + ).toMatchObject({ value_json: '1.25' }), + ); }); }); diff --git a/__tests__/unit/sync/modelSettingsMutation.test.ts b/__tests__/unit/sync/modelSettingsMutation.test.ts new file mode 100644 index 000000000..959000122 --- /dev/null +++ b/__tests__/unit/sync/modelSettingsMutation.test.ts @@ -0,0 +1,73 @@ +import { + CORE_SYNC_ENTITIES, + mobileModelSettingPatch, + modelSettingMutations, +} from '../../../src/services/sync/mutation'; + +describe('model settings sync contract', () => { + it('round-trips every setting shared by desktop and mobile through canonical wire keys', () => { + const localSettings = { + temperature: 0.65, + contextLength: 16_384, + topP: 0.92, + repeatPenalty: 1.15, + maxTokens: 2_048, + systemPrompt: 'Answer from local context.', + cacheType: 'q4_0', + flashAttn: true, + gpuLayers: 99, + nThreads: 8, + nBatch: 512, + }; + const expectedWireToLocal = { + temperature: 'temperature', + ctxSize: 'contextLength', + topP: 'topP', + repeatPenalty: 'repeatPenalty', + maxTokens: 'maxTokens', + systemPrompt: 'systemPrompt', + kvCacheType: 'cacheType', + flashAttn: 'flashAttn', + gpuLayers: 'gpuLayers', + threads: 'nThreads', + batchSize: 'nBatch', + } as const; + + const mutations = modelSettingMutations({}, localSettings); + + expect(mutations).toHaveLength(Object.keys(expectedWireToLocal).length); + for (const mutation of mutations) { + const localKey = + expectedWireToLocal[ + mutation.entityId as keyof typeof expectedWireToLocal + ]; + expect(mutation).toMatchObject({ + entity: CORE_SYNC_ENTITIES.modelSetting, + kind: 'put', + }); + expect( + mobileModelSettingPatch(mutation.entityId, mutation.fields ?? {}), + ).toEqual({ [localKey]: localSettings[localKey] }); + } + }); + + it('ignores unsupported keys and malformed or unsafe peer values', () => { + expect( + mobileModelSettingPatch('performanceMode', { + value_json: '"extreme"', + }), + ).toBeNull(); + expect( + mobileModelSettingPatch('temperature', { value_json: 'not-json' }), + ).toBeNull(); + expect( + mobileModelSettingPatch('temperature', { value_json: '3' }), + ).toBeNull(); + expect( + mobileModelSettingPatch('ctxSize', { value_json: '1024.5' }), + ).toBeNull(); + expect( + mobileModelSettingPatch('kvCacheType', { value_json: '"unsafe"' }), + ).toBeNull(); + }); +}); diff --git a/pro b/pro index 93cf8ce82..07e06ee24 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 93cf8ce828208ac976022f15c316e37cb8ce77ad +Subproject commit 07e06ee241c6a3c6514183d2f7d9432d74bce961 diff --git a/src/screens/ModelSettingsScreen/index.tsx b/src/screens/ModelSettingsScreen/index.tsx index 58c61dbf4..bd7b312bf 100644 --- a/src/screens/ModelSettingsScreen/index.tsx +++ b/src/screens/ModelSettingsScreen/index.tsx @@ -67,7 +67,12 @@ export const ModelSettingsScreen: React.FC = () => { return ( - navigation.goBack()}> + navigation.goBack()} + accessibilityRole="button" + accessibilityLabel="Back" + > Model Settings diff --git a/src/services/sync/mutation.ts b/src/services/sync/mutation.ts index a27333a0a..94ad74628 100644 --- a/src/services/sync/mutation.ts +++ b/src/services/sync/mutation.ts @@ -19,6 +19,97 @@ export interface SyncMutation { fields?: Record; } +interface ModelSettingDescriptor { + localKey: string; + accepts: (value: unknown) => boolean; +} + +const finiteInRange = + (minimum: number, maximum: number) => + (value: unknown): boolean => + typeof value === 'number' && + Number.isFinite(value) && + value >= minimum && + value <= maximum; + +const integerInRange = + (minimum: number, maximum: number) => + (value: unknown): boolean => + Number.isInteger(value) && + (value as number) >= minimum && + (value as number) <= maximum; + +/** Desktop wire keys mapped once to the equivalent mobile setting owner keys. */ +const MODEL_SETTING_DESCRIPTORS: Readonly< + Record +> = { + temperature: { localKey: 'temperature', accepts: finiteInRange(0, 2) }, + ctxSize: { + localKey: 'contextLength', + accepts: integerInRange(512, 1_048_576), + }, + topP: { localKey: 'topP', accepts: finiteInRange(0, 1) }, + repeatPenalty: { + localKey: 'repeatPenalty', + accepts: finiteInRange(0, 2), + }, + maxTokens: { localKey: 'maxTokens', accepts: integerInRange(1, 1_048_576) }, + systemPrompt: { + localKey: 'systemPrompt', + accepts: value => typeof value === 'string', + }, + kvCacheType: { + localKey: 'cacheType', + accepts: value => value === 'f16' || value === 'q8_0' || value === 'q4_0', + }, + flashAttn: { + localKey: 'flashAttn', + accepts: value => typeof value === 'boolean', + }, + gpuLayers: { localKey: 'gpuLayers', accepts: integerInRange(-1, 999) }, + threads: { localKey: 'nThreads', accepts: integerInRange(0, 256) }, + batchSize: { localKey: 'nBatch', accepts: integerInRange(1, 65_536) }, +}; + +export function modelSettingMutations( + before: Record, + after: Record, +): SyncMutation[] { + const mutations: SyncMutation[] = []; + for (const [wireKey, descriptor] of Object.entries( + MODEL_SETTING_DESCRIPTORS, + )) { + const value = after[descriptor.localKey]; + if ( + value === undefined || + Object.is(value, before[descriptor.localKey]) || + !descriptor.accepts(value) + ) + continue; + mutations.push({ + entity: CORE_SYNC_ENTITIES.modelSetting, + entityId: wireKey, + kind: 'put', + fields: { value_json: JSON.stringify(value) }, + }); + } + return mutations; +} + +export function mobileModelSettingPatch( + wireKey: string, + fields: Record, +): Record | null { + const descriptor = MODEL_SETTING_DESCRIPTORS[wireKey]; + if (!descriptor || typeof fields.value_json !== 'string') return null; + try { + const value = JSON.parse(fields.value_json) as unknown; + return descriptor.accepts(value) ? { [descriptor.localKey]: value } : null; + } catch { + return null; + } +} + export function conversationPutMutation( conversation: Conversation, ): SyncMutation { @@ -87,3 +178,12 @@ export function emitSyncMutation(mutation: SyncMutation | null): void { // Sync is additive. A Pro integration failure must not roll back local data. } } + +export function emitChangedModelSettings( + before: Record, + after: Record, +): void { + for (const mutation of modelSettingMutations(before, after)) { + emitSyncMutation(mutation); + } +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 2d2a7496c..51d35c6cf 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -3,6 +3,10 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import { Platform } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { DeviceInfo, DownloadedModel, ModelRecommendation, ONNXImageModel, ImageGenerationMode, AutoDetectMethod, CacheType, InferenceBackend, INFERENCE_BACKENDS, LiteRTBackend, GeneratedImage } from '../types'; +import { + emitChangedModelSettings, + mobileModelSettingPatch, +} from '../services/sync/mutation'; function isUnknownLike(value: string): boolean { const normalized = value.trim().toLowerCase(); @@ -129,6 +133,10 @@ interface AppState { setModelMaxContext: (ctx: number | null) => void; settings: AppSettings; updateSettings: (settings: Partial) => void; + applySyncedModelSetting: ( + wireKey: string, + fields: Record, + ) => void; resetSettings: () => void; downloadedImageModels: ONNXImageModel[]; activeImageModelId: string | null; @@ -345,11 +353,26 @@ export const useAppStore = create()( modelMaxContext: null, setModelMaxContext: (ctx) => set({ modelMaxContext: ctx }), settings: { ...DEFAULT_SETTINGS }, - updateSettings: (newSettings) => - set((state) => ({ - settings: { ...state.settings, ...newSettings }, - })), - resetSettings: () => set({ settings: { ...DEFAULT_SETTINGS } }), + updateSettings: (newSettings) => { + const before = get().settings; + const after = { ...before, ...newSettings }; + set({ settings: after }); + emitChangedModelSettings(before, after); + }, + applySyncedModelSetting: (wireKey, fields) => { + const patch = mobileModelSettingPatch(wireKey, fields); + if (patch) { + set((state) => ({ + settings: { ...state.settings, ...(patch as Partial) }, + })); + } + }, + resetSettings: () => { + const before = get().settings; + const after = { ...DEFAULT_SETTINGS }; + set({ settings: after }); + emitChangedModelSettings(before, after); + }, // Image models (ONNX-based) downloadedImageModels: [], activeImageModelId: null, From 0accddc0f2e8ade000a87650288ff6e7c466ca88 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 09:59:27 +0530 Subject: [PATCH 028/332] docs(sync): record mobile settings coverage --- docs/SYNC_MOBILE_PROGRESS.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index a095762bf..dd9dbf9aa 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -75,7 +75,13 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - [x] One rendered AppNavigator journey proves a pre-pair desktop project/chat/message arrives and becomes visible, a project created through the phone UI stays local while Projects sharing is off, and enabling Projects sends it over the real loopback transport. -- [ ] Sync `model_setting` records; no mobile settings owner is wired yet. +- [x] Sync the shared `model_setting` keys through the canonical desktop wire names. The mobile + app store remains the settings owner; inbound values are validated and applied without + rebroadcast, while local changes and resets emit through the optional Pro hook. +- [x] The rendered AppNavigator journey proves an inbound desktop temperature becomes visible in + Model Settings, a phone edit stays local while Model settings sharing is off, and re-enabling + the category backfills the current value. A contract test round-trips every supported + desktop↔mobile key and rejects malformed or unsafe peer values. - [ ] Prove offline same-record edits converge to the engine's LWW winner and rehydrate without duplicate ops through a focused rendered/relaunch journey. - [ ] Resolve project-delete semantics before mobile emits project tombstones. Mobile deletion @@ -106,7 +112,8 @@ high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphra ## Branch `feat/sync-integration-phase0` (mobile). State-sync checkpoints: -`f50dea2c` (stable IDs), Pro `93cf8ce8`, and core `857096a0`. +`f50dea2c` (stable IDs), `b8abb869` (withhold unsafe project tombstones), +Pro `07e06ee2` and core `78df85ba` (model settings). Commits are small + each has rendered integration coverage + hygiene. ## Prior-art decision (2026-07-26) From 6f5e433bb4a4600196838906d175f5edf69943d8 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 10:40:50 +0530 Subject: [PATCH 029/332] test(sync): prove offline convergence on relaunch --- .../pro/sync/stateSync.integration.test.tsx | 83 +++++++++++++++++++ docs/SYNC_MOBILE_PROGRESS.md | 6 +- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx index c32636d4f..3dae36cd8 100644 --- a/__tests__/pro/sync/stateSync.integration.test.tsx +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -294,5 +294,88 @@ describe('Pro mobile state sync journey', () => { ), ).toMatchObject({ value_json: '1.25' }), ); + + await waitFor(() => + expect(remoteLog.size()).toBe(stateSyncService.opCount()), + ); + await remote.engine.stop(); + await waitFor(() => + expect(syncService.connectedDeviceIds()).not.toContain(remoteDevice.id), + ); + + fireEvent.press(ui.getByLabelText('Back')); + fireEvent.press(ui.getByText('Model Settings')); + fireEvent.press( + await waitFor(() => ui!.getByTestId('text-generation-accordion')), + ); + fireEvent( + ui.getByTestId('llama-temperature-slider'), + 'slidingComplete', + 0.75, + ); + remoteLog.record(CORE_SYNC_ENTITIES.modelSetting, 'temperature', 'put', { + value_json: '0.85', + }); + expect(ui.getByTestId('llama-temperature-value').props.children).toBe( + '0.75', + ); + expect( + remoteRecords.records.get( + `${CORE_SYNC_ENTITIES.modelSetting}:temperature`, + ), + ).toMatchObject({ value_json: '0.85' }); + + await remote.engine.start(0); + await remote.engine.pair( + { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, + 'violet-lake-27', + ); + await waitFor(() => + expect(syncService.connectedDeviceIds()).toContain(remoteDevice.id), + ); + + const winningTemperature = + mobile.id > remoteDevice.id + ? { value: '0.75', json: '0.75' } + : { + value: '0.85', + json: '0.85', + }; + await waitFor(() => + expect(ui!.getByTestId('llama-temperature-value').props.children).toBe( + winningTemperature.value, + ), + ); + await waitFor(() => + expect( + remoteRecords.records.get( + `${CORE_SYNC_ENTITIES.modelSetting}:temperature`, + ), + ).toMatchObject({ value_json: winningTemperature.json }), + ); + + const persistedOpCount = stateSyncService.opCount(); + ui.unmount(); + ui = undefined; + await stateSyncService.stop(); + await stateSyncService.start(); + expect(stateSyncService.opCount()).toBe(persistedOpCount); + useAppStore + .getState() + .setDownloadedModels([createDownloadedModel({ engine: 'litert' })]); + + ui = render( + + + , + ); + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(ui.getByText('Model Settings')); + fireEvent.press( + await waitFor(() => ui!.getByTestId('text-generation-accordion')), + ); + expect(ui.getByTestId('llama-temperature-value').props.children).toBe( + winningTemperature.value, + ); }); }); diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index dd9dbf9aa..b4cb98e9f 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -82,8 +82,10 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as Model Settings, a phone edit stays local while Model settings sharing is off, and re-enabling the category backfills the current value. A contract test round-trips every supported desktop↔mobile key and rejects malformed or unsafe peer values. -- [ ] Prove offline same-record edits converge to the engine's LWW winner and rehydrate without - duplicate ops through a focused rendered/relaunch journey. +- [x] The same rendered journey disconnects peers with identical histories, makes one temperature + edit on each side at the same Lamport, reconnects, and proves both sides select the shared + engine's higher-device-ID LWW winner. Restarting the mobile state service preserves the op + count, and remounting AppNavigator shows the winning value without duplicate backfill ops. - [ ] Resolve project-delete semantics before mobile emits project tombstones. Mobile deletion unfiles chats; desktop currently deletes the project's conversations and messages. - [ ] Verify conversation/project/message convergence with the real desktop app on physical iOS From 69f16ccb191ce9670ff717616b6f3ee5c2d3e662 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 10:46:01 +0530 Subject: [PATCH 030/332] fix(sync): propagate non-destructive project deletes --- .../pro/sync/stateSync.integration.test.tsx | 26 +++++++++++++++++++ src/stores/projectStore.ts | 9 +++++++ 2 files changed, 35 insertions(+) diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx index 3dae36cd8..a161734c7 100644 --- a/__tests__/pro/sync/stateSync.integration.test.tsx +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -259,6 +259,32 @@ describe('Pro mobile state sync journey', () => { ).toMatchObject({ name: 'Phone Notes' }), ); + fireEvent.press(ui.getByLabelText('Back')); + fireEvent.press(ui.getByTestId('projects-tab')); + fireEvent.press(ui.getByText('Desktop Research')); + fireEvent.press(await waitFor(() => ui!.getByText('Delete Project'))); + fireEvent.press(await waitFor(() => ui!.getByText('Delete'))); + await waitFor(() => + expect( + remoteRecords.records.has( + `${CORE_SYNC_ENTITIES.project}:remote-project`, + ), + ).toBe(false), + ); + expect( + remoteRecords.records.get( + `${CORE_SYNC_ENTITIES.conversation}:remote-conversation`, + ), + ).toMatchObject({ project_id: null }); + expect( + remoteRecords.records.get(`${CORE_SYNC_ENTITIES.message}:remote-message`), + ).toMatchObject({ content: 'Bring the field notes' }); + fireEvent.press(ui.getByTestId('chats-tab')); + await waitFor(() => expect(ui!.getByText('Field planning')).toBeTruthy()); + expect(ui.getByText('You: Bring the field notes')).toBeTruthy(); + + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); fireEvent(ui.getByTestId('sync-settings-toggle'), 'valueChange', false); await waitFor(() => expect(stateSyncService.preferences().settings).toBe(false), diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 881432e62..2a58ff340 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -7,6 +7,7 @@ import { ragService } from '../services/rag'; import { useChatStore } from './chatStore'; import logger from '../utils/logger'; import { + CORE_SYNC_ENTITIES, emitSyncMutation, projectPutMutation, } from '../services/sync/mutation'; @@ -127,6 +128,7 @@ export const useProjectStore = create()( }, deleteProject: id => { + const projectExists = get().projects.some(project => project.id === id); ragService .deleteProjectDocuments(id) .catch(err => @@ -143,6 +145,13 @@ export const useProjectStore = create()( set(state => ({ projects: state.projects.filter(project => project.id !== id), })); + if (projectExists) { + emitSyncMutation({ + entity: CORE_SYNC_ENTITIES.project, + entityId: id, + kind: 'delete', + }); + } }, getProject: id => { From 02278bf7944ab7d7377bf950a4a1db18b9b67c49 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 10:55:15 +0530 Subject: [PATCH 031/332] docs(sync): record resolved project deletion --- docs/SYNC_MOBILE_PROGRESS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index b4cb98e9f..9835447ec 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -86,8 +86,10 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as edit on each side at the same Lamport, reconnects, and proves both sides select the shared engine's higher-device-ID LWW winner. Restarting the mobile state service preserves the op count, and remounting AppNavigator shows the winning value without duplicate backfill ops. -- [ ] Resolve project-delete semantics before mobile emits project tombstones. Mobile deletion - unfiles chats; desktop currently deletes the project's conversations and messages. +- [x] Project deletion now has one non-destructive cross-lane contract: both apps remove the + project and unfile its conversations without deleting messages. The rendered journey deletes + an inbound project through mobile UI and proves the tombstone, unfiled conversation, and + preserved message all reach the peer. - [ ] Verify conversation/project/message convergence with the real desktop app on physical iOS and Android devices. @@ -115,7 +117,8 @@ high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphra `feat/sync-integration-phase0` (mobile). State-sync checkpoints: `f50dea2c` (stable IDs), `b8abb869` (withhold unsafe project tombstones), -Pro `07e06ee2` and core `78df85ba` (model settings). +Pro `07e06ee2` and core `78df85ba` (model settings), and `69f16ccb` +(non-destructive project deletion). Commits are small + each has rendered integration coverage + hygiene. ## Prior-art decision (2026-07-26) From 9ced2a55e26dd74ec828d6f80f987daefc8dd725 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 12:18:44 +0530 Subject: [PATCH 032/332] fix(sync): label development entitlement honestly --- __tests__/pro/sync/SyncScreen.test.tsx | 86 ++++++++++++++++++++------ pro | 2 +- src/hooks/useProStatusLabel.ts | 38 +++++++++--- 3 files changed, 97 insertions(+), 29 deletions(-) diff --git a/__tests__/pro/sync/SyncScreen.test.tsx b/__tests__/pro/sync/SyncScreen.test.tsx index c1f9df398..4d4971252 100644 --- a/__tests__/pro/sync/SyncScreen.test.tsx +++ b/__tests__/pro/sync/SyncScreen.test.tsx @@ -2,9 +2,16 @@ import React from 'react'; import { Alert } from 'react-native'; import * as Keychain from 'react-native-keychain'; import { NavigationContainer } from '@react-navigation/native'; -import { render, fireEvent, waitFor, within } from '@testing-library/react-native'; +import { + render, + fireEvent, + waitFor, + within, +} from '@testing-library/react-native'; -jest.mock('@react-navigation/native', () => jest.requireActual('@react-navigation/native')); +jest.mock('@react-navigation/native', () => + jest.requireActual('@react-navigation/native'), +); import { AppNavigator } from '../../../src/navigation/AppNavigator'; import { @@ -32,10 +39,12 @@ const mockTcpModule = { close: jest.Mock; }; server.on = jest.fn(() => server); - server.listen = jest.fn((options: { port: number }, callback?: () => void) => { - boundPort = options.port || 42001; - callback?.(); - }); + server.listen = jest.fn( + (options: { port: number }, callback?: () => void) => { + boundPort = options.port || 42001; + callback?.(); + }, + ); server.address = jest.fn(() => ({ port: boundPort })); server.close = jest.fn(); return server; @@ -85,6 +94,8 @@ describe('Settings to Sync licensed-device management', () => { hasCompletedOnboarding: true, downloadedModels: [createDownloadedModel()], themeMode: 'dark', + hasRegisteredPro: true, + isProActive: true, }); useSyncStore.getState().reset(); useLicensedDevicesStore.setState({ @@ -133,17 +144,19 @@ describe('Settings to Sync licensed-device management', () => { }, }, ]; - global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.endsWith('/licenses/lic-1/machines')) { - return response(200, { data: machines }); - } - if (url.endsWith('/machines/old') && init?.method === 'DELETE') { - machines.splice(1, 1); - return response(204); - } - return response(404); - }) as typeof fetch; + global.fetch = jest.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/licenses/lic-1/machines')) { + return response(200, { data: machines }); + } + if (url.endsWith('/machines/old') && init?.method === 'DELETE') { + machines.splice(1, 1); + return response(204); + } + return response(404); + }, + ) as typeof fetch; }); afterEach(async () => { @@ -166,13 +179,15 @@ describe('Settings to Sync licensed-device management', () => { await waitFor(() => expect(ui.getByText('2 of 5 active')).toBeTruthy()); expect(ui.getByText('My iPhone')).toBeTruthy(); expect( - within(ui.getByTestId('licensed-device-current')).getByText('THIS DEVICE'), + within(ui.getByTestId('licensed-device-current')).getByText( + 'THIS DEVICE', + ), ).toBeTruthy(); expect(ui.getByText('Old Android')).toBeTruthy(); fireEvent.press(ui.getByTestId('deactivate-device-old')); const destructiveAction = (alert.mock.calls[0][2] ?? []).find( - (button) => button.style === 'destructive', + button => button.style === 'destructive', ); destructiveAction?.onPress?.(); @@ -182,4 +197,37 @@ describe('Settings to Sync licensed-device management', () => { alert.mockRestore(); ui.unmount(); }); + + it('labels Debug Pro without claiming a Keygen device slot', async () => { + storedSecrets.delete('off-grid-pro-license'); + useAppStore.setState({ + hasRegisteredPro: false, + isProActive: true, + }); + + const ui = render( + + + , + ); + + fireEvent.press(ui.getByTestId('settings-tab')); + await waitFor(() => + expect(ui.getByText('Development · active')).toBeTruthy(), + ); + fireEvent.press(await waitFor(() => ui.getByTestId('open-sync-settings'))); + + await waitFor(() => expect(ui.getByText('DEVELOPMENT PRO')).toBeTruthy()); + expect(ui.getByText('Local development access')).toBeTruthy(); + expect( + ui.getByText( + 'This Debug build unlocks Pro locally. Device slots appear after activating a license key.', + ), + ).toBeTruthy(); + expect(ui.queryByText('0 of 5 active')).toBeNull(); + expect(ui.queryByText('No licensed devices are active.')).toBeNull(); + expect(ui.getByPlaceholderText('Enter a pairing code')).toBeTruthy(); + + ui.unmount(); + }); }); diff --git a/pro b/pro index 07e06ee24..afca0d7ec 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 07e06ee241c6a3c6514183d2f7d9432d74bce961 +Subproject commit afca0d7ec2c37077eb1ecb5bcee8e00a34066c4b diff --git a/src/hooks/useProStatusLabel.ts b/src/hooks/useProStatusLabel.ts index 82d8aa0a5..591d850bf 100644 --- a/src/hooks/useProStatusLabel.ts +++ b/src/hooks/useProStatusLabel.ts @@ -1,26 +1,46 @@ import { useEffect, useState } from 'react'; import { useAppStore } from '../stores'; -import { getProLicenseInfo, PRO_TIER_META, type ProLicenseInfo } from '../services/proLicenseService'; +import { + getProLicenseInfo, + PRO_TIER_META, + type ProLicenseInfo, +} from '../services/proLicenseService'; /** * Label for the Settings "Off Grid AI PRO" row: the upsell line when not Pro, or * the subscription status (a renewing tier shows its date; a one-time tier shows * " · active") when Pro. */ -export function useProStatusLabel(): { hasRegisteredPro: boolean; proStatusLabel: string } { - const hasRegisteredPro = useAppStore((s) => s.hasRegisteredPro); +export function useProStatusLabel(): { + hasRegisteredPro: boolean; + proStatusLabel: string; +} { + const hasRegisteredPro = useAppStore(s => s.hasRegisteredPro); + const isProActive = useAppStore(s => s.isProActive); const [info, setInfo] = useState(null); useEffect(() => { - if (hasRegisteredPro) getProLicenseInfo().then(setInfo).catch(() => {}); - }, [hasRegisteredPro]); + if (isProActive) + getProLicenseInfo() + .then(setInfo) + .catch(() => {}); + else setInfo(null); + }, [isProActive]); // Drive off the tier's `renews` flag (single source), not a concrete-tier check. const meta = info?.tier ? PRO_TIER_META[info.tier] : null; - const proStatusLabel = !hasRegisteredPro + const proStatusLabel = !isProActive ? 'Unlock premium features' - : meta?.renews && info?.expiry - ? `Active until ${new Date(info.expiry).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}` - : `${meta?.label ?? 'Lifetime'} · active`; + : info?.isPro && meta?.renews && info.expiry + ? `Active until ${new Date(info.expiry).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })}` + : info?.isPro && meta + ? `${meta.label} · active` + : info + ? 'Development · active' + : 'Pro · active'; return { hasRegisteredPro, proStatusLabel }; } From 8f40da8fee7dc0f26338b1ade7fb55a440063954 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 12:21:58 +0530 Subject: [PATCH 033/332] docs(sync): clarify development device status --- docs/SYNC_MOBILE_PROGRESS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 9835447ec..62f7f347f 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -61,6 +61,9 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as then retries activation. Revalidation follows the same replacement path. - [x] Boundary tests cover the real Keygen HTTP sequence (five active → delete oldest → activate current), and a rendered AppNavigator journey covers Settings → Sync → device deactivation. +- [x] Debug-only Pro access is labeled as local development access instead of a Lifetime license. + Its Sync card explains that Keygen device slots require a license key and never renders a + misleading `0 of 5`; the pairing field uses an unmistakable instruction placeholder. ### Phase 1 - State sync (chats/projects/settings) - IN PROGRESS @@ -118,7 +121,8 @@ high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphra `feat/sync-integration-phase0` (mobile). State-sync checkpoints: `f50dea2c` (stable IDs), `b8abb869` (withhold unsafe project tombstones), Pro `07e06ee2` and core `78df85ba` (model settings), and `69f16ccb` -(non-destructive project deletion). +(non-destructive project deletion). Pro `afca0d7e` and core `9ced2a55` distinguish Debug Pro from +real Keygen device activation. Commits are small + each has rendered integration coverage + hygiene. ## Prior-art decision (2026-07-26) From 41e0e5a1dfeca2c2e654d1afb3355a65ac762698 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 17:18:28 +0530 Subject: [PATCH 034/332] test(sync): approve desktop pairing in app --- .../sync/modelTransfer.integration.test.tsx | 19 ++++++++++--- .../pro/sync/stateSync.integration.test.tsx | 28 ++++++++++++++++--- .../sync/syncPersistence.integration.test.ts | 6 +++- pro | 2 +- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/__tests__/pro/sync/modelTransfer.integration.test.tsx b/__tests__/pro/sync/modelTransfer.integration.test.tsx index e9890b81f..6b09c50d9 100644 --- a/__tests__/pro/sync/modelTransfer.integration.test.tsx +++ b/__tests__/pro/sync/modelTransfer.integration.test.tsx @@ -28,6 +28,7 @@ import { useSyncStore } from '../../../pro/sync/syncStore'; import { modelTransferService } from '../../../pro/sync/modelTransferService'; import { SyncScreen } from '../../../pro/ui/SyncScreen'; import { SyncSettingsSection } from '../../../pro/ui/SyncSettingsSection'; +import { ProRoot } from '../../../pro/ui/ProRoot'; import { getDiscoveryBoundaries, resetDiscoveryBoundaries, @@ -145,16 +146,18 @@ describe('Pro mobile model transfer journey', () => { await syncService.start(); ui = render( - - - , + <> + + + + + , ); fireEvent.press(ui.getByTestId('settings-tab')); fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); await waitFor(() => expect(ui!.getByText('Discoverable on your Wi-Fi')).toBeTruthy(), ); - fireEvent.changeText(ui.getByTestId('sync-pairing-code'), 'green-river-52'); const mobile = useSyncStore.getState().thisDevice; const discovery = getDiscoveryBoundaries().at(-1); @@ -169,6 +172,14 @@ describe('Pro mobile model transfer journey', () => { }, 'green-river-52', ); + await waitFor(() => + expect(ui!.getByText('Pair with Off Grid AI Desktop')).toBeTruthy(), + ); + fireEvent.changeText( + ui.getByTestId('incoming-pairing-code'), + 'green-river-52', + ); + fireEvent.press(ui.getByTestId('accept-incoming-pairing')); await waitFor(() => expect(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).toBeTruthy(), ); diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx index a161734c7..068d410a9 100644 --- a/__tests__/pro/sync/stateSync.integration.test.tsx +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -38,6 +38,7 @@ import { stateSyncService } from '../../../pro/sync/stateSyncService'; import { useSyncStore } from '../../../pro/sync/syncStore'; import { SyncScreen } from '../../../pro/ui/SyncScreen'; import { SyncSettingsSection } from '../../../pro/ui/SyncSettingsSection'; +import { ProRoot } from '../../../pro/ui/ProRoot'; import { getDiscoveryBoundaries, resetDiscoveryBoundaries, @@ -189,13 +190,15 @@ describe('Pro mobile state sync journey', () => { await syncService.start(); ui = render( - - - , + <> + + + + + , ); fireEvent.press(ui.getByTestId('settings-tab')); fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); - fireEvent.changeText(ui.getByTestId('sync-pairing-code'), 'violet-lake-27'); const mobile = useSyncStore.getState().thisDevice; const discovery = getDiscoveryBoundaries().at(-1); @@ -206,6 +209,19 @@ describe('Pro mobile state sync journey', () => { { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, 'violet-lake-27', ); + await waitFor(() => + expect(ui!.getByText('Pair with Off Grid AI Desktop')).toBeTruthy(), + ); + expect( + ui.getByText( + 'Off Grid AI Desktop wants to pair with this phone. Enter the code shown on that device.', + ), + ).toBeTruthy(); + fireEvent.changeText( + ui.getByTestId('incoming-pairing-code'), + 'violet-lake-27', + ); + fireEvent.press(ui.getByTestId('accept-incoming-pairing')); await waitFor(() => expect(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).toBeTruthy(), ); @@ -356,6 +372,10 @@ describe('Pro mobile state sync journey', () => { { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, 'violet-lake-27', ); + await waitFor(() => + expect(ui!.getByText('Pair with Off Grid AI Desktop')).toBeTruthy(), + ); + fireEvent.press(ui.getByTestId('accept-incoming-pairing')); await waitFor(() => expect(syncService.connectedDeviceIds()).toContain(remoteDevice.id), ); diff --git a/__tests__/pro/sync/syncPersistence.integration.test.ts b/__tests__/pro/sync/syncPersistence.integration.test.ts index 00852b33a..7aff34829 100644 --- a/__tests__/pro/sync/syncPersistence.integration.test.ts +++ b/__tests__/pro/sync/syncPersistence.integration.test.ts @@ -100,12 +100,16 @@ describe('Pro Sync app-lifetime pairing persistence', () => { const firstDiscovery = getDiscoveryBoundaries().at(-1); expect(mobile).toBeDefined(); expect(firstDiscovery?.publishedPort).toBeGreaterThan(0); - useSyncStore.getState().setPairingCode('blue-otter-42'); await remote.engine.pair( { ...mobile!, host: '127.0.0.1', port: firstDiscovery!.publishedPort! }, 'blue-otter-42', ); + await waitFor( + () => + useSyncStore.getState().incomingPairingDevice?.id === remoteDevice.id, + ); + syncService.acceptIncomingPairing('blue-otter-42'); await waitFor(() => useSyncStore .getState() diff --git a/pro b/pro index afca0d7ec..bba2515fc 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit afca0d7ec2c37077eb1ecb5bcee8e00a34066c4b +Subproject commit bba2515fc3af0981dad04bdcdea8127783ca2aa9 From 1b2970062e042ee8fde67dd259402e86e430a5d3 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 17:37:04 +0530 Subject: [PATCH 035/332] feat(sync): recover device discovery without relaunch --- .../pro/sync/stateSync.integration.test.tsx | 6 +++ __tests__/utils/nativeSyncBoundaries.ts | 12 ++++- pro | 2 +- src/services/sync/nativeSync.ts | 46 ++++++++++++++++--- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx index 068d410a9..54cc26205 100644 --- a/__tests__/pro/sync/stateSync.integration.test.tsx +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -205,6 +205,12 @@ describe('Pro mobile state sync journey', () => { if (!mobile || !discovery?.publishedPort) { throw new Error('Sync did not publish the mobile device'); } + expect(discovery.scanCount).toBe(1); + fireEvent.press(ui.getByTestId('sync-rescan')); + await waitFor(() => expect(discovery.scanCount).toBe(2)); + expect(discovery.stopCount).toBe(1); + expect(discovery.publishedPort).toBeGreaterThan(0); + await remote.engine.pair( { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, 'violet-lake-27', diff --git a/__tests__/utils/nativeSyncBoundaries.ts b/__tests__/utils/nativeSyncBoundaries.ts index ce282bf7b..e17fa5cfb 100644 --- a/__tests__/utils/nativeSyncBoundaries.ts +++ b/__tests__/utils/nativeSyncBoundaries.ts @@ -80,6 +80,8 @@ export function createNativeTcpBoundary(): RnTcpModule { export interface DiscoveryBoundary { publishedPort?: number; + scanCount: number; + stopCount: number; resolve(device: DeviceInfo): void; } @@ -88,6 +90,8 @@ let boundaries: DiscoveryBoundary[] = []; export function createNativeDiscoveryBoundary(): new () => DiscoveryBoundary { return class NativeDiscoveryBoundary implements DiscoveryBoundary { publishedPort?: number; + scanCount = 0; + stopCount = 0; private readonly handlers = new Map(); constructor() { @@ -98,8 +102,12 @@ export function createNativeDiscoveryBoundary(): new () => DiscoveryBoundary { this.handlers.set(event, callback); } - scan(): void {} - stop(): void {} + scan(): void { + this.scanCount += 1; + } + stop(): void { + this.stopCount += 1; + } removeDeviceListeners(): void {} publishService( _type: string, diff --git a/pro b/pro index bba2515fc..6b41e1395 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit bba2515fc3af0981dad04bdcdea8127783ca2aa9 +Subproject commit 6b41e139511a0a291e4ed6bd4fe0b6628f36303f diff --git a/src/services/sync/nativeSync.ts b/src/services/sync/nativeSync.ts index dd1ce8732..5e89b2561 100644 --- a/src/services/sync/nativeSync.ts +++ b/src/services/sync/nativeSync.ts @@ -6,7 +6,12 @@ import { Platform } from 'react-native'; import TcpSocket from 'react-native-tcp-socket'; import Zeroconf from 'react-native-zeroconf'; -import type { DeviceInfo, DiscoveredDevice, PairedDevice, Message } from '@offgrid/sync'; +import type { + DeviceInfo, + DiscoveredDevice, + PairedDevice, + Message, +} from '@offgrid/sync'; import type { RnTcpModule } from '@offgrid/sync/rn'; import type { RnZeroconf } from '@offgrid/sync/rn-discovery'; import { buildSyncEngine } from './engine'; @@ -15,7 +20,9 @@ import logger from '../../utils/logger'; export interface NativeSyncCallbacks { /** Passphrase for an INBOUND pairing (UI prompt). Return null to refuse. */ - getPassphrase?: (remote: DeviceInfo) => Promise | string | null | undefined; + getPassphrase?: ( + remote: DeviceInfo, + ) => Promise | string | null | undefined; /** Stored shared secret for a device (for silent reconnect). */ getSharedSecret?: (deviceId: string) => string | undefined; onPaired?: (device: PairedDevice) => void; @@ -31,6 +38,7 @@ export interface NativeSync { readonly localDevice: DeviceInfo; start(): Promise; stop(): Promise; + rescan(): Promise; pair(device: DeviceInfo, passphrase: string): Promise; send(deviceId: string, message: Message): boolean; sendApp(deviceId: string, channel: string, data: unknown): boolean; @@ -38,7 +46,10 @@ export interface NativeSync { } /** Construct (but don't start) the mobile Sync stack for a given local device. */ -export function createNativeSync(localDevice: DeviceInfo, cbs: NativeSyncCallbacks): NativeSync { +export function createNativeSync( + localDevice: DeviceInfo, + cbs: NativeSyncCallbacks, +): NativeSync { const { engine, transport } = buildSyncEngine({ localDevice, tcpModule: TcpSocket as unknown as RnTcpModule, @@ -59,6 +70,8 @@ export function createNativeSync(localDevice: DeviceInfo, cbs: NativeSyncCallbac onDiscovered: cbs.onDiscovered, onLost: cbs.onLost, }); + let active = false; + let rescanTask: Promise | null = null; return { localDevice, @@ -66,17 +79,38 @@ export function createNativeSync(localDevice: DeviceInfo, cbs: NativeSyncCallbac await engine.start(0); // ephemeral port localDevice.port = transport.boundPort ?? 0; // advertise the real bound port await orchestrator.start(); - logger.log(`[SYNC] started id=${localDevice.id} port=${localDevice.port} platform=${localDevice.platform}`); + active = true; + logger.log( + `[SYNC] started id=${localDevice.id} port=${localDevice.port} platform=${localDevice.platform}`, + ); }, async stop() { + active = false; + await rescanTask?.catch(() => undefined); await orchestrator.stop(); await engine.stop(); logger.log('[SYNC] stopped'); }, + async rescan() { + if (!active) throw new Error('Sync is not running.'); + if (rescanTask) return rescanTask; + rescanTask = (async () => { + await orchestrator.stop(); + if (!active) return; + await orchestrator.start(); + logger.log('[SYNC] discovery rescanned'); + })(); + try { + await rescanTask; + } finally { + rescanTask = null; + } + }, pair: (device, passphrase) => engine.pair(device, passphrase), send: (deviceId, message) => engine.send(deviceId, message), - sendApp: (deviceId, channel, data) => engine.sendApp(deviceId, channel, data), - isPaired: (deviceId) => engine.isPaired(deviceId), + sendApp: (deviceId, channel, data) => + engine.sendApp(deviceId, channel, data), + isPaired: deviceId => engine.isPaired(deviceId), }; } From 2fb00fddb3c13f6f769d30f38865df72f79dc0b6 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 18:11:39 +0530 Subject: [PATCH 036/332] fix(sync): preserve pairing and message reasoning --- .../pro/sync/stateSync.integration.test.tsx | 51 ++++++++++++++++++- index.js | 3 ++ package-lock.json | 7 +++ package.json | 1 + pro | 2 +- .../ChatScreen/ChatScreenComponents.tsx | 14 ++++- src/services/sync/messageContext.ts | 35 +++++++++++++ src/services/sync/mutation.ts | 3 +- 8 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 src/services/sync/messageContext.ts diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx index 54cc26205..74ef521dc 100644 --- a/__tests__/pro/sync/stateSync.integration.test.tsx +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -178,6 +178,20 @@ describe('Pro mobile state sync journey', () => { context: null, created_at: createdAt, }); + remoteLog.record( + CORE_SYNC_ENTITIES.message, + 'remote-reasoning-message', + 'put', + { + conversation_id: 'remote-conversation', + role: 'assistant', + content: 'The field notes are ready.', + context: JSON.stringify({ + reasoning: 'I should confirm the notes before answering.', + }), + created_at: createdAt, + }, + ); for (let revision = 0; revision < 20; revision += 1) { remoteLog.record(CORE_SYNC_ENTITIES.modelSetting, 'temperature', 'put', { value_json: revision === 19 ? '0.55' : '0.5', @@ -242,8 +256,41 @@ describe('Pro mobile state sync journey', () => { await waitFor(() => expect(ui!.getByText('Desktop Research')).toBeTruthy()); fireEvent.press(ui.getByTestId('chats-tab')); await waitFor(() => expect(ui!.getByText('Field planning')).toBeTruthy()); - expect(ui.getByText('You: Bring the field notes')).toBeTruthy(); + expect(ui.getByText('The field notes are ready.')).toBeTruthy(); expect(ui.getByText('Desktop Research')).toBeTruthy(); + fireEvent.press(ui.getByText('Field planning')); + await waitFor(() => + expect(ui!.getByText('The field notes are ready.')).toBeTruthy(), + ); + expect(ui.getByText('Thought process')).toBeTruthy(); + fireEvent.press(ui.getByTestId('thinking-block-toggle')); + expect( + ui.getByText('I should confirm the notes before answering.'), + ).toBeTruthy(); + fireEvent.press(ui.getByLabelText('Back')); + + useChatStore.getState().addMessage('remote-conversation', { + role: 'assistant', + content: 'The phone checked the notes.', + reasoningContent: 'I should send the reasoning back to Desktop.', + }); + await waitFor(() => + expect( + remoteRecords.records.get( + `${CORE_SYNC_ENTITIES.message}:${ + useChatStore + .getState() + .conversations.find(item => item.id === 'remote-conversation') + ?.messages.at(-1)?.uuid + }`, + ), + ).toMatchObject({ + content: 'The phone checked the notes.', + context: JSON.stringify({ + reasoning: 'I should send the reasoning back to Desktop.', + }), + }), + ); fireEvent.press(ui.getByTestId('projects-tab')); fireEvent.press(ui.getByText('New')); @@ -303,7 +350,7 @@ describe('Pro mobile state sync journey', () => { ).toMatchObject({ content: 'Bring the field notes' }); fireEvent.press(ui.getByTestId('chats-tab')); await waitFor(() => expect(ui!.getByText('Field planning')).toBeTruthy()); - expect(ui.getByText('You: Bring the field notes')).toBeTruthy(); + expect(ui.getByText('The phone checked the notes.')).toBeTruthy(); fireEvent.press(ui.getByTestId('settings-tab')); fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); diff --git a/index.js b/index.js index b35f284ef..4dcdf050a 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,9 @@ // Spec-compliant global URL — RN's built-in mangles paths (adds trailing slashes), // which breaks the MCP SDK's OAuth discovery. Must load before any network/OAuth code. import 'react-native-url-polyfill/auto'; +// Hermes does not provide TextEncoder/TextDecoder. Sync's proven EasyShare +// protocol requires both before the shared wire codec is loaded. +import 'text-encoding-polyfill'; // Hermes does not expose Web Crypto on every supported OS version. Install the // secure getRandomValues polyfill before stores create persisted sync identities. import 'react-native-get-random-values'; diff --git a/package-lock.json b/package-lock.json index d1c07fe1c..934c3f5b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "react-native-worklets": "^0.7.3", "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", + "text-encoding-polyfill": "^0.6.7", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", "whisper.rn": "^0.5.5", @@ -16356,6 +16357,12 @@ "node": "*" } }, + "node_modules/text-encoding-polyfill": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/text-encoding-polyfill/-/text-encoding-polyfill-0.6.7.tgz", + "integrity": "sha512-/DZ1XJqhbqRkCop6s9ZFu8JrFRwmVuHg4quIRm+ziFkR3N3ec6ck6yBvJ1GYeEQZhLVwRW0rZE+C3SSJpy0RTg==", + "license": "Unlicense" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 34ad9c1fc..928492abb 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "react-native-worklets": "^0.7.3", "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", + "text-encoding-polyfill": "^0.6.7", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", "whisper.rn": "^0.5.5", diff --git a/pro b/pro index 6b41e1395..bcd5c0567 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 6b41e139511a0a291e4ed6bd4fe0b6628f36303f +Subproject commit bcd5c0567ff49e5b72b46e874200831188af4aa2 diff --git a/src/screens/ChatScreen/ChatScreenComponents.tsx b/src/screens/ChatScreen/ChatScreenComponents.tsx index 1e13ae480..22cb0184e 100644 --- a/src/screens/ChatScreen/ChatScreenComponents.tsx +++ b/src/screens/ChatScreen/ChatScreenComponents.tsx @@ -33,7 +33,12 @@ export const NoModelScreen: React.FC<{ - navigation.goBack()}> + navigation.goBack()} + accessibilityRole="button" + accessibilityLabel="Back" + > @@ -95,7 +100,12 @@ export const ChatHeader: React.FC<{ }> = ({ styles, colors, activeConversation, activeProject, navigation, onOpenModels, setShowSettingsPanel, setShowProjectSelector, isRemote }) => ( - navigation.goBack()}> + navigation.goBack()} + accessibilityRole="button" + accessibilityLabel="Back" + > diff --git a/src/services/sync/messageContext.ts b/src/services/sync/messageContext.ts new file mode 100644 index 000000000..703a33220 --- /dev/null +++ b/src/services/sync/messageContext.ts @@ -0,0 +1,35 @@ +import type { Message } from '../../types'; + +interface SyncedMessageContext { + reasoning?: unknown; +} + +/** Match Desktop's persisted message-context contract without leaking UI state onto the wire. */ +export function serializeMessageContext( + message: Pick, +): string | null { + const reasoning = message.reasoningContent; + return typeof reasoning === 'string' && reasoning.trim() + ? JSON.stringify({ reasoning }) + : null; +} + +/** Peer-controlled context is optional JSON; malformed or empty reasoning is ignored. */ +export function reasoningFromMessageContext( + value: unknown, +): string | undefined { + let context: SyncedMessageContext; + try { + context = + typeof value === 'string' + ? (JSON.parse(value) as SyncedMessageContext) + : (value as SyncedMessageContext); + } catch { + return undefined; + } + if (!context || typeof context !== 'object') return undefined; + const reasoning = context.reasoning; + return typeof reasoning === 'string' && reasoning.trim() + ? reasoning + : undefined; +} diff --git a/src/services/sync/mutation.ts b/src/services/sync/mutation.ts index 94ad74628..023d3047f 100644 --- a/src/services/sync/mutation.ts +++ b/src/services/sync/mutation.ts @@ -1,5 +1,6 @@ import { callHook, HOOKS } from '../../bootstrap/hookRegistry'; import type { Conversation, Message, Project } from '../../types'; +import { serializeMessageContext } from './messageContext'; /** Stable wire entity names shared with Off Grid Desktop. */ export const CORE_SYNC_ENTITIES = { @@ -139,7 +140,7 @@ export function messagePutMutation( conversation_id: conversationId, role: message.role, content: message.content, - context: null, + context: serializeMessageContext(message), created_at: new Date(message.timestamp).toISOString(), }, }; From 53c0b61ccff381dac0773a377820746a5dc216ae Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 18:27:30 +0530 Subject: [PATCH 037/332] feat(sync): register transferred model packages --- .../modelPackageTransfer.integration.test.ts | 313 ++++++++++++++++++ .../sync/modelTransfer.integration.test.tsx | 17 +- pro | 2 +- .../modelManager/transferAdmission.ts | 63 +++- src/services/whisperModelFiles.ts | 19 ++ src/stores/whisperStore.ts | 6 +- 6 files changed, 398 insertions(+), 22 deletions(-) create mode 100644 __tests__/pro/sync/modelPackageTransfer.integration.test.ts diff --git a/__tests__/pro/sync/modelPackageTransfer.integration.test.ts b/__tests__/pro/sync/modelPackageTransfer.integration.test.ts new file mode 100644 index 000000000..c64f1f819 --- /dev/null +++ b/__tests__/pro/sync/modelPackageTransfer.integration.test.ts @@ -0,0 +1,313 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; +import TcpSocket from 'react-native-tcp-socket'; +import { + FileTransferManager, + IncrementalChecksum, + MODEL_TRANSFER_MIME, + type DeviceInfo, + type ModelPackageTransferMetadata, + type TransferFileSource, + type TransferredModelManifest, +} from '@offgrid/sync'; +import type { RnTcpModule } from '@offgrid/sync/rn'; +import { modelManager } from '../../../src/services/modelManager'; +import { buildSyncEngine } from '../../../src/services/sync/engine'; +import { whisperService } from '../../../src/services/whisperService'; +import { useWhisperStore } from '../../../src/stores/whisperStore'; +import { modelTransferService } from '../../../pro/sync/modelTransferService'; +import { syncService } from '../../../pro/sync/syncService'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { + getDiscoveryBoundaries, + resetDiscoveryBoundaries, +} from '../../utils/nativeSyncBoundaries'; +import { modelTransferFsBoundary } from '../../utils/modelTransferFsBoundary'; + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +jest.mock('react-native-fs', () => { + const { + modelTransferFsBoundary: boundary, + } = require('../../utils/modelTransferFsBoundary'); + return { + __esModule: true, + default: boundary.module, + ...boundary.module, + }; +}); + +const nativeTcpBoundary = TcpSocket as unknown as RnTcpModule; + +async function waitForState( + condition: () => boolean, + timeoutMs = 3000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error('Timed out waiting for model transfer state'); + } + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +function packageSource( + bytes: Buffer, + metadata: ModelPackageTransferMetadata, +): TransferFileSource { + const file = metadata.manifest.files[metadata.fileIndex]; + if (!file) throw new Error('Package source has no selected file'); + const checksum = new IncrementalChecksum(); + checksum.update(bytes); + return { + fileName: file.name, + fileSize: bytes.length, + mimeType: MODEL_TRANSFER_MIME, + metadata, + checksum: async () => checksum.digest(), + read: async (offset, length) => + new Uint8Array(bytes.subarray(offset, offset + length)), + }; +} + +function modelBytes(size: number, fill: number): Buffer { + const bytes = Buffer.alloc(size, fill); + bytes.write('GGUF', 0, 'ascii'); + return bytes; +} + +function packageMetadata( + packageId: string, + manifest: TransferredModelManifest, + fileIndex: number, +): ModelPackageTransferMetadata { + return { + type: 'offgrid-model', + version: 2, + packageId, + fileIndex, + manifest, + }; +} + +describe('Pro mobile model package receiver', () => { + let remote: ReturnType | undefined; + let remoteTransfers: FileTransferManager | undefined; + + beforeEach(async () => { + modelTransferFsBoundary.reset(); + await AsyncStorage.clear(); + resetDiscoveryBoundaries(); + useSyncStore.getState().reset(); + await useWhisperStore.getState().refreshPresentModels(); + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(false); + (Keychain.setGenericPassword as jest.Mock).mockResolvedValue(true); + }); + + afterEach(async () => { + await remoteTransfers?.dispose(); + await remote?.engine.stop(); + await syncService.stop(); + await modelTransferService.stop(); + }); + + it('admits grouped vision and Whisper packages while rejecting image and Parakeet', async () => { + const remoteDevice: DeviceInfo = { + id: 'desktop-package-source', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + onMessage: (deviceId, message) => { + remoteTransfers?.handleMessage(deviceId, message); + }, + }); + remoteTransfers = new FileTransferManager({ + send: (deviceId, message) => remote!.engine.send(deviceId, message), + createSink: async () => null, + }); + + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + modelTransferService.start(); + await syncService.start(); + + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !discovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + const pairing = remote.engine.pair( + { + ...mobile, + host: '127.0.0.1', + port: discovery.publishedPort, + }, + 'blue-otter-42', + ); + await waitForState( + () => + useSyncStore.getState().incomingPairingDevice?.id === remoteDevice.id, + ); + syncService.acceptIncomingPairing('blue-otter-42'); + await pairing; + await waitForState(() => + useSyncStore + .getState() + .paired.some(device => device.id === remoteDevice.id), + ); + + const primary = modelBytes(96 * 1024 + 4, 0x31); + const projector = modelBytes(64 * 1024 + 4, 0x32); + const visionManifest: TransferredModelManifest = { + id: 'off-grid/mobile-vision', + name: 'Mobile Vision', + kind: 'vision', + source: 'downloaded', + files: [ + { + name: 'mobile-vision-Q4_K_M.gguf', + sizeBytes: primary.length, + role: 'primary', + }, + { + name: 'mmproj-mobile-vision-F16.gguf', + sizeBytes: projector.length, + role: 'projector', + }, + ], + }; + await remoteTransfers.sendFile( + mobile.id, + packageSource( + primary, + packageMetadata('vision-package', visionManifest, 0), + ), + ); + + const modelsDirectory = modelManager.getModelsDirectory(); + await expect( + modelTransferFsBoundary.exists( + `${modelsDirectory}/${visionManifest.files[0].name}`, + ), + ).resolves.toBe(false); + await expect(modelManager.getDownloadedModels()).resolves.toHaveLength(0); + + await remoteTransfers.sendFile( + mobile.id, + packageSource( + projector, + packageMetadata('vision-package', visionManifest, 1), + ), + ); + await expect(modelManager.getDownloadedModels()).resolves.toEqual([ + expect.objectContaining({ + id: `off-grid/mobile-vision/${visionManifest.files[0].name}`, + name: 'Mobile Vision', + engine: 'llama', + isVisionModel: true, + mmProjFileName: visionManifest.files[1].name, + mmProjPath: `${modelsDirectory}/${visionManifest.files[1].name}`, + }), + ]); + + const whisper = Buffer.alloc(10 * 1024 * 1024 + 4, 0x44); + const whisperManifest: TransferredModelManifest = { + id: 'ggerganov/whisper.cpp/base.en', + name: 'Whisper Base English', + kind: 'transcription', + source: 'catalog', + files: [ + { + name: 'ggml-base.en.bin', + sizeBytes: whisper.length, + role: 'primary', + }, + ], + }; + await remoteTransfers.sendFile( + mobile.id, + packageSource( + whisper, + packageMetadata('whisper-package', whisperManifest, 0), + ), + ); + await expect(whisperService.listDownloadedModels()).resolves.toEqual([ + expect.objectContaining({ + modelId: 'base.en', + fileName: 'ggml-base.en.bin', + sizeBytes: whisper.length, + }), + ]); + expect(useWhisperStore.getState().presentModelIds).toContain('base.en'); + + const imageManifest: TransferredModelManifest = { + id: 'off-grid/mobile-image', + name: 'Mobile Image', + kind: 'image', + source: 'downloaded', + files: [ + { + name: 'mobile-image.gguf', + sizeBytes: primary.length, + role: 'primary', + }, + ], + }; + await expect( + remoteTransfers.sendFile( + mobile.id, + packageSource( + primary, + packageMetadata('image-package', imageManifest, 0), + ), + ), + ).rejects.toThrow( + 'only text, vision, and Whisper transcription models can be sent to Off Grid Mobile', + ); + + const parakeet = Buffer.alloc(4096, 0x50); + const parakeetManifest: TransferredModelManifest = { + id: 'nvidia/parakeet', + name: 'Parakeet', + kind: 'transcription', + source: 'catalog', + files: [ + { + name: 'parakeet-encoder.onnx', + sizeBytes: parakeet.length, + role: 'primary', + }, + ], + }; + await expect( + remoteTransfers.sendFile( + mobile.id, + packageSource( + parakeet, + packageMetadata('parakeet-package', parakeetManifest, 0), + ), + ), + ).rejects.toThrow( + 'only text, vision, and Whisper transcription models can be sent to Off Grid Mobile', + ); + }); +}); diff --git a/__tests__/pro/sync/modelTransfer.integration.test.tsx b/__tests__/pro/sync/modelTransfer.integration.test.tsx index 6b09c50d9..7d49743d4 100644 --- a/__tests__/pro/sync/modelTransfer.integration.test.tsx +++ b/__tests__/pro/sync/modelTransfer.integration.test.tsx @@ -195,13 +195,21 @@ describe('Pro mobile model transfer journey', () => { mimeType: MODEL_TRANSFER_MIME, metadata: { type: 'offgrid-model', - version: 1, + version: 2, + packageId: 'text-package', + fileIndex: 0, manifest: { id: 'google/gemma-mobile', name: 'Gemma Mobile', kind: 'text', source: 'downloaded', - files: [{ name: fileName, sizeBytes: payload.length }], + files: [ + { + name: fileName, + sizeBytes: payload.length, + role: 'primary', + }, + ], }, }, checksum: async () => checksum.digest(), @@ -241,7 +249,9 @@ describe('Pro mobile model transfer journey', () => { mimeType: MODEL_TRANSFER_MIME, metadata: { type: 'offgrid-model', - version: 1, + version: 2, + packageId: 'invalid-package', + fileIndex: 0, manifest: { id: 'offgrid/invalid-model', name: 'Invalid model', @@ -251,6 +261,7 @@ describe('Pro mobile model transfer journey', () => { { name: invalidFileName, sizeBytes: invalidPayload.length, + role: 'primary', }, ], }, diff --git a/pro b/pro index bcd5c0567..1d204acdc 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit bcd5c0567ff49e5b72b46e874200831188af4aa2 +Subproject commit 1d204acdc1fe2a8f01183e6f27ea4ca565d1da1e diff --git a/src/services/modelManager/transferAdmission.ts b/src/services/modelManager/transferAdmission.ts index 6942b628b..d18aa853a 100644 --- a/src/services/modelManager/transferAdmission.ts +++ b/src/services/modelManager/transferAdmission.ts @@ -1,5 +1,8 @@ import RNFS from 'react-native-fs'; -import type { TransferredModelManifest } from '@offgrid/sync'; +import { + ogamModelTransferBlocker, + type TransferredModelManifest, +} from '@offgrid/sync'; import type { DownloadedModel, ModelFile } from '../../types'; import { buildDownloadedModel, @@ -11,34 +14,66 @@ export async function registerTransferredModelFile( manifest: TransferredModelManifest, modelsDir: string, ): Promise { - const file = manifest.files[0]; - if (!file || file.name.includes('/') || file.name.includes('\\') || !/\.gguf$/i.test(file.name)) { + const blocker = ogamModelTransferBlocker(manifest); + if (blocker || (manifest.kind !== 'text' && manifest.kind !== 'vision')) { + throw new Error('Transferred model manifest is invalid'); + } + + const primary = + manifest.files.find(file => file.role === 'primary') ?? + manifest.files.find(file => file.role !== 'projector'); + const projector = manifest.files.find(file => file.role === 'projector'); + if (!primary) { throw new Error('Transferred model manifest is invalid'); } - const filePath = `${modelsDir}/${file.name}`; - const stat = await RNFS.stat(filePath); - const actualSize = typeof stat.size === 'string' ? Number.parseInt(stat.size, 10) : stat.size; - if (!stat.isFile() || actualSize !== file.sizeBytes) { - throw new Error('Transferred model file does not match its manifest'); + for (const file of manifest.files) { + const filePath = `${modelsDir}/${file.name}`; + const stat = await RNFS.stat(filePath); + const actualSize = + typeof stat.size === 'string' + ? Number.parseInt(stat.size, 10) + : stat.size; + if (!stat.isFile() || actualSize !== file.sizeBytes) { + throw new Error('Transferred model file does not match its manifest'); + } } - const quantization = file.name.match(/[_-](Q\d+[_\w]*|f16|f32)/i)?.[1]?.toUpperCase() ?? 'Unknown'; + const primaryPath = `${modelsDir}/${primary.name}`; + const projectorPath = projector + ? `${modelsDir}/${projector.name}` + : undefined; + const quantization = + primary.name.match(/[_-](Q\d+[_\w]*|f16|f32)/i)?.[1]?.toUpperCase() ?? + 'Unknown'; const pseudoFile: ModelFile = { - name: file.name, - size: file.sizeBytes, + name: primary.name, + size: primary.sizeBytes, quantization, downloadUrl: '', + ...(projector + ? { + mmProjFile: { + name: projector.name, + size: projector.sizeBytes, + downloadUrl: '', + }, + } + : {}), }; const base = await buildDownloadedModel({ modelId: manifest.id, file: pseudoFile, - resolvedLocalPath: filePath, + resolvedLocalPath: primaryPath, + mmProjPath: projectorPath, }); - const author = manifest.source === 'local' ? 'Local Import' : (manifest.id.split('/')[0] || 'Unknown'); + const author = + manifest.source === 'local' + ? 'Local Import' + : manifest.id.split('/')[0] || 'Unknown'; const model: DownloadedModel = { ...base, - id: `${manifest.id}/${file.name}`, + id: `${manifest.id}/${primary.name}`, name: manifest.name, author, credibility: determineCredibility(author), diff --git a/src/services/whisperModelFiles.ts b/src/services/whisperModelFiles.ts index 85a7aad12..d732f2c55 100644 --- a/src/services/whisperModelFiles.ts +++ b/src/services/whisperModelFiles.ts @@ -88,3 +88,22 @@ export async function validateModelFile(modelPath: string): Promise { logger.log(`[Whisper] Model file validated: ${modelPath} (${Math.round(fileSize / (1024 * 1024))} MB)`); } + +/** + * Admit a transferred Whisper artifact into the disk-backed transcription catalog. + * The catalog identity and destination filename must agree before native code can see it. + */ +export async function registerTransferredModel( + filePath: string, + modelId: string, +): Promise { + if ( + !modelId || + modelId.includes('/') || + modelId.includes('\\') || + filePath !== getModelPath(modelId) + ) { + throw new Error('Transferred Whisper model identity is invalid'); + } + await validateModelFile(filePath); +} diff --git a/src/stores/whisperStore.ts b/src/stores/whisperStore.ts index 8e4f9b4f1..412484d6b 100644 --- a/src/stores/whisperStore.ts +++ b/src/stores/whisperStore.ts @@ -224,10 +224,8 @@ export const useWhisperStore = create()( }, refreshPresentModels: async () => { - const present: string[] = []; - for (const m of WHISPER_MODELS) { - if (await whisperService.isModelDownloaded(m.id)) present.push(m.id); - } + const present = (await whisperService.listDownloadedModels()) + .map(model => model.modelId); // Reconcile the active pointer against disk too. Deleting from the // Download Manager goes through whisperService directly (bypassing this // store), so downloadedModelId can point at a model whose file is gone — From a45ba81f7db74456d52b0c7ee0683d90f64552a0 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 18:29:21 +0530 Subject: [PATCH 038/332] test(sync): bound model package journey --- __tests__/pro/sync/modelPackageTransfer.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/pro/sync/modelPackageTransfer.integration.test.ts b/__tests__/pro/sync/modelPackageTransfer.integration.test.ts index c64f1f819..e9b8cef87 100644 --- a/__tests__/pro/sync/modelPackageTransfer.integration.test.ts +++ b/__tests__/pro/sync/modelPackageTransfer.integration.test.ts @@ -309,5 +309,5 @@ describe('Pro mobile model package receiver', () => { ).rejects.toThrow( 'only text, vision, and Whisper transcription models can be sent to Off Grid Mobile', ); - }); + }, 30_000); }); From dfc16b1b5e89158bb784877f875dcf91714c3ae5 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 19:35:02 +0530 Subject: [PATCH 039/332] feat(sync): bridge mobile clipboard natively --- .../sync/clipboardSync.integration.test.tsx | 247 ++++++++++++++++++ .../java/ai/offgridmobile/MainApplication.kt | 2 + .../clipboard/SyncClipboardModule.kt | 81 ++++++ .../clipboard/SyncClipboardPackage.kt | 15 ++ .../clipboard/SyncClipboardObserverTest.kt | 43 +++ ios/OffgridMobile.xcodeproj/project.pbxproj | 8 + .../OffgridMobileTests.swift | 41 +++ ios/SyncClipboardModule.m | 9 + ios/SyncClipboardModule.swift | 121 +++++++++ pro | 2 +- src/services/sync/nativeClipboard.ts | 61 +++++ 11 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 __tests__/pro/sync/clipboardSync.integration.test.tsx create mode 100644 android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt create mode 100644 android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardPackage.kt create mode 100644 android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt create mode 100644 ios/SyncClipboardModule.m create mode 100644 ios/SyncClipboardModule.swift create mode 100644 src/services/sync/nativeClipboard.ts diff --git a/__tests__/pro/sync/clipboardSync.integration.test.tsx b/__tests__/pro/sync/clipboardSync.integration.test.tsx new file mode 100644 index 000000000..0d576377d --- /dev/null +++ b/__tests__/pro/sync/clipboardSync.integration.test.tsx @@ -0,0 +1,247 @@ +import React from 'react'; +import { + NativeEventEmitter, + NativeModules, + type EmitterSubscription, +} from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { + CLIPBOARD_CHANNEL, + MAX_CLIPBOARD_TEXT_BYTES, + type DeviceInfo, +} from '@offgrid/sync'; +import type { RnTcpModule } from '@offgrid/sync/rn'; +import { buildSyncEngine } from '../../../src/services/sync/engine'; +import type { + NativeClipboardBoundary, + NativeClipboardChange, +} from '../../../src/services/sync/nativeClipboard'; +import { + MobileClipboardSyncService, + clipboardSyncService, +} from '../../../pro/sync/clipboardSyncService'; +import { ClipboardPreferences } from '../../../pro/sync/clipboardPreferences'; +import { SyncScreen } from '../../../pro/ui/SyncScreen'; +import { + createNativeTcpBoundary, + resetDiscoveryBoundaries, +} from '../../utils/nativeSyncBoundaries'; + +jest.mock('@react-navigation/native', () => + jest.requireActual('@react-navigation/native'), +); + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary: createBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +class ClipboardBoundary implements NativeClipboardBoundary { + enabled = false; + readonly writes: string[] = []; + private listener: ((change: NativeClipboardChange) => void) | null = null; + + observe(listener: (change: NativeClipboardChange) => void): () => void { + this.enabled = true; + this.listener = listener; + return () => { + this.enabled = false; + this.listener = null; + }; + } + + copy(text: string, ts: number): void { + if (this.enabled) this.listener?.({ text, ts }); + } + + writeText(text: string): void { + this.writes.push(text); + this.copy(text, Date.now()); + } +} + +const device = (id: string, platform: DeviceInfo['platform']): DeviceInfo => ({ + id, + name: id, + platform, + version: '1', + host: '127.0.0.1', + port: 0, +}); + +describe('mobile clipboard Sync journey', () => { + beforeEach(async () => { + await clipboardSyncService.stop(); + await AsyncStorage.clear(); + resetDiscoveryBoundaries(); + }); + + afterEach(async () => { + await clipboardSyncService.stop(); + jest.restoreAllMocks(); + }); + + it('syncs opted-in native clipboard text once over the encrypted app channel', async () => { + const tcpModule = createNativeTcpBoundary() as RnTcpModule; + const mobileDevice = device('mobile-clipboard', 'ios'); + const desktopDevice = device('desktop-clipboard', 'macos'); + const connected = new Set(); + const mobileAppListeners = new Set< + (deviceId: string, channel: string, data: unknown) => void + >(); + const receivedByDesktop: unknown[] = []; + + const mobile = buildSyncEngine({ + localDevice: mobileDevice, + tcpModule, + getPassphrase: async () => 'green-river-42', + onPaired: peer => connected.add(peer.id), + onAppMessage: (deviceId, channel, data) => { + for (const listener of mobileAppListeners) { + listener(deviceId, channel, data); + } + }, + }); + const desktop = buildSyncEngine({ + localDevice: desktopDevice, + tcpModule, + getPassphrase: async () => 'green-river-42', + onAppMessage: (_deviceId, channel, data) => { + if (channel === CLIPBOARD_CHANNEL) receivedByDesktop.push(data); + }, + }); + const nativeClipboard = new ClipboardBoundary(); + const service = new MobileClipboardSyncService({ + nativeClipboard, + preferences: new ClipboardPreferences(), + transport: { + sendApp: (deviceId, channel, data) => + mobile.engine.sendApp(deviceId, channel, data), + connectedDeviceIds: () => [...connected], + onAppMessage: listener => { + mobileAppListeners.add(listener); + return () => mobileAppListeners.delete(listener); + }, + }, + now: () => 10_000, + }); + + await Promise.all([mobile.engine.start(0), desktop.engine.start(0)]); + desktopDevice.port = desktop.transport.boundPort ?? 0; + await mobile.engine.pair(desktopDevice, 'green-river-42'); + await waitFor(() => expect(connected.has(desktopDevice.id)).toBe(true)); + await service.start(); + + nativeClipboard.copy('disabled stays on phone', 1); + expect(receivedByDesktop).toEqual([]); + + await service.setEnabled(true); + expect(nativeClipboard.enabled).toBe(true); + nativeClipboard.copy('copied on iPhone', 2); + await waitFor(() => + expect(receivedByDesktop).toEqual([ + { t: 'text', text: 'copied on iPhone', ts: 2 }, + ]), + ); + + const inbound = { t: 'text', text: 'copied on Mac', ts: 3 }; + expect( + desktop.engine.sendApp(mobileDevice.id, CLIPBOARD_CHANNEL, inbound), + ).toBe(true); + await waitFor(() => + expect(nativeClipboard.writes).toEqual(['copied on Mac']), + ); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(receivedByDesktop).toHaveLength(1); + + desktop.engine.sendApp(mobileDevice.id, CLIPBOARD_CHANNEL, inbound); + desktop.engine.sendApp(mobileDevice.id, CLIPBOARD_CHANNEL, { + t: 'text', + text: 'missing timestamp', + }); + desktop.engine.sendApp(mobileDevice.id, CLIPBOARD_CHANNEL, { + t: 'text', + text: 'x'.repeat(MAX_CLIPBOARD_TEXT_BYTES + 1), + ts: 4, + }); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(nativeClipboard.writes).toEqual(['copied on Mac']); + + await service.stop(); + const restoredBoundary = new ClipboardBoundary(); + const restored = new MobileClipboardSyncService({ + nativeClipboard: restoredBoundary, + preferences: new ClipboardPreferences(), + transport: { + sendApp: (deviceId, channel, data) => + mobile.engine.sendApp(deviceId, channel, data), + connectedDeviceIds: () => [...connected], + onAppMessage: listener => { + mobileAppListeners.add(listener); + return () => mobileAppListeners.delete(listener); + }, + }, + }); + await restored.start(); + expect(restored.enabled()).toBe(true); + expect(restoredBoundary.enabled).toBe(true); + + await restored.setEnabled(false); + expect(restoredBoundary.enabled).toBe(false); + desktop.engine.sendApp(mobileDevice.id, CLIPBOARD_CHANNEL, { + t: 'text', + text: 'disabled receiver', + ts: 5, + }); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(restoredBoundary.writes).toEqual([]); + + await restored.stop(); + await Promise.all([mobile.engine.stop(), desktop.engine.stop()]); + }); + + it('exposes the persisted opt-in on the rendered Sync screen', async () => { + const nativeModule = { + setEnabled: jest.fn(), + writeText: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + }; + NativeModules.SyncClipboardModule = nativeModule; + jest + .spyOn(NativeEventEmitter.prototype, 'addListener') + .mockReturnValue({ remove: jest.fn() } as unknown as EmitterSubscription); + + const ui = render( + + + , + ); + const toggle = ui.getByTestId('sync-clipboard-toggle'); + expect(toggle.props.value).toBe(false); + + fireEvent(toggle, 'valueChange', true); + await waitFor(() => + expect(nativeModule.setEnabled).toHaveBeenCalledWith(true), + ); + expect(ui.getByTestId('sync-clipboard-toggle').props.value).toBe(true); + expect( + JSON.parse( + (await AsyncStorage.getItem('offgrid-sync-clipboard-v1')) ?? '{}', + ), + ).toEqual({ enabled: true }); + + ui.unmount(); + }); +}); diff --git a/android/app/src/main/java/ai/offgridmobile/MainApplication.kt b/android/app/src/main/java/ai/offgridmobile/MainApplication.kt index e576ce678..6529d358a 100644 --- a/android/app/src/main/java/ai/offgridmobile/MainApplication.kt +++ b/android/app/src/main/java/ai/offgridmobile/MainApplication.kt @@ -11,6 +11,7 @@ import ai.offgridmobile.localdream.LocalDreamPackage import ai.offgridmobile.pdf.PDFExtractorPackage import ai.offgridmobile.litert.LiteRTPackage import ai.offgridmobile.devicememory.DeviceMemoryPackage +import ai.offgridmobile.clipboard.SyncClipboardPackage class MainApplication : Application(), ReactApplication { @@ -25,6 +26,7 @@ class MainApplication : Application(), ReactApplication { add(PDFExtractorPackage()) add(LiteRTPackage()) add(DeviceMemoryPackage()) + add(SyncClipboardPackage()) }, ) } diff --git a/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt new file mode 100644 index 000000000..74fe36c64 --- /dev/null +++ b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt @@ -0,0 +1,81 @@ +package ai.offgridmobile.clipboard + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.core.DeviceEventManagerModule + +internal class SyncClipboardObserver( + private val context: Context, + private val clipboardManager: ClipboardManager, + private val onText: (String, Double) -> Unit, + private val now: () -> Long = System::currentTimeMillis, +) { + private var enabled = false + private val listener = ClipboardManager.OnPrimaryClipChangedListener { + if (!enabled) return@OnPrimaryClipChangedListener + val item = clipboardManager.primaryClip?.getItemAt(0) ?: return@OnPrimaryClipChangedListener + val text = item.coerceToText(context)?.toString() ?: return@OnPrimaryClipChangedListener + onText(text, now().toDouble()) + } + + fun setEnabled(next: Boolean) { + if (enabled == next) return + enabled = next + if (next) { + clipboardManager.addPrimaryClipChangedListener(listener) + } else { + clipboardManager.removePrimaryClipChangedListener(listener) + } + } + + fun writeText(text: String) { + clipboardManager.setPrimaryClip(ClipData.newPlainText("Off Grid Sync", text)) + } +} + +class SyncClipboardModule( + private val reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + private val observer = SyncClipboardObserver( + reactContext, + reactContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager, + ::emitText, + ) + + override fun getName(): String = "SyncClipboardModule" + + @ReactMethod + fun setEnabled(enabled: Boolean) { + observer.setEnabled(enabled) + } + + @ReactMethod + fun writeText(text: String) { + observer.writeText(text) + } + + @ReactMethod + fun addListener(eventName: String) { + // Required by React Native's NativeEventEmitter contract. + } + + @ReactMethod + fun removeListeners(count: Int) { + // Observation is controlled by the persisted Sync preference, not JS listener count. + } + + private fun emitText(text: String, timestamp: Double) { + val payload = Arguments.createMap().apply { + putString("text", text) + putDouble("ts", timestamp) + } + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("SyncClipboardChanged", payload) + } +} diff --git a/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardPackage.kt b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardPackage.kt new file mode 100644 index 000000000..bc99aa370 --- /dev/null +++ b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardPackage.kt @@ -0,0 +1,15 @@ +package ai.offgridmobile.clipboard + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class SyncClipboardPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(SyncClipboardModule(reactContext)) + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> = emptyList() +} diff --git a/android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt b/android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt new file mode 100644 index 000000000..00323d85c --- /dev/null +++ b/android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt @@ -0,0 +1,43 @@ +package ai.offgridmobile.clipboard + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class SyncClipboardObserverTest { + @Test + fun observesAndWritesTheRealAndroidClipboardOnlyWhileEnabled() { + val context = ApplicationProvider.getApplicationContext() + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val observed = mutableListOf>() + val observer = SyncClipboardObserver( + context, + clipboard, + { text, timestamp -> observed.add(text to timestamp) }, + now = { 42L }, + ) + + observer.setEnabled(true) + clipboard.setPrimaryClip(ClipData.newPlainText("test", "copied locally")) + + assertEquals(listOf("copied locally" to 42.0), observed) + + observer.writeText("received from desktop") + assertEquals("received from desktop", clipboard.primaryClip?.getItemAt(0)?.text) + assertEquals("received from desktop" to 42.0, observed.last()) + + observer.setEnabled(false) + clipboard.setPrimaryClip(ClipData.newPlainText("test", "must stay local")) + assertEquals(2, observed.size) + } +} diff --git a/ios/OffgridMobile.xcodeproj/project.pbxproj b/ios/OffgridMobile.xcodeproj/project.pbxproj index d2bc091e3..514a664bd 100644 --- a/ios/OffgridMobile.xcodeproj/project.pbxproj +++ b/ios/OffgridMobile.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 0A7B3D072F3A0B1200CC5FA1 /* EmbeddingModelBundleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7B3D082F3A0B1200CC5FA1 /* EmbeddingModelBundleTests.swift */; }; 0ADE3D052F3A0B1200CC5FA1 /* DeviceMemoryModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 0ADE3D012F3A0B1200CC5FA1 /* DeviceMemoryModule.m */; }; 0ADE3D062F3A0B1200CC5FA1 /* DeviceMemoryModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADE3D022F3A0B1200CC5FA1 /* DeviceMemoryModule.swift */; }; + 0C1A00012F44000100C11A00 /* SyncClipboardModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A00032F44000100C11A00 /* SyncClipboardModule.m */; }; + 0C1A00022F44000100C11A00 /* SyncClipboardModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1A00042F44000100C11A00 /* SyncClipboardModule.swift */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 553E18B7CCC207C0885499E4 /* libPods-OffgridMobileTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D1B1541769AADA563D6CC44E /* libPods-OffgridMobileTests.a */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; @@ -50,6 +52,8 @@ 0A7B3D082F3A0B1200CC5FA1 /* EmbeddingModelBundleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddingModelBundleTests.swift; sourceTree = ""; }; 0ADE3D012F3A0B1200CC5FA1 /* DeviceMemoryModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DeviceMemoryModule.m; sourceTree = ""; }; 0ADE3D022F3A0B1200CC5FA1 /* DeviceMemoryModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMemoryModule.swift; sourceTree = ""; }; + 0C1A00032F44000100C11A00 /* SyncClipboardModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SyncClipboardModule.m; sourceTree = ""; }; + 0C1A00042F44000100C11A00 /* SyncClipboardModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncClipboardModule.swift; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* OffgridMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OffgridMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = OffgridMobile/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = OffgridMobile/Info.plist; sourceTree = ""; }; @@ -98,6 +102,8 @@ 0A7B3D022F3A0B1200CC5FA1 /* PDFExtractorModule.swift */, 0ADE3D012F3A0B1200CC5FA1 /* DeviceMemoryModule.m */, 0ADE3D022F3A0B1200CC5FA1 /* DeviceMemoryModule.swift */, + 0C1A00032F44000100C11A00 /* SyncClipboardModule.m */, + 0C1A00042F44000100C11A00 /* SyncClipboardModule.swift */, 04B9D6412F38EC7700F1A435 /* DownloadManagerModule.swift */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 761780EC2CA45674006654EE /* AppDelegate.swift */, @@ -384,6 +390,8 @@ 0A7B3D042F3A0B1200CC5FA1 /* PDFExtractorModule.swift in Sources */, 0ADE3D052F3A0B1200CC5FA1 /* DeviceMemoryModule.m in Sources */, 0ADE3D062F3A0B1200CC5FA1 /* DeviceMemoryModule.swift in Sources */, + 0C1A00012F44000100C11A00 /* SyncClipboardModule.m in Sources */, + 0C1A00022F44000100C11A00 /* SyncClipboardModule.swift in Sources */, 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/OffgridMobileTests/OffgridMobileTests.swift b/ios/OffgridMobileTests/OffgridMobileTests.swift index 09d919905..5a3577aa6 100644 --- a/ios/OffgridMobileTests/OffgridMobileTests.swift +++ b/ios/OffgridMobileTests/OffgridMobileTests.swift @@ -850,3 +850,44 @@ final class AppDelegateBackgroundSessionTests: XCTestCase { ) } } + +// MARK: - Sync Clipboard Native Boundary Tests + +final class SyncClipboardObserverTests: XCTestCase { + + func testObservesAndWritesTheRealPasteboardOnlyWhileEnabled() { + let pasteboardName = UIPasteboard.Name("ai.offgridmobile.tests.\(UUID().uuidString)") + guard let pasteboard = UIPasteboard(name: pasteboardName, create: true) else { + return XCTFail("Could not create a test pasteboard") + } + defer { UIPasteboard.remove(withName: pasteboardName) } + + let notificationCenter = NotificationCenter() + var observed: [(text: String, timestamp: Double)] = [] + let observer = SyncClipboardObserver( + pasteboard: pasteboard, + notificationCenter: notificationCenter, + now: { 42 } + ) { text, timestamp in + observed.append((text, timestamp)) + } + + observer.setEnabled(true) + pasteboard.string = "copied locally" + notificationCenter.post(name: UIPasteboard.changedNotification, object: pasteboard) + + XCTAssertEqual(observed.count, 1) + XCTAssertEqual(observed.first?.text, "copied locally") + XCTAssertEqual(observed.first?.timestamp, 42_000) + + observer.writeText("received from desktop") + notificationCenter.post(name: UIPasteboard.changedNotification, object: pasteboard) + XCTAssertEqual(pasteboard.string, "received from desktop") + XCTAssertEqual(observed.last?.text, "received from desktop") + + observer.setEnabled(false) + pasteboard.string = "must stay local" + notificationCenter.post(name: UIPasteboard.changedNotification, object: pasteboard) + XCTAssertEqual(observed.count, 2) + } +} diff --git a/ios/SyncClipboardModule.m b/ios/SyncClipboardModule.m new file mode 100644 index 000000000..b8f8d3f38 --- /dev/null +++ b/ios/SyncClipboardModule.m @@ -0,0 +1,9 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(SyncClipboardModule, RCTEventEmitter) + +RCT_EXTERN_METHOD(setEnabled:(BOOL)enabled) +RCT_EXTERN_METHOD(writeText:(NSString *)text) + +@end diff --git a/ios/SyncClipboardModule.swift b/ios/SyncClipboardModule.swift new file mode 100644 index 000000000..97a5e6303 --- /dev/null +++ b/ios/SyncClipboardModule.swift @@ -0,0 +1,121 @@ +import Foundation +import UIKit + +final class SyncClipboardObserver: NSObject { + private let pasteboard: UIPasteboard + private let notificationCenter: NotificationCenter + private let now: () -> TimeInterval + private let onText: (String, Double) -> Void + private var enabled = false + private var lastChangeCount = 0 + + init( + pasteboard: UIPasteboard = .general, + notificationCenter: NotificationCenter = .default, + now: @escaping () -> TimeInterval = { Date.timeIntervalSinceReferenceDate }, + onText: @escaping (String, Double) -> Void + ) { + self.pasteboard = pasteboard + self.notificationCenter = notificationCenter + self.now = now + self.onText = onText + super.init() + } + + func setEnabled(_ next: Bool) { + guard enabled != next else { return } + enabled = next + if next { + lastChangeCount = pasteboard.changeCount + notificationCenter.addObserver( + self, + selector: #selector(clipboardChanged), + name: UIPasteboard.changedNotification, + object: pasteboard + ) + notificationCenter.addObserver( + self, + selector: #selector(clipboardChanged), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + } else { + notificationCenter.removeObserver(self) + } + } + + func writeText(_ text: String) { + pasteboard.string = text + } + + @objc private func clipboardChanged() { + guard enabled, pasteboard.changeCount != lastChangeCount else { return } + lastChangeCount = pasteboard.changeCount + guard let text = pasteboard.string else { return } + onText(text, now() * 1_000) + } + + deinit { + notificationCenter.removeObserver(self) + } +} + +@objc(SyncClipboardModule) +final class SyncClipboardModule: RCTEventEmitter { + private var hasEventListeners = false + private var observer: SyncClipboardObserver? + + @objc + override static func requiresMainQueueSetup() -> Bool { + true + } + + override func supportedEvents() -> [String] { + ["SyncClipboardChanged"] + } + + override func startObserving() { + hasEventListeners = true + } + + override func stopObserving() { + hasEventListeners = false + } + + @objc + func setEnabled(_ enabled: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if observer == nil { + observer = SyncClipboardObserver { [weak self] text, timestamp in + guard self?.hasEventListeners == true else { return } + self?.sendEvent( + withName: "SyncClipboardChanged", + body: ["text": text, "ts": timestamp] + ) + } + } + observer?.setEnabled(enabled) + } + } + + @objc + func writeText(_ text: String) { + DispatchQueue.main.async { [weak self] in + if self?.observer == nil { + self?.observer = SyncClipboardObserver { [weak self] text, timestamp in + guard self?.hasEventListeners == true else { return } + self?.sendEvent( + withName: "SyncClipboardChanged", + body: ["text": text, "ts": timestamp] + ) + } + } + self?.observer?.writeText(text) + } + } + + deinit { + observer?.setEnabled(false) + } +} diff --git a/pro b/pro index 1d204acdc..0e85bdb9a 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 1d204acdc1fe2a8f01183e6f27ea4ca565d1da1e +Subproject commit 0e85bdb9a5a17e8e2d9d225e507604e3f96f3e94 diff --git a/src/services/sync/nativeClipboard.ts b/src/services/sync/nativeClipboard.ts new file mode 100644 index 000000000..63080f053 --- /dev/null +++ b/src/services/sync/nativeClipboard.ts @@ -0,0 +1,61 @@ +import { + NativeEventEmitter, + NativeModules, + type EmitterSubscription, +} from 'react-native'; + +const CLIPBOARD_CHANGED_EVENT = 'SyncClipboardChanged'; + +export interface NativeClipboardChange { + text: string; + ts: number; +} + +interface SyncClipboardNativeModule { + setEnabled(enabled: boolean): void; + writeText(text: string): void; + addListener(eventName: string): void; + removeListeners(count: number): void; +} + +export interface NativeClipboardBoundary { + observe(listener: (change: NativeClipboardChange) => void): () => void; + writeText(text: string): void; +} + +function module(): SyncClipboardNativeModule { + const nativeModule = NativeModules.SyncClipboardModule as + | SyncClipboardNativeModule + | undefined; + if (!nativeModule) { + throw new Error('Native clipboard sync is unavailable in this build.'); + } + return nativeModule; +} + +export const nativeClipboardBoundary: NativeClipboardBoundary = { + observe(listener): () => void { + const nativeModule = module(); + const emitter = new NativeEventEmitter(nativeModule); + const subscription: EmitterSubscription = emitter.addListener( + CLIPBOARD_CHANGED_EVENT, + (value: unknown) => { + if (!value || typeof value !== 'object') return; + const change = value as Partial; + if (typeof change.text !== 'string' || typeof change.ts !== 'number') { + return; + } + listener({ text: change.text, ts: change.ts }); + }, + ); + nativeModule.setEnabled(true); + return () => { + nativeModule.setEnabled(false); + subscription.remove(); + }; + }, + + writeText(text): void { + module().writeText(text); + }, +}; From 562f11d7d242c74cb435b6114b3f9cd458efc38f Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 19:36:24 +0530 Subject: [PATCH 040/332] feat(sync): reconcile trusted devices --- __tests__/pro/sync/SyncScreen.test.tsx | 71 ++++++++- .../modelPackageTransfer.integration.test.ts | 5 +- .../sync/syncPersistence.integration.test.ts | 141 +++++++++++++++++- __tests__/pro/sync/syncStore.test.ts | 76 ++++++++-- docs/SYNC_MOBILE_PROGRESS.md | 21 ++- pro | 2 +- src/services/sync/nativeSync.ts | 2 + 7 files changed, 290 insertions(+), 28 deletions(-) diff --git a/__tests__/pro/sync/SyncScreen.test.tsx b/__tests__/pro/sync/SyncScreen.test.tsx index 4d4971252..681f93d20 100644 --- a/__tests__/pro/sync/SyncScreen.test.tsx +++ b/__tests__/pro/sync/SyncScreen.test.tsx @@ -27,6 +27,7 @@ import { createDownloadedModel } from '../../utils/factories'; import { SyncScreen } from '../../../pro/ui/SyncScreen'; import { SyncSettingsSection } from '../../../pro/ui/SyncSettingsSection'; import { useSyncStore } from '../../../pro/sync/syncStore'; +import { syncService } from '../../../pro/sync/syncService'; import { useLicensedDevicesStore } from '../../../pro/sync/licensedDevicesStore'; const mockTcpModule = { @@ -83,7 +84,8 @@ function response(status: number, body: unknown = {}): Response { } describe('Settings to Sync licensed-device management', () => { - beforeEach(() => { + beforeEach(async () => { + await syncService.stop(); jest.clearAllMocks(); _clearScreensForTesting(); _clearSectionsForTesting(); @@ -123,6 +125,16 @@ describe('Settings to Sync licensed-device management', () => { return value ? { username: 'stored', password: value } : false; }, ); + (Keychain.setGenericPassword as jest.Mock).mockImplementation( + async ( + _username: string, + password: string, + options: { service: string }, + ) => { + storedSecrets.set(options.service, password); + return true; + }, + ); const machines = [ { @@ -160,6 +172,7 @@ describe('Settings to Sync licensed-device management', () => { }); afterEach(async () => { + await syncService.stop(); global.fetch = originalFetch; _clearScreensForTesting(); _clearSectionsForTesting(); @@ -230,4 +243,60 @@ describe('Settings to Sync licensed-device management', () => { ui.unmount(); }); + + it('keeps an offline paired device visible and lets the user forget it', async () => { + storedSecrets.set( + 'off-grid-sync-pairings', + JSON.stringify({ + version: 2, + pairings: { + 'desktop-peer': { + device: { + id: 'desktop-peer', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '192.168.1.27', + port: 52095, + }, + pairedAt: Date.parse('2026-07-26T10:00:00.000Z'), + lastSeenAt: Date.parse('2026-07-27T10:00:00.000Z'), + state: 'trusted', + secret: 'saved-secret', + }, + }, + }), + ); + await syncService.start(); + const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const ui = render( + + + , + ); + + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(await waitFor(() => ui.getByTestId('open-sync-settings'))); + + const deviceRow = await waitFor(() => + ui.getByTestId('sync-paired-desktop-peer'), + ); + expect(within(deviceRow).getByText(/Offline - last seen/)).toBeTruthy(); + + fireEvent.press(ui.getByTestId('sync-forget-desktop-peer')); + const destructiveAction = (alert.mock.calls[0][2] ?? []).find( + button => button.style === 'destructive', + ); + destructiveAction?.onPress?.(); + + await waitFor(() => + expect(ui.queryByTestId('sync-paired-desktop-peer')).toBeNull(), + ); + expect( + JSON.parse(storedSecrets.get('off-grid-sync-pairings') ?? '{}'), + ).toEqual({ version: 2, pairings: {} }); + + alert.mockRestore(); + ui.unmount(); + }); }); diff --git a/__tests__/pro/sync/modelPackageTransfer.integration.test.ts b/__tests__/pro/sync/modelPackageTransfer.integration.test.ts index e9b8cef87..1112f3d16 100644 --- a/__tests__/pro/sync/modelPackageTransfer.integration.test.ts +++ b/__tests__/pro/sync/modelPackageTransfer.integration.test.ts @@ -172,7 +172,10 @@ describe('Pro mobile model package receiver', () => { await waitForState(() => useSyncStore .getState() - .paired.some(device => device.id === remoteDevice.id), + .knownDevices.some( + device => + device.id === remoteDevice.id && device.status === 'connected', + ), ); const primary = modelBytes(96 * 1024 + 4, 0x31); diff --git a/__tests__/pro/sync/syncPersistence.integration.test.ts b/__tests__/pro/sync/syncPersistence.integration.test.ts index 7aff34829..cb0be2beb 100644 --- a/__tests__/pro/sync/syncPersistence.integration.test.ts +++ b/__tests__/pro/sync/syncPersistence.integration.test.ts @@ -30,11 +30,12 @@ jest.mock('react-native-zeroconf', () => { const waitFor = async ( condition: () => boolean, timeoutMs = 3000, + label = 'Sync state', ): Promise => { const deadline = Date.now() + timeoutMs; while (!condition()) { if (Date.now() >= deadline) - throw new Error('Timed out waiting for Sync state'); + throw new Error(`Timed out waiting for ${label}`); await new Promise(resolve => setTimeout(resolve, 10)); } }; @@ -108,12 +109,20 @@ describe('Pro Sync app-lifetime pairing persistence', () => { await waitFor( () => useSyncStore.getState().incomingPairingDevice?.id === remoteDevice.id, + 3000, + 'initial incoming pairing', ); syncService.acceptIncomingPairing('blue-otter-42'); - await waitFor(() => - useSyncStore - .getState() - .paired.some(device => device.id === remoteDevice.id), + await waitFor( + () => + useSyncStore + .getState() + .knownDevices.some( + device => + device.id === remoteDevice.id && device.status === 'connected', + ), + 3000, + 'initial connected device', ); await waitFor(() => Boolean(persistedPairings)); @@ -125,12 +134,130 @@ describe('Pro Sync app-lifetime pairing persistence', () => { expect(discovery).toBeDefined(); discovery!.resolve(remoteDevice); + await waitFor( + () => + useSyncStore + .getState() + .knownDevices.some( + device => + device.id === remoteDevice.id && device.status === 'connected', + ), + 3000, + 'reconnected device', + ); + expect(useSyncStore.getState().discovered).toHaveLength(0); + + await remote.engine.stop(); + }); + + it('repairs one-sided trust and forgets the device locally and remotely', async () => { + let remoteSecret: string | undefined; + const trustMessages: unknown[] = []; + const remoteDevice: DeviceInfo = { + id: 'desktop-peer', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + let remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + getSharedSecret: deviceId => + deviceId === useSyncStore.getState().thisDevice?.id + ? remoteSecret + : undefined, + onPaired: device => { + remoteSecret = device.sharedSecret; + }, + }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + + await syncService.start(); + const mobile = useSyncStore.getState().thisDevice; + const firstDiscovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !firstDiscovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + + await remote.engine.pair( + { ...mobile, host: '127.0.0.1', port: firstDiscovery.publishedPort }, + 'blue-otter-42', + ); + await waitFor( + () => + useSyncStore.getState().incomingPairingDevice?.id === remoteDevice.id, + ); + syncService.acceptIncomingPairing('blue-otter-42'); await waitFor(() => useSyncStore .getState() - .paired.some(device => device.id === remoteDevice.id), + .knownDevices.some( + device => + device.id === remoteDevice.id && device.status === 'connected', + ), ); - expect(useSyncStore.getState().discovered).toHaveLength(0); + + await remote.engine.stop(); + await waitFor( + () => + useSyncStore + .getState() + .knownDevices.find(device => device.id === remoteDevice.id) + ?.status === 'offline', + 3000, + 'disconnect before repair', + ); + remoteSecret = undefined; + remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + getPassphrase: () => 'blue-otter-42', + getSharedSecret: () => undefined, + onPaired: device => { + remoteSecret = device.sharedSecret; + }, + onAppMessage: (_deviceId, channel, data) => { + if (channel === 'device-trust-v1') trustMessages.push(data); + }, + }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + + getDiscoveryBoundaries().at(-1)!.resolve(remoteDevice); + await waitFor( + () => + useSyncStore + .getState() + .knownDevices.find(device => device.id === remoteDevice.id) + ?.status === 'needs_repair', + 3000, + 'one-sided trust repair state', + ); + + useSyncStore.getState().setPairingCode('blue-otter-42'); + await syncService.pair(remoteDevice); + await waitFor( + () => + useSyncStore + .getState() + .knownDevices.find(device => device.id === remoteDevice.id) + ?.status === 'connected', + 3000, + 'repaired connection', + ); + expect(remoteSecret).toBeTruthy(); + + await syncService.forgetDevice(remoteDevice.id); + await waitFor(() => trustMessages.length === 1, 3000, 'remote forget'); + expect(trustMessages).toEqual([{ type: 'forget' }]); + expect(useSyncStore.getState().knownDevices).toEqual([]); + expect(JSON.parse(persistedPairings ?? '{}')).toEqual({ + version: 2, + pairings: {}, + }); await remote.engine.stop(); }); diff --git a/__tests__/pro/sync/syncStore.test.ts b/__tests__/pro/sync/syncStore.test.ts index 8e1cb07ca..adb164f59 100644 --- a/__tests__/pro/sync/syncStore.test.ts +++ b/__tests__/pro/sync/syncStore.test.ts @@ -1,22 +1,38 @@ -import { useSyncStore } from '../../../pro/sync/syncStore'; +import { + useSyncStore, + type KnownSyncDevice, +} from '../../../pro/sync/syncStore'; const discoveredDevice = (id: string, host = '1.2.3.4') => - ({ id, name: id, platform: 'macos', version: '1', host, port: 7 }) as any; -const pairedDevice = (id: string) => ({ id, name: id, platform: 'macos', version: '1', - host: '', + host, port: 7, - sharedSecret: 's', - }) as any; + lastSeen: 1, + } as const); + +const knownDevice = ( + id: string, + status: KnownSyncDevice['status'] = 'offline', +): KnownSyncDevice => ({ + id, + name: id, + platform: 'macos', + version: '1', + host: '', + port: 7, + pairedAt: 1, + lastSeenAt: 2, + status, +}); beforeEach(() => useSyncStore.getState().reset()); describe('useSyncStore', () => { - it('replaces a discovered device when the same peer moves', () => { + it('replaces an available device when the same peer moves', () => { const state = useSyncStore.getState(); state.upsertDiscovered(discoveredDevice('a', '1.1.1.1')); state.upsertDiscovered(discoveredDevice('a', '2.2.2.2')); @@ -26,31 +42,59 @@ describe('useSyncStore', () => { expect(discovered[0].host).toBe('2.2.2.2'); }); - it('moves a paired device out of the discovered list', () => { + it('keeps known devices separate from devices available to pair', () => { const state = useSyncStore.getState(); state.upsertDiscovered(discoveredDevice('a')); state.upsertDiscovered(discoveredDevice('b')); - state.addPaired(pairedDevice('a')); + state.upsertKnownDevice(knownDevice('a', 'connected')); + + expect( + useSyncStore.getState().knownDevices.map(device => device.id), + ).toEqual(['a']); + expect(useSyncStore.getState().discovered.map(device => device.id)).toEqual( + ['b'], + ); + }); + + it('projects connection, offline, and repair states without losing identity', () => { + const state = useSyncStore.getState(); + state.setKnownDevices([knownDevice('a')]); + + state.setKnownDeviceStatus('a', 'available', { + ...discoveredDevice('a', '2.2.2.2'), + port: 42, + }); + expect(useSyncStore.getState().knownDevices[0]).toMatchObject({ + id: 'a', + host: '2.2.2.2', + port: 42, + status: 'available', + }); - expect(useSyncStore.getState().paired.map((device) => device.id)).toEqual(['a']); - expect(useSyncStore.getState().discovered.map((device) => device.id)).toEqual(['b']); + state.setKnownDeviceStatus('a', 'needs_repair'); + expect(useSyncStore.getState().knownDevices[0]).toMatchObject({ + id: 'a', + status: 'needs_repair', + }); }); - it('removes only the lost discovered device', () => { + it('removes only the lost available device', () => { const state = useSyncStore.getState(); state.upsertDiscovered(discoveredDevice('a')); state.upsertDiscovered(discoveredDevice('b')); state.removeDiscovered('a'); - expect(useSyncStore.getState().discovered.map((device) => device.id)).toEqual(['b']); + expect(useSyncStore.getState().discovered.map(device => device.id)).toEqual( + ['b'], + ); }); - it('clears transient engine and pairing state on reset', () => { + it('clears transient engine and known-device projections on reset', () => { const state = useSyncStore.getState(); state.setStatus('running'); state.setPairingDevice('a', 'failed'); state.upsertDiscovered(discoveredDevice('a')); - state.addPaired(pairedDevice('b')); + state.upsertKnownDevice(knownDevice('b')); state.reset(); const reset = useSyncStore.getState(); @@ -58,6 +102,6 @@ describe('useSyncStore', () => { expect(reset.pairingDeviceId).toBeNull(); expect(reset.pairingError).toBeUndefined(); expect(reset.discovered).toEqual([]); - expect(reset.paired).toEqual([]); + expect(reset.knownDevices).toEqual([]); }); }); diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 62f7f347f..204070673 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -49,6 +49,12 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - [x] Two-device handshake (discover, NaCl pair, app message) verified manually on real devices. - [x] Pairing secrets persist in Keychain. A paired peer reconnects after the mobile Sync service restarts without asking for the pairing code again. +- [x] Pairing metadata persists independently of discovery, so trusted devices remain visible while + offline. Sync distinguishes connected, reconnecting, offline, and needs-repair states. +- [x] One-sided trust is recoverable through Pair again. Forget device clears the local secret, + disconnects the session, and notifies a connected peer to clear its trust too. +- [x] The rendered persistence journey covers restart/reconnect, one-sided trust repair, re-pair, + and two-sided forget. Settings → Sync also proves an offline device remains manageable. ### Phase 0.5 — Pro experience + licensed devices — COMPLETE @@ -103,12 +109,23 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - [x] Invalid files are rejected and both the final file and partial file are removed. - [x] Send an installed single-file GGUF from a paired device row. Sync shows transfer progress, failure, cancellation, completion, and dismissal states. +- [x] Receive mobile-compatible multi-file packages for vision and Whisper models. Package + admission reuses the mobile model registry and rejects desktop-only image and Parakeet models. - [x] The rendered AppNavigator journey covers Settings to Sync, pairing-code entry, valid receive, invalid receive, model admission, and sending the admitted model back. - [ ] Verify a full-size GGUF transfer in both directions on real iOS and Android devices. -- [ ] Add multi-file transfer before exposing vision models or other model formats. -### Phase 3 — Ambient sharing — NOT STARTED +### Phase 3 — Ambient sharing — IN PROGRESS + +- [x] Add explicit opt-in clipboard Sync on mobile. It sends text copied after the toggle is enabled + over the encrypted paired-device app channel and never sends images or files. +- [x] Native iOS and Android clipboard observers bridge local copies into Sync and apply received + text without echo loops. Payloads are deduplicated, validated, and capped at 256 KiB. +- [x] Integration coverage proves opt-in persistence, encrypted paired delivery, receive/apply, + duplicate suppression, malformed/oversized rejection, and the rendered toggle. +- [x] iOS native test, Android native test, and a signed physical-iPhone build all pass. +- [ ] Verify clipboard text in both directions against a desktop build implementing the same + `clipboard` app channel. iOS observes copies while active and rechecks on foreground. ## Security note (logged for GA) diff --git a/pro b/pro index 0e85bdb9a..e83ca1e57 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 0e85bdb9a5a17e8e2d9d225e507604e3f96f3e94 +Subproject commit e83ca1e57f3946fe826b848d4c1a71aa966764f5 diff --git a/src/services/sync/nativeSync.ts b/src/services/sync/nativeSync.ts index 5e89b2561..057729a66 100644 --- a/src/services/sync/nativeSync.ts +++ b/src/services/sync/nativeSync.ts @@ -40,6 +40,7 @@ export interface NativeSync { stop(): Promise; rescan(): Promise; pair(device: DeviceInfo, passphrase: string): Promise; + disconnect(deviceId: string): boolean; send(deviceId: string, message: Message): boolean; sendApp(deviceId: string, channel: string, data: unknown): boolean; isPaired(deviceId: string): boolean; @@ -107,6 +108,7 @@ export function createNativeSync( } }, pair: (device, passphrase) => engine.pair(device, passphrase), + disconnect: deviceId => engine.disconnect(deviceId), send: (deviceId, message) => engine.send(deviceId, message), sendApp: (deviceId, channel, data) => engine.sendApp(deviceId, channel, data), From e339d6e9fa27fcb191d3324746ea0fbc15428421 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 19:50:27 +0530 Subject: [PATCH 041/332] test(clipboard): prove attributed mobile history --- .../sync/clipboardSync.integration.test.tsx | 178 ++++++++++++++++-- docs/SYNC_MOBILE_PROGRESS.md | 7 + pro | 2 +- 3 files changed, 173 insertions(+), 14 deletions(-) diff --git a/__tests__/pro/sync/clipboardSync.integration.test.tsx b/__tests__/pro/sync/clipboardSync.integration.test.tsx index 0d576377d..51e7ec3c5 100644 --- a/__tests__/pro/sync/clipboardSync.integration.test.tsx +++ b/__tests__/pro/sync/clipboardSync.integration.test.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { + Alert, NativeEventEmitter, NativeModules, type EmitterSubscription, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; +import TcpSocket from 'react-native-tcp-socket'; import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; import { @@ -23,11 +26,29 @@ import { clipboardSyncService, } from '../../../pro/sync/clipboardSyncService'; import { ClipboardPreferences } from '../../../pro/sync/clipboardPreferences'; +import { ClipboardHistoryStore } from '../../../pro/sync/clipboardHistoryStore'; +import { syncService } from '../../../pro/sync/syncService'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { ClipboardScreen } from '../../../pro/ui/ClipboardScreen'; import { SyncScreen } from '../../../pro/ui/SyncScreen'; +import { SyncSettingsSection } from '../../../pro/ui/SyncSettingsSection'; +import { ProRoot } from '../../../pro/ui/ProRoot'; +import { AppNavigator } from '../../../src/navigation/AppNavigator'; +import { + registerScreen, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; +import { + registerSettingsSection, + _clearSectionsForTesting, +} from '../../../src/components/settings/sectionRegistry'; +import { useAppStore } from '../../../src/stores/appStore'; import { createNativeTcpBoundary, + getDiscoveryBoundaries, resetDiscoveryBoundaries, } from '../../utils/nativeSyncBoundaries'; +import { createDownloadedModel } from '../../utils/factories'; jest.mock('@react-navigation/native', () => jest.requireActual('@react-navigation/native'), @@ -81,14 +102,36 @@ const device = (id: string, platform: DeviceInfo['platform']): DeviceInfo => ({ }); describe('mobile clipboard Sync journey', () => { + let remote: ReturnType | undefined; + let ui: ReturnType | undefined; + beforeEach(async () => { await clipboardSyncService.stop(); await AsyncStorage.clear(); + await clipboardSyncService.clearHistory(); + await clipboardSyncService.stop(); resetDiscoveryBoundaries(); + _clearScreensForTesting(); + _clearSectionsForTesting(); + registerScreen({ name: 'Sync', component: SyncScreen }); + registerScreen({ name: 'Clipboard', component: ClipboardScreen }); + registerSettingsSection(SyncSettingsSection); + useAppStore.getState().setOnboardingComplete(true); + useAppStore + .getState() + .setDownloadedModels([createDownloadedModel({ engine: 'litert' })]); + useSyncStore.getState().reset(); + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(false); + (Keychain.setGenericPassword as jest.Mock).mockResolvedValue(true); }); afterEach(async () => { + ui?.unmount(); + await remote?.engine.stop(); + await syncService.stop(); await clipboardSyncService.stop(); + _clearScreensForTesting(); + _clearSectionsForTesting(); jest.restoreAllMocks(); }); @@ -122,13 +165,17 @@ describe('mobile clipboard Sync journey', () => { }, }); const nativeClipboard = new ClipboardBoundary(); + const history = new ClipboardHistoryStore(); const service = new MobileClipboardSyncService({ nativeClipboard, preferences: new ClipboardPreferences(), + history, transport: { sendApp: (deviceId, channel, data) => mobile.engine.sendApp(deviceId, channel, data), connectedDeviceIds: () => [...connected], + deviceName: deviceId => + deviceId === desktopDevice.id ? 'Off Grid AI Desktop' : undefined, onAppMessage: listener => { mobileAppListeners.add(listener); return () => mobileAppListeners.delete(listener); @@ -162,6 +209,20 @@ describe('mobile clipboard Sync journey', () => { await waitFor(() => expect(nativeClipboard.writes).toEqual(['copied on Mac']), ); + await waitFor(() => + expect(service.historySnapshot()).toEqual([ + expect.objectContaining({ + text: 'copied on Mac', + source: 'remote', + sourceDeviceName: 'Off Grid AI Desktop', + }), + expect.objectContaining({ + text: 'copied on iPhone', + source: 'local', + sourceDeviceName: 'This phone', + }), + ]), + ); await new Promise(resolve => setTimeout(resolve, 50)); expect(receivedByDesktop).toHaveLength(1); @@ -187,6 +248,8 @@ describe('mobile clipboard Sync journey', () => { sendApp: (deviceId, channel, data) => mobile.engine.sendApp(deviceId, channel, data), connectedDeviceIds: () => [...connected], + deviceName: deviceId => + deviceId === desktopDevice.id ? 'Off Grid AI Desktop' : undefined, onAppMessage: listener => { mobileAppListeners.add(listener); return () => mobileAppListeners.delete(listener); @@ -211,7 +274,8 @@ describe('mobile clipboard Sync journey', () => { await Promise.all([mobile.engine.stop(), desktop.engine.stop()]); }); - it('exposes the persisted opt-in on the rendered Sync screen', async () => { + it('shows attributed clipboard history through Settings and manages it', async () => { + let nativeChange: ((change: NativeClipboardChange) => void) | undefined; const nativeModule = { setEnabled: jest.fn(), writeText: jest.fn(), @@ -221,27 +285,115 @@ describe('mobile clipboard Sync journey', () => { NativeModules.SyncClipboardModule = nativeModule; jest .spyOn(NativeEventEmitter.prototype, 'addListener') - .mockReturnValue({ remove: jest.fn() } as unknown as EmitterSubscription); + .mockImplementation((eventName, listener) => { + if (eventName === 'SyncClipboardChanged') { + nativeChange = listener as (change: NativeClipboardChange) => void; + } + return { remove: jest.fn() } as unknown as EmitterSubscription; + }); + + const remoteDevice: DeviceInfo = { + id: 'clipboard-desktop', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: TcpSocket as unknown as RnTcpModule, + }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + await syncService.start(); - const ui = render( - - - , + ui = render( + <> + + + + + , + ); + fireEvent.press(ui.getByTestId('settings-tab')); + fireEvent.press(await waitFor(() => ui!.getByTestId('open-sync-settings'))); + await waitFor(() => + expect(ui!.getByText('Discoverable on your Wi-Fi')).toBeTruthy(), + ); + + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !discovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + const pairing = remote.engine.pair( + { + ...mobile, + host: '127.0.0.1', + port: discovery.publishedPort, + }, + 'green-river-42', + ); + await waitFor(() => + expect(ui!.getByText('Pair with Off Grid AI Desktop')).toBeTruthy(), ); + fireEvent.changeText( + ui.getByTestId('incoming-pairing-code'), + 'green-river-42', + ); + fireEvent.press(ui.getByTestId('accept-incoming-pairing')); + await pairing; + const toggle = ui.getByTestId('sync-clipboard-toggle'); expect(toggle.props.value).toBe(false); - fireEvent(toggle, 'valueChange', true); await waitFor(() => expect(nativeModule.setEnabled).toHaveBeenCalledWith(true), ); - expect(ui.getByTestId('sync-clipboard-toggle').props.value).toBe(true); + expect(nativeChange).toBeDefined(); + + nativeChange?.({ text: 'copied on iPhone', ts: 1000 }); + await waitFor(() => + expect( + remote!.engine.sendApp(mobile.id, 'clipboard-test-ready', {}), + ).toBe(true), + ); expect( - JSON.parse( - (await AsyncStorage.getItem('offgrid-sync-clipboard-v1')) ?? '{}', - ), - ).toEqual({ enabled: true }); + remote.engine.sendApp(mobile.id, CLIPBOARD_CHANNEL, { + t: 'text', + text: 'copied on Mac', + ts: 2000, + }), + ).toBe(true); + await waitFor(() => + expect(nativeModule.writeText).toHaveBeenCalledWith('copied on Mac'), + ); - ui.unmount(); + fireEvent.press(ui.getByTestId('open-clipboard-history')); + await waitFor(() => expect(ui!.getByText('copied on iPhone')).toBeTruthy()); + expect(ui.getByText('This phone')).toBeTruthy(); + expect(ui.getByText('copied on Mac')).toBeTruthy(); + expect(ui.getByText('From Off Grid AI Desktop')).toBeTruthy(); + + fireEvent.press( + ui.getAllByLabelText('Copy text from Off Grid AI Desktop')[0], + ); + await waitFor(() => + expect(nativeModule.writeText).toHaveBeenLastCalledWith('copied on Mac'), + ); + + fireEvent.press(ui.getByLabelText('Delete text from Off Grid AI Desktop')); + await waitFor(() => expect(ui!.queryByText('copied on Mac')).toBeNull()); + + const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + fireEvent.press(ui.getByTestId('clipboard-clear')); + const clear = (alert.mock.calls[0][2] ?? []).find( + button => button.style === 'destructive', + ); + clear?.onPress?.(); + await waitFor(() => + expect(ui!.getByTestId('clipboard-empty')).toBeTruthy(), + ); }); }); diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 204070673..c15e5a52e 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -123,6 +123,13 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as text without echo loops. Payloads are deduplicated, validated, and capped at 256 KiB. - [x] Integration coverage proves opt-in persistence, encrypted paired delivery, receive/apply, duplicate suppression, malformed/oversized rejection, and the rendered toggle. +- [x] Settings → Sync → View clipboard opens a persistent text history with source attribution + (`This phone` or the paired device name). Tapping restores a clip to the system clipboard; + individual delete and confirmed Clear are available. Retention is bounded to 100 entries and + 1 MiB of text. +- [x] The rendered AppNavigator journey pairs a real loopback peer, captures one local and one + encrypted remote clip, proves both source labels, restores the remote clip, deletes it, and + clears the remaining history. - [x] iOS native test, Android native test, and a signed physical-iPhone build all pass. - [ ] Verify clipboard text in both directions against a desktop build implementing the same `clipboard` app channel. iOS observes copies while active and rechecks on foreground. diff --git a/pro b/pro index e83ca1e57..7d61bb2b7 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit e83ca1e57f3946fe826b848d4c1a71aa966764f5 +Subproject commit 7d61bb2b7dfe8cacdda7bec44b3ec979079df92c From 4d041e240ea58cc8f83f5ee110297ffdf6060da0 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Mon, 27 Jul 2026 20:11:26 +0530 Subject: [PATCH 042/332] fix(clipboard): preserve remote device attribution --- .../offgridmobile/clipboard/SyncClipboardModule.kt | 12 ++++++++++-- .../clipboard/SyncClipboardObserverTest.kt | 8 ++++++-- ios/OffgridMobileTests/OffgridMobileTests.swift | 8 ++++++-- ios/SyncClipboardModule.swift | 11 +++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt index 74fe36c64..218641f88 100644 --- a/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/clipboard/SyncClipboardModule.kt @@ -18,7 +18,11 @@ internal class SyncClipboardObserver( private var enabled = false private val listener = ClipboardManager.OnPrimaryClipChangedListener { if (!enabled) return@OnPrimaryClipChangedListener - val item = clipboardManager.primaryClip?.getItemAt(0) ?: return@OnPrimaryClipChangedListener + val clip = clipboardManager.primaryClip ?: return@OnPrimaryClipChangedListener + if (clip.description.label?.toString() == SYNC_CLIP_LABEL) { + return@OnPrimaryClipChangedListener + } + val item = clip.getItemAt(0) val text = item.coerceToText(context)?.toString() ?: return@OnPrimaryClipChangedListener onText(text, now().toDouble()) } @@ -34,7 +38,11 @@ internal class SyncClipboardObserver( } fun writeText(text: String) { - clipboardManager.setPrimaryClip(ClipData.newPlainText("Off Grid Sync", text)) + clipboardManager.setPrimaryClip(ClipData.newPlainText(SYNC_CLIP_LABEL, text)) + } + + private companion object { + const val SYNC_CLIP_LABEL = "Off Grid Sync" } } diff --git a/android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt b/android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt index 00323d85c..bad9d2925 100644 --- a/android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt +++ b/android/app/src/test/java/ai/offgridmobile/clipboard/SyncClipboardObserverTest.kt @@ -34,10 +34,14 @@ class SyncClipboardObserverTest { observer.writeText("received from desktop") assertEquals("received from desktop", clipboard.primaryClip?.getItemAt(0)?.text) - assertEquals("received from desktop" to 42.0, observed.last()) + assertEquals( + "A programmatic Sync write must not be attributed as a local copy", + listOf("copied locally" to 42.0), + observed, + ) observer.setEnabled(false) clipboard.setPrimaryClip(ClipData.newPlainText("test", "must stay local")) - assertEquals(2, observed.size) + assertEquals(1, observed.size) } } diff --git a/ios/OffgridMobileTests/OffgridMobileTests.swift b/ios/OffgridMobileTests/OffgridMobileTests.swift index 5a3577aa6..e080d7aba 100644 --- a/ios/OffgridMobileTests/OffgridMobileTests.swift +++ b/ios/OffgridMobileTests/OffgridMobileTests.swift @@ -883,11 +883,15 @@ final class SyncClipboardObserverTests: XCTestCase { observer.writeText("received from desktop") notificationCenter.post(name: UIPasteboard.changedNotification, object: pasteboard) XCTAssertEqual(pasteboard.string, "received from desktop") - XCTAssertEqual(observed.last?.text, "received from desktop") + XCTAssertEqual( + observed.map(\.text), + ["copied locally"], + "A programmatic Sync write must not be attributed as a local copy" + ) observer.setEnabled(false) pasteboard.string = "must stay local" notificationCenter.post(name: UIPasteboard.changedNotification, object: pasteboard) - XCTAssertEqual(observed.count, 2) + XCTAssertEqual(observed.count, 1) } } diff --git a/ios/SyncClipboardModule.swift b/ios/SyncClipboardModule.swift index 97a5e6303..d3846dd56 100644 --- a/ios/SyncClipboardModule.swift +++ b/ios/SyncClipboardModule.swift @@ -8,6 +8,7 @@ final class SyncClipboardObserver: NSObject { private let onText: (String, Double) -> Void private var enabled = false private var lastChangeCount = 0 + private var isWritingSyncedText = false init( pasteboard: UIPasteboard = .general, @@ -45,10 +46,20 @@ final class SyncClipboardObserver: NSObject { } func writeText(_ text: String) { + isWritingSyncedText = true pasteboard.string = text + // A Sync write updates the system pasteboard but is not a new local copy. + // Seed the observed generation so a delayed pasteboard/application + // notification cannot publish it back to the sender. + lastChangeCount = pasteboard.changeCount + isWritingSyncedText = false } @objc private func clipboardChanged() { + if isWritingSyncedText { + lastChangeCount = pasteboard.changeCount + return + } guard enabled, pasteboard.changeCount != lastChangeCount else { return } lastChangeCount = pasteboard.changeCount guard let text = pasteboard.string else { return } From 9deceba5fcdc7630c2f826cb2b738b4e0e3015b0 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Tue, 28 Jul 2026 07:39:07 +0530 Subject: [PATCH 043/332] feat(sync): give knowledge documents stable identity --- __tests__/harness/sqliteFake.ts | 37 +++- ...wledgeDocumentIdentity.integration.test.ts | 52 +++++ __tests__/unit/services/rag/database.test.ts | 119 +++++++--- __tests__/unit/services/rag/index.test.ts | 203 ------------------ src/bootstrap/hookRegistry.ts | 3 + src/services/rag/database.ts | 151 ++++++++++--- src/services/rag/index.ts | 155 +++++++++++-- src/services/sync/knowledgeDocument.ts | 29 +++ 8 files changed, 465 insertions(+), 284 deletions(-) create mode 100644 __tests__/integration/knowledge-base/knowledgeDocumentIdentity.integration.test.ts delete mode 100644 __tests__/unit/services/rag/index.test.ts create mode 100644 src/services/sync/knowledgeDocument.ts diff --git a/__tests__/harness/sqliteFake.ts b/__tests__/harness/sqliteFake.ts index 33ff89a4e..8d71dad2b 100644 --- a/__tests__/harness/sqliteFake.ts +++ b/__tests__/harness/sqliteFake.ts @@ -7,9 +7,9 @@ * Call at the top of a test body; it jest.resetModules() + doMock('@op-engineering/op-sqlite'), then the * test require()s the rag modules so they open the real :memory: db. Each install = a fresh empty db. */ -export function installRealSqlite(): void { +export function installRealSqlite(setupSql?: string): void { jest.resetModules(); - doMockRealSqlite(); + doMockRealSqlite(setupSql); } /** @@ -17,22 +17,26 @@ export function installRealSqlite(): void { * already reset modules (e.g. installNativeBoundary for a mounted-screen RAG test). Call AFTER that installer, * before requiring the rag modules. installRealSqlite = resetModules + this. */ -export function doMockRealSqlite(): void { +export function doMockRealSqlite(setupSql?: string): void { jest.doMock('@op-engineering/op-sqlite', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires const { DatabaseSync } = require('node:sqlite'); const wrap = (db: any) => ({ executeSync: (sql: string, params: unknown[] = []) => { - const bind = (params ?? []).map((p) => + const bind = (params ?? []).map(p => // op-sqlite accepts ArrayBuffer for BLOBs; node:sqlite wants a Uint8Array/Buffer. Use a // realm-safe check (Object.prototype.toString) because a composed harness (installNativeBoundary // + doMockRealSqlite) can hand us an ArrayBuffer from a different realm where `instanceof` fails. - (p instanceof ArrayBuffer || Object.prototype.toString.call(p) === '[object ArrayBuffer]') - ? new Uint8Array(p as ArrayBuffer) : p, + p instanceof ArrayBuffer || + Object.prototype.toString.call(p) === '[object ArrayBuffer]' + ? new Uint8Array(p as ArrayBuffer) + : p, ); // Transaction / DDL control statements: no params, run via exec. - if (/^\s*(BEGIN|COMMIT|ROLLBACK|CREATE|PRAGMA|DROP)/i.test(sql) && bind.length === 0) { + if ( + /^\s*(BEGIN|COMMIT|ROLLBACK|CREATE|PRAGMA|DROP)/i.test(sql) && + bind.length === 0 + ) { db.exec(sql); return { rows: [], insertId: undefined, rowsAffected: 0 }; } @@ -44,15 +48,26 @@ export function doMockRealSqlite(): void { const info = stmt.run(...bind); return { rows: [], - insertId: info.lastInsertRowid != null ? Number(info.lastInsertRowid) : undefined, + insertId: + info.lastInsertRowid != null + ? Number(info.lastInsertRowid) + : undefined, rowsAffected: Number(info.changes ?? 0), }; }, - execute: async function (this: any, sql: string, params: unknown[] = []) { return this.executeSync(sql, params); }, + execute: async function (this: any, sql: string, params: unknown[] = []) { + return this.executeSync(sql, params); + }, close: () => db.close(), delete: () => {}, }); - return { open: () => wrap(new DatabaseSync(':memory:')) }; + return { + open: () => { + const db = new DatabaseSync(':memory:'); + if (setupSql) db.exec(setupSql); + return wrap(db); + }, + }; }); } diff --git a/__tests__/integration/knowledge-base/knowledgeDocumentIdentity.integration.test.ts b/__tests__/integration/knowledge-base/knowledgeDocumentIdentity.integration.test.ts new file mode 100644 index 000000000..97d711922 --- /dev/null +++ b/__tests__/integration/knowledge-base/knowledgeDocumentIdentity.integration.test.ts @@ -0,0 +1,52 @@ +import { installRealSqlite } from '../../harness/sqliteFake'; + +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe('knowledge document Sync identity', () => { + it('backfills legacy rows once and assigns stable UUIDs to new documents', async () => { + installRealSqlite(` + CREATE TABLE rag_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + size INTEGER NOT NULL, + created_at TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 + ); + INSERT INTO rag_documents + (project_id, name, path, size, created_at, enabled) + VALUES + ('project-1', 'legacy.txt', '/docs/legacy.txt', 12, '2026-07-01T00:00:00.000Z', 1); + `); + + const { ragDatabase } = + require('../../../src/services/rag/database') as typeof import('../../../src/services/rag/database'); + await ragDatabase.ensureReady(); + + const firstRead = ragDatabase.getAllDocuments(); + expect(firstRead).toHaveLength(1); + expect(firstRead[0].sync_id).toMatch(UUID_V4); + + const legacySyncId = firstRead[0].sync_id; + const newDocumentId = ragDatabase.insertDocument({ + projectId: 'project-1', + name: 'new.txt', + path: '/docs/new.txt', + size: 7, + }); + const newDocument = ragDatabase.getDocument(newDocumentId); + + expect(newDocument?.sync_id).toMatch(UUID_V4); + expect(newDocument?.sync_id).not.toBe(legacySyncId); + expect(ragDatabase.getDocumentBySyncId(legacySyncId)?.name).toBe( + 'legacy.txt', + ); + + await ragDatabase.ensureReady(); + expect(ragDatabase.getDocument(firstRead[0].id)?.sync_id).toBe( + legacySyncId, + ); + }); +}); diff --git a/__tests__/unit/services/rag/database.test.ts b/__tests__/unit/services/rag/database.test.ts index 206f587fd..61801ca12 100644 --- a/__tests__/unit/services/rag/database.test.ts +++ b/__tests__/unit/services/rag/database.test.ts @@ -4,7 +4,9 @@ import { open } from '@op-engineering/op-sqlite'; const mockExecuteSync = jest.fn(); const mockDb = { executeSync: mockExecuteSync, - execute: jest.fn(() => Promise.resolve({ rows: [], insertId: 0, rowsAffected: 0 })), + execute: jest.fn(() => + Promise.resolve({ rows: [], insertId: 0, rowsAffected: 0 }), + ), close: jest.fn(), delete: jest.fn(), }; @@ -22,7 +24,7 @@ import { ragDatabase } from '../../../../src/services/rag/database'; function expectDeleteCascade() { const deleteCalls = mockExecuteSync.mock.calls.filter( - (c: any[]) => typeof c[0] === 'string' && c[0].includes('DELETE') + (c: any[]) => typeof c[0] === 'string' && c[0].includes('DELETE'), ); expect(deleteCalls).toHaveLength(3); expect(deleteCalls[0][0]).toContain('rag_embeddings'); @@ -42,11 +44,17 @@ describe('RagDatabase', () => { it('opens the database and creates tables', async () => { await ragDatabase.ensureReady(); expect(open).toHaveBeenCalledWith({ name: 'rag.db' }); - // rag_documents, rag_chunks, rag_embeddings = 3 tables - expect(mockExecuteSync).toHaveBeenCalledTimes(3); - expect(mockExecuteSync.mock.calls[0][0]).toContain('rag_documents'); - expect(mockExecuteSync.mock.calls[1][0]).toContain('rag_chunks'); - expect(mockExecuteSync.mock.calls[2][0]).toContain('rag_embeddings'); + const tableCreates = mockExecuteSync.mock.calls + .map(call => call[0]) + .filter( + sql => + typeof sql === 'string' && + sql.includes('CREATE TABLE IF NOT EXISTS'), + ); + expect(tableCreates).toHaveLength(3); + expect(tableCreates[0]).toContain('rag_documents'); + expect(tableCreates[1]).toContain('rag_chunks'); + expect(tableCreates[2]).toContain('rag_embeddings'); }); it('does not re-initialize on second call', async () => { @@ -60,13 +68,22 @@ describe('RagDatabase', () => { describe('insertDocument', () => { it('inserts a document and returns the id', async () => { await ragDatabase.ensureReady(); - mockExecuteSync.mockReturnValue({ insertId: 42, rowsAffected: 1, rows: [] }); + mockExecuteSync.mockReturnValue({ + insertId: 42, + rowsAffected: 1, + rows: [], + }); - const id = ragDatabase.insertDocument({ projectId: 'proj1', name: 'test.txt', path: '/path/test.txt', size: 1234 }); + const id = ragDatabase.insertDocument({ + projectId: 'proj1', + name: 'test.txt', + path: '/path/test.txt', + size: 1234, + }); expect(id).toBe(42); expect(mockExecuteSync).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO rag_documents'), - expect.arrayContaining(['proj1', 'test.txt', '/path/test.txt', 1234]) + expect.arrayContaining(['proj1', 'test.txt', '/path/test.txt', 1234]), ); }); }); @@ -74,7 +91,11 @@ describe('RagDatabase', () => { describe('insertChunks', () => { it('inserts each chunk and returns rowids', async () => { await ragDatabase.ensureReady(); - mockExecuteSync.mockReturnValue({ insertId: 10, rowsAffected: 1, rows: [] }); + mockExecuteSync.mockReturnValue({ + insertId: 10, + rowsAffected: 1, + rows: [], + }); const chunks = [ { content: 'chunk one', position: 0 }, @@ -83,7 +104,8 @@ describe('RagDatabase', () => { const rowIds = ragDatabase.insertChunks(42, chunks); expect(rowIds).toEqual([10, 10]); // mock always returns 10 const chunkInserts = mockExecuteSync.mock.calls.filter( - (c: any[]) => typeof c[0] === 'string' && c[0].includes('INSERT INTO rag_chunks') + (c: any[]) => + typeof c[0] === 'string' && c[0].includes('INSERT INTO rag_chunks'), ); expect(chunkInserts).toHaveLength(2); expect(chunkInserts[0][1]).toEqual(['chunk one', 42, 0]); @@ -100,7 +122,9 @@ describe('RagDatabase', () => { ]); const embInserts = mockExecuteSync.mock.calls.filter( - (c: any[]) => typeof c[0] === 'string' && c[0].includes('INSERT INTO rag_embeddings') + (c: any[]) => + typeof c[0] === 'string' && + c[0].includes('INSERT INTO rag_embeddings'), ); expect(embInserts).toHaveLength(2); }); @@ -111,10 +135,16 @@ describe('RagDatabase', () => { await ragDatabase.ensureReady(); const embBuffer = new Float32Array([0.1, 0.2]).buffer; mockExecuteSync.mockReturnValue({ - rows: [{ - chunk_rowid: 1, doc_id: 42, name: 'doc.txt', - content: 'hello', position: 0, embedding: embBuffer, - }], + rows: [ + { + chunk_rowid: 1, + doc_id: 42, + name: 'doc.txt', + content: 'hello', + position: 0, + embedding: embBuffer, + }, + ], }); const results = ragDatabase.getEmbeddingsByProject('proj1'); @@ -165,7 +195,15 @@ describe('RagDatabase', () => { it('returns documents for the given project', async () => { await ragDatabase.ensureReady(); const mockDocs = [ - { id: 1, project_id: 'proj1', name: 'doc1.txt', path: '/p', size: 100, created_at: '2024-01-01', enabled: 1 }, + { + id: 1, + project_id: 'proj1', + name: 'doc1.txt', + path: '/p', + size: 100, + created_at: '2024-01-01', + enabled: 1, + }, ]; mockExecuteSync.mockReturnValue({ rows: mockDocs }); @@ -179,7 +217,7 @@ describe('RagDatabase', () => { await ragDatabase.ensureReady(); ragDatabase.toggleEnabled(42, false); const updateCalls = mockExecuteSync.mock.calls.filter( - (c: any[]) => typeof c[0] === 'string' && c[0].includes('UPDATE') + (c: any[]) => typeof c[0] === 'string' && c[0].includes('UPDATE'), ); expect(updateCalls).toHaveLength(1); expect(updateCalls[0][1]).toEqual([0, 42]); @@ -190,7 +228,13 @@ describe('RagDatabase', () => { it('returns chunks for a project', async () => { await ragDatabase.ensureReady(); const mockResults = [ - { doc_id: 1, name: 'doc.txt', content: 'some content', position: 0, score: 0 }, + { + doc_id: 1, + name: 'doc.txt', + content: 'some content', + position: 0, + score: 0, + }, ]; mockExecuteSync.mockReturnValue({ rows: mockResults }); @@ -212,7 +256,14 @@ describe('RagDatabase', () => { it('throws if getDb called before ensureReady', () => { (ragDatabase as any).ready = false; (ragDatabase as any).db = null; - expect(() => ragDatabase.insertDocument({ projectId: 'p', name: 'n', path: 'path', size: 0 })).toThrow('not initialized'); + expect(() => + ragDatabase.insertDocument({ + projectId: 'p', + name: 'n', + path: 'path', + size: 0, + }), + ).toThrow('not initialized'); }); it('rolls back insertChunks transaction on error', async () => { @@ -226,28 +277,34 @@ describe('RagDatabase', () => { return { insertId: 1, rowsAffected: 1, rows: [] }; }); - expect(() => ragDatabase.insertChunks(42, [ - { content: 'chunk', position: 0 }, - ])).toThrow('insert failed'); + expect(() => + ragDatabase.insertChunks(42, [{ content: 'chunk', position: 0 }]), + ).toThrow('insert failed'); - const rollbackCall = mockExecuteSync.mock.calls.find((c: any[]) => c[0] === 'ROLLBACK'); + const rollbackCall = mockExecuteSync.mock.calls.find( + (c: any[]) => c[0] === 'ROLLBACK', + ); expect(rollbackCall).toBeDefined(); }); it('rolls back insertEmbeddingsBatch transaction on error', async () => { await ragDatabase.ensureReady(); mockExecuteSync.mockImplementation((sql: string) => { - if (sql.includes('INSERT INTO rag_embeddings')) throw new Error('embed failed'); + if (sql.includes('INSERT INTO rag_embeddings')) + throw new Error('embed failed'); return { insertId: 1, rowsAffected: 1, rows: [] }; }); - expect(() => ragDatabase.insertEmbeddingsBatch([ - { chunkRowid: 1, docId: 42, embedding: [0.1, 0.2] }, - ])).toThrow('embed failed'); + expect(() => + ragDatabase.insertEmbeddingsBatch([ + { chunkRowid: 1, docId: 42, embedding: [0.1, 0.2] }, + ]), + ).toThrow('embed failed'); - const rollbackCall = mockExecuteSync.mock.calls.find((c: any[]) => c[0] === 'ROLLBACK'); + const rollbackCall = mockExecuteSync.mock.calls.find( + (c: any[]) => c[0] === 'ROLLBACK', + ); expect(rollbackCall).toBeDefined(); }); - }); }); diff --git a/__tests__/unit/services/rag/index.test.ts b/__tests__/unit/services/rag/index.test.ts deleted file mode 100644 index af4b3cd41..000000000 --- a/__tests__/unit/services/rag/index.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -jest.mock('../../../../src/services/rag/database', () => ({ - ragDatabase: { - ensureReady: jest.fn(() => Promise.resolve()), - insertDocument: jest.fn((_doc: any) => 1), - insertChunks: jest.fn(() => [1, 2]), - deleteDocument: jest.fn(), - getDocumentsByProject: jest.fn(() => []), - toggleEnabled: jest.fn(), - getChunksByProject: jest.fn(() => []), - getEmbeddingsByProject: jest.fn(() => []), - insertEmbeddingsBatch: jest.fn(), - hasEmbeddingsForDocument: jest.fn(() => false), - getChunksByDocument: jest.fn(() => []), - deleteDocumentsByProject: jest.fn(), - }, -})); - -jest.mock('../../../../src/services/rag/embedding', () => ({ - embeddingService: { - load: jest.fn(() => Promise.resolve()), - embedBatch: jest.fn(() => Promise.resolve([[0.1, 0.2], [0.3, 0.4]])), - isLoaded: jest.fn(() => false), - }, -})); - -jest.mock('../../../../src/services/documentService', () => ({ - documentService: { - processDocumentFromPath: jest.fn(() => Promise.resolve({ - id: '1', - type: 'document', - uri: '/path/to/doc', - fileName: 'test.txt', - textContent: 'This is a long enough test document content that should be chunked properly by the service.', - fileSize: 100, - })), - }, -})); - -jest.mock('../../../../src/utils/logger', () => ({ - __esModule: true, - default: { log: jest.fn(), error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, -})); - -import { ragService } from '../../../../src/services/rag'; -import { ragDatabase } from '../../../../src/services/rag/database'; -import { embeddingService } from '../../../../src/services/rag/embedding'; -import { documentService } from '../../../../src/services/documentService'; - -const mockDb = ragDatabase as jest.Mocked; -const mockDocService = documentService as jest.Mocked; -const mockEmbedding = embeddingService as jest.Mocked; - -describe('RagService', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('ensureReady', () => { - it('calls ragDatabase.ensureReady', async () => { - await ragService.ensureReady(); - expect(mockDb.ensureReady).toHaveBeenCalled(); - }); - }); - - describe('indexDocument', () => { - it('extracts text, chunks, stores, and generates embeddings', async () => { - const onProgress = jest.fn(); - const docId = await ragService.indexDocument({ projectId: 'proj1', filePath: '/path/test.txt', fileName: 'test.txt', fileSize: 100, onProgress }); - - expect(mockDocService.processDocumentFromPath).toHaveBeenCalledWith('/path/test.txt', 'test.txt', 500_000); - expect(mockDb.insertDocument).toHaveBeenCalledWith({ projectId: 'proj1', name: 'test.txt', path: '/path/test.txt', size: 100 }); - expect(mockDb.insertChunks).toHaveBeenCalled(); - expect(docId).toBe(1); - - // Progress callbacks include new 'embedding' stage - expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ stage: 'extracting' })); - expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ stage: 'chunking' })); - expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ stage: 'indexing' })); - expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ stage: 'embedding' })); - expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ stage: 'done' })); - - // Verify embeddings were generated - expect(mockEmbedding.load).toHaveBeenCalled(); - expect(mockEmbedding.embedBatch).toHaveBeenCalled(); - expect(mockDb.insertEmbeddingsBatch).toHaveBeenCalled(); - }); - - it('throws when no text content extracted', async () => { - mockDocService.processDocumentFromPath.mockResolvedValueOnce(null); - await expect(ragService.indexDocument({ projectId: 'proj1', filePath: '/p', fileName: 'f', fileSize: 0 })).rejects.toThrow('Could not extract text'); - }); - - it('throws when document produces no chunks', async () => { - mockDocService.processDocumentFromPath.mockResolvedValueOnce({ - id: '1', type: 'document', uri: '/p', fileName: 'f', textContent: 'tiny', fileSize: 5, - }); - await expect(ragService.indexDocument({ projectId: 'proj1', filePath: '/p', fileName: 'f', fileSize: 0 })).rejects.toThrow('no indexable content'); - }); - - it('throws if document with same path already exists', async () => { - mockDb.getDocumentsByProject.mockReturnValueOnce([ - { id: 1, project_id: 'proj1', name: 'test.txt', path: '/path/test.txt', size: 100, created_at: '', enabled: 1 }, - ]); - await expect(ragService.indexDocument({ projectId: 'proj1', filePath: '/path/test.txt', fileName: 'test.txt', fileSize: 100 })) - .rejects.toThrow('already in the knowledge base'); - }); - - it('throws if document with same name already exists', async () => { - mockDb.getDocumentsByProject.mockReturnValueOnce([ - { id: 1, project_id: 'proj1', name: 'test.txt', path: '/other/path', size: 100, created_at: '', enabled: 1 }, - ]); - await expect(ragService.indexDocument({ projectId: 'proj1', filePath: '/new/path', fileName: 'test.txt', fileSize: 100 })) - .rejects.toThrow('already in the knowledge base'); - }); - - it('ABORTS and rolls back the just-inserted doc when embedding fails (no half-indexed entry)', async () => { - // Product decision (user-ratified): an embedding failure mid-index must ABORT — roll back the - // doc + chunks and propagate the error — never silently "continue without embeddings" (that left - // a permanent, non-searchable dead entry). Assert the rejection AND the rollback consequence. - mockEmbedding.embedBatch.mockRejectedValueOnce(new Error('OOM: embedding model ran out of memory')); - - await expect( - ragService.indexDocument({ projectId: 'proj1', filePath: '/p', fileName: 'test.txt', fileSize: 100 }), - ).rejects.toThrow('OOM: embedding model ran out of memory'); - - // Rollback: the doc inserted before the failed embed is deleted, and no embeddings were persisted. - expect(mockDb.deleteDocument).toHaveBeenCalledWith(1); - expect(mockDb.insertEmbeddingsBatch).not.toHaveBeenCalled(); - }); - }); - - describe('backfillEmbeddings', () => { - it('generates embeddings for documents without them', async () => { - mockDb.getDocumentsByProject.mockReturnValue([ - { id: 1, project_id: 'proj1', name: 'a.txt', path: '/a', size: 100, created_at: '', enabled: 1 }, - ]); - mockDb.hasEmbeddingsForDocument.mockReturnValue(false); - mockDb.getChunksByDocument.mockReturnValue([ - { id: 10, content: 'chunk one', position: 0 }, - { id: 11, content: 'chunk two', position: 1 }, - ]); - - const total = await ragService.backfillEmbeddings('proj1'); - expect(total).toBe(2); - expect(mockEmbedding.embedBatch).toHaveBeenCalled(); - expect(mockDb.insertEmbeddingsBatch).toHaveBeenCalled(); - }); - - it('skips documents that already have embeddings', async () => { - mockDb.getDocumentsByProject.mockReturnValue([ - { id: 1, project_id: 'proj1', name: 'a.txt', path: '/a', size: 100, created_at: '', enabled: 1 }, - ]); - mockDb.hasEmbeddingsForDocument.mockReturnValue(true); - - const total = await ragService.backfillEmbeddings('proj1'); - expect(total).toBe(0); - expect(mockEmbedding.embedBatch).not.toHaveBeenCalled(); - }); - }); - - describe('deleteDocument', () => { - it('delegates to ragDatabase', async () => { - await ragService.deleteDocument(42); - expect(mockDb.deleteDocument).toHaveBeenCalledWith(42); - }); - }); - - describe('getDocumentsByProject', () => { - it('returns documents from database', async () => { - const mockDocs = [{ id: 1, project_id: 'proj1', name: 'a.txt', path: '/a', size: 100, created_at: '', enabled: 1 }]; - mockDb.getDocumentsByProject.mockReturnValue(mockDocs); - - const docs = await ragService.getDocumentsByProject('proj1'); - expect(docs).toEqual(mockDocs); - }); - }); - - describe('toggleDocument', () => { - it('delegates to ragDatabase', async () => { - await ragService.toggleDocument(1, false); - expect(mockDb.toggleEnabled).toHaveBeenCalledWith(1, false); - }); - }); - - describe('searchProject', () => { - it('calls search without contextLength', async () => { - const result = await ragService.searchProject('proj1', 'query'); - expect(result.chunks).toEqual([]); - }); - - it('calls searchWithBudget with contextLength', async () => { - const result = await ragService.searchProject('proj1', 'query', 2048); - expect(result.chunks).toEqual([]); - }); - }); - - describe('deleteProjectDocuments', () => { - it('delegates to ragDatabase', async () => { - await ragService.deleteProjectDocuments('proj1'); - expect(mockDb.deleteDocumentsByProject).toHaveBeenCalledWith('proj1'); - }); - }); -}); diff --git a/src/bootstrap/hookRegistry.ts b/src/bootstrap/hookRegistry.ts index 88fc45771..fef52881a 100644 --- a/src/bootstrap/hookRegistry.ts +++ b/src/bootstrap/hookRegistry.ts @@ -60,4 +60,7 @@ export const HOOKS = { /** (mutation: SyncMutation) => void — a core data owner committed a record * change. Pro records it in the state-sync op-log; free builds do nothing. */ syncRecordLocalMutation: 'sync.recordLocalMutation', + /** (mutation: KnowledgeDocumentMutation) => void — the RAG owner committed + * a document lifecycle change. Pro transfers or reconciles it with peers. */ + syncKnowledgeDocumentMutation: 'sync.knowledgeDocumentMutation', } as const; diff --git a/src/services/rag/database.ts b/src/services/rag/database.ts index f11e18ad0..2d3e08088 100644 --- a/src/services/rag/database.ts +++ b/src/services/rag/database.ts @@ -2,9 +2,11 @@ import { open } from '@op-engineering/op-sqlite'; import type { DB } from '@op-engineering/op-sqlite'; import type { Chunk } from './chunking'; import logger from '../../utils/logger'; +import { generateId } from '../../utils/generateId'; export interface RagDocument { id: number; + sync_id: string; project_id: string; name: string; path: string; @@ -41,13 +43,39 @@ class RagDatabase { this.db.executeSync( `CREATE TABLE IF NOT EXISTS rag_documents ( id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_id TEXT, project_id TEXT NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, size INTEGER NOT NULL, created_at TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1 - )` + )`, + ); + const columns = this.db.executeSync( + "SELECT name FROM pragma_table_info('rag_documents')", + ); + const hasSyncId = ( + (columns.rows ?? []) as unknown as { name: string }[] + ).some(column => column.name === 'sync_id'); + if (!hasSyncId) { + this.db.executeSync( + 'ALTER TABLE rag_documents ADD COLUMN sync_id TEXT', + ); + } + const legacyDocuments = this.db.executeSync( + "SELECT id FROM rag_documents WHERE sync_id IS NULL OR sync_id = ''", + ); + for (const document of (legacyDocuments.rows ?? []) as unknown as { + id: number; + }[]) { + this.db.executeSync( + 'UPDATE rag_documents SET sync_id = ? WHERE id = ?', + [generateId(), document.id], + ); + } + this.db.executeSync( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_rag_documents_sync_id ON rag_documents(sync_id)', ); this.db.executeSync( `CREATE TABLE IF NOT EXISTS rag_chunks ( @@ -56,7 +84,7 @@ class RagDatabase { doc_id INTEGER NOT NULL, position INTEGER NOT NULL, FOREIGN KEY (doc_id) REFERENCES rag_documents(id) - )` + )`, ); this.db.executeSync( `CREATE TABLE IF NOT EXISTS rag_embeddings ( @@ -66,7 +94,7 @@ class RagDatabase { embedding BLOB NOT NULL, FOREIGN KEY (chunk_rowid) REFERENCES rag_chunks(id), FOREIGN KEY (doc_id) REFERENCES rag_documents(id) - )` + )`, ); this.ready = true; } catch (error) { @@ -76,17 +104,37 @@ class RagDatabase { } private getDb(): DB { - if (!this.db) throw new Error('RagDatabase not initialized. Call ensureReady() first.'); + if (!this.db) + throw new Error('RagDatabase not initialized. Call ensureReady() first.'); return this.db; } - insertDocument(doc: { projectId: string; name: string; path: string; size: number }): number { + insertDocument(doc: { + projectId: string; + name: string; + path: string; + size: number; + syncId?: string; + createdAt?: string; + enabled?: boolean; + }): number { const db = this.getDb(); const result = db.executeSync( - 'INSERT INTO rag_documents (project_id, name, path, size, created_at) VALUES (?, ?, ?, ?, ?)', - [doc.projectId, doc.name, doc.path, doc.size, new Date().toISOString()] + `INSERT INTO rag_documents + (sync_id, project_id, name, path, size, created_at, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + doc.syncId ?? generateId(), + doc.projectId, + doc.name, + doc.path, + doc.size, + doc.createdAt ?? new Date().toISOString(), + doc.enabled === false ? 0 : 1, + ], ); - if (result.insertId == null) throw new Error('Failed to insert document: no insertId returned'); + if (result.insertId == null) + throw new Error('Failed to insert document: no insertId returned'); return result.insertId; } @@ -98,9 +146,12 @@ class RagDatabase { for (const chunk of chunks) { const result = db.executeSync( 'INSERT INTO rag_chunks (content, doc_id, position) VALUES (?, ?, ?)', - [chunk.content, docId, chunk.position] + [chunk.content, docId, chunk.position], ); - if (result.insertId == null) throw new Error(`Failed to insert chunk at position ${chunk.position}`); + if (result.insertId == null) + throw new Error( + `Failed to insert chunk at position ${chunk.position}`, + ); rowIds.push(result.insertId); } db.executeSync('COMMIT'); @@ -117,18 +168,25 @@ class RagDatabase { private blobToEmbedding(blob: any): number[] { if (blob instanceof ArrayBuffer) return Array.from(new Float32Array(blob)); - if (blob?.buffer instanceof ArrayBuffer) return Array.from(new Float32Array(blob.buffer)); + if (blob?.buffer instanceof ArrayBuffer) + return Array.from(new Float32Array(blob.buffer)); return []; } - insertEmbeddingsBatch(entries: { chunkRowid: number; docId: number; embedding: number[] }[]): void { + insertEmbeddingsBatch( + entries: { chunkRowid: number; docId: number; embedding: number[] }[], + ): void { const db = this.getDb(); db.executeSync('BEGIN'); try { for (const entry of entries) { db.executeSync( 'INSERT INTO rag_embeddings (chunk_rowid, doc_id, embedding) VALUES (?, ?, ?)', - [entry.chunkRowid, entry.docId, this.embeddingToBlob(entry.embedding)] + [ + entry.chunkRowid, + entry.docId, + this.embeddingToBlob(entry.embedding), + ], ); } db.executeSync('COMMIT'); @@ -146,7 +204,7 @@ class RagDatabase { JOIN rag_chunks c ON e.chunk_rowid = c.id JOIN rag_documents d ON e.doc_id = d.id WHERE d.project_id = ? AND d.enabled = 1`, - [projectId] + [projectId], ); return ((result.rows ?? []) as unknown as any[]).map(row => ({ ...row, @@ -158,19 +216,25 @@ class RagDatabase { const db = this.getDb(); const result = db.executeSync( 'SELECT COUNT(*) as count FROM rag_embeddings WHERE doc_id = ?', - [docId] + [docId], ); const rows = (result.rows ?? []) as unknown as { count: number }[]; return rows.length > 0 && rows[0].count > 0; } - getChunksByDocument(docId: number): { id: number; content: string; position: number }[] { + getChunksByDocument( + docId: number, + ): { id: number; content: string; position: number }[] { const db = this.getDb(); const result = db.executeSync( 'SELECT id, content, position FROM rag_chunks WHERE doc_id = ? ORDER BY position', - [docId] + [docId], ); - return (result.rows ?? []) as unknown as { id: number; content: string; position: number }[]; + return (result.rows ?? []) as unknown as { + id: number; + content: string; + position: number; + }[]; } deleteDocument(docId: number): void { @@ -183,15 +247,44 @@ class RagDatabase { getDocumentsByProject(projectId: string): RagDocument[] { const db = this.getDb(); const result = db.executeSync( - 'SELECT id, project_id, name, path, size, created_at, enabled FROM rag_documents WHERE project_id = ? ORDER BY created_at DESC', - [projectId] + 'SELECT id, sync_id, project_id, name, path, size, created_at, enabled FROM rag_documents WHERE project_id = ? ORDER BY created_at DESC', + [projectId], + ); + return (result.rows ?? []) as unknown as RagDocument[]; + } + + getAllDocuments(): RagDocument[] { + const db = this.getDb(); + const result = db.executeSync( + 'SELECT id, sync_id, project_id, name, path, size, created_at, enabled FROM rag_documents ORDER BY created_at ASC', ); return (result.rows ?? []) as unknown as RagDocument[]; } + getDocument(docId: number): RagDocument | undefined { + const db = this.getDb(); + const result = db.executeSync( + 'SELECT id, sync_id, project_id, name, path, size, created_at, enabled FROM rag_documents WHERE id = ?', + [docId], + ); + return ((result.rows ?? []) as unknown as RagDocument[])[0]; + } + + getDocumentBySyncId(syncId: string): RagDocument | undefined { + const db = this.getDb(); + const result = db.executeSync( + 'SELECT id, sync_id, project_id, name, path, size, created_at, enabled FROM rag_documents WHERE sync_id = ?', + [syncId], + ); + return ((result.rows ?? []) as unknown as RagDocument[])[0]; + } + toggleEnabled(docId: number, enabled: boolean): void { const db = this.getDb(); - db.executeSync('UPDATE rag_documents SET enabled = ? WHERE id = ?', [enabled ? 1 : 0, docId]); + db.executeSync('UPDATE rag_documents SET enabled = ? WHERE id = ?', [ + enabled ? 1 : 0, + docId, + ]); } getChunksByProject(projectId: string, topK: number = 5): RagSearchResult[] { @@ -201,16 +294,24 @@ class RagDatabase { FROM rag_chunks c JOIN rag_documents d ON c.doc_id = d.id WHERE d.project_id = ? AND d.enabled = 1 ORDER BY c.position LIMIT ?`, - [projectId, topK] + [projectId, topK], ); return (result.rows ?? []) as unknown as RagSearchResult[]; } deleteDocumentsByProject(projectId: string): void { const db = this.getDb(); - db.executeSync('DELETE FROM rag_embeddings WHERE doc_id IN (SELECT id FROM rag_documents WHERE project_id = ?)', [projectId]); - db.executeSync('DELETE FROM rag_chunks WHERE doc_id IN (SELECT id FROM rag_documents WHERE project_id = ?)', [projectId]); - db.executeSync('DELETE FROM rag_documents WHERE project_id = ?', [projectId]); + db.executeSync( + 'DELETE FROM rag_embeddings WHERE doc_id IN (SELECT id FROM rag_documents WHERE project_id = ?)', + [projectId], + ); + db.executeSync( + 'DELETE FROM rag_chunks WHERE doc_id IN (SELECT id FROM rag_documents WHERE project_id = ?)', + [projectId], + ); + db.executeSync('DELETE FROM rag_documents WHERE project_id = ?', [ + projectId, + ]); } } diff --git a/src/services/rag/index.ts b/src/services/rag/index.ts index 25eb71aae..07cc90245 100644 --- a/src/services/rag/index.ts +++ b/src/services/rag/index.ts @@ -3,15 +3,15 @@ import { chunkDocument } from './chunking'; import { retrievalService } from './retrieval'; import { embeddingService } from './embedding'; import { documentService } from '../documentService'; +import { + emitKnowledgeDocumentMutation, + type KnowledgeDocumentSnapshot, +} from '../sync/knowledgeDocument'; import logger from '../../utils/logger'; -; export type { RagDocument, RagSearchResult } from './database'; -; export { chunkDocument } from './chunking'; export { retrievalService } from './retrieval'; -; - interface IndexProgress { stage: 'extracting' | 'chunking' | 'indexing' | 'embedding' | 'done'; message: string; @@ -22,6 +22,10 @@ interface IndexDocumentParams { filePath: string; fileName: string; fileSize: number; + syncId?: string; + createdAt?: string; + enabled?: boolean; + origin?: 'local' | 'sync'; onProgress?: (progress: IndexProgress) => void; } @@ -37,13 +41,22 @@ class RagService { // Prevent duplicate indexing of the same file const existing = ragDatabase.getDocumentsByProject(projectId); if (existing.some(d => d.path === filePath || d.name === fileName)) { - throw new Error(`Document "${fileName}" is already in the knowledge base`); + throw new Error( + `Document "${fileName}" is already in the knowledge base`, + ); } - onProgress?.({ stage: 'extracting', message: `Extracting text from ${fileName}...` }); + onProgress?.({ + stage: 'extracting', + message: `Extracting text from ${fileName}...`, + }); // Extract full document text for RAG — don't truncate based on context window const RAG_MAX_CHARS = 500_000; - const attachment = await documentService.processDocumentFromPath(filePath, fileName, RAG_MAX_CHARS); + const attachment = await documentService.processDocumentFromPath( + filePath, + fileName, + RAG_MAX_CHARS, + ); if (!attachment?.textContent) { // A PDF that extracts to zero text is a scanned / image-only PDF (no text layer); // there is no on-device OCR, so name that cause instead of a generic failure (B-KB). @@ -62,7 +75,15 @@ class RagService { } onProgress?.({ stage: 'indexing', message: 'Indexing chunks...' }); - const docId = ragDatabase.insertDocument({ projectId, name: fileName, path: filePath, size: fileSize }); + const docId = ragDatabase.insertDocument({ + projectId, + name: fileName, + path: attachment.uri || filePath, + size: attachment.fileSize ?? fileSize, + syncId: params.syncId, + createdAt: params.createdAt, + enabled: params.enabled, + }); const rowIds = ragDatabase.insertChunks(docId, chunks); onProgress?.({ stage: 'embedding', message: 'Generating embeddings...' }); @@ -76,18 +97,32 @@ class RagService { embedding: embeddings[i], })); ragDatabase.insertEmbeddingsBatch(entries); - logger.log(`[RAG] Generated ${embeddings.length} embeddings for ${fileName}`); + logger.log( + `[RAG] Generated ${embeddings.length} embeddings for ${fileName}`, + ); } catch (err) { // A document with zero embeddings is invisible to semantic search and never // auto-backfilled — a permanent dead entry. Roll back the just-inserted doc + chunks // and surface the failure so the KB screen reports it, rather than swallowing it. - logger.error('[RAG] Embedding generation failed — rolling back index:', err); + logger.error( + '[RAG] Embedding generation failed — rolling back index:', + err, + ); ragDatabase.deleteDocument(docId); - throw err instanceof Error ? err : new Error('Embedding generation failed'); + throw err instanceof Error + ? err + : new Error('Embedding generation failed'); } onProgress?.({ stage: 'done', message: 'Done' }); logger.log(`[RAG] Indexed ${fileName}: ${chunks.length} chunks`); + const indexed = ragDatabase.getDocument(docId); + if (indexed && params.origin !== 'sync') { + emitKnowledgeDocumentMutation({ + kind: 'indexed', + document: this.snapshot(indexed), + }); + } return docId; } @@ -113,7 +148,9 @@ class RagService { })); ragDatabase.insertEmbeddingsBatch(entries); total += embeddings.length; - logger.log(`[RAG] Backfilled ${embeddings.length} embeddings for ${doc.name}`); + logger.log( + `[RAG] Backfilled ${embeddings.length} embeddings for ${doc.name}`, + ); } catch (err) { logger.error(`[RAG] Backfill failed for ${doc.name}:`, err); } @@ -124,7 +161,14 @@ class RagService { async deleteDocument(docId: number): Promise { await this.ensureReady(); + const document = ragDatabase.getDocument(docId); ragDatabase.deleteDocument(docId); + if (document) { + emitKnowledgeDocumentMutation({ + kind: 'deleted', + syncId: document.sync_id, + }); + } } async getDocumentsByProject(projectId: string) { @@ -135,19 +179,102 @@ class RagService { async toggleDocument(docId: number, enabled: boolean): Promise { await this.ensureReady(); ragDatabase.toggleEnabled(docId, enabled); + const document = ragDatabase.getDocument(docId); + if (document) { + emitKnowledgeDocumentMutation({ + kind: 'enabled', + syncId: document.sync_id, + enabled, + }); + } } - async searchProject(projectId: string, query: string, contextLength?: number) { + async searchProject( + projectId: string, + query: string, + contextLength?: number, + ) { await this.ensureReady(); if (contextLength) { - return retrievalService.searchWithBudget({ projectId, query, contextLength }); + return retrievalService.searchWithBudget({ + projectId, + query, + contextLength, + }); } return retrievalService.search(projectId, query); } async deleteProjectDocuments(projectId: string): Promise { await this.ensureReady(); + const documents = ragDatabase.getDocumentsByProject(projectId); ragDatabase.deleteDocumentsByProject(projectId); + for (const document of documents) { + emitKnowledgeDocumentMutation({ + kind: 'deleted', + syncId: document.sync_id, + }); + } + } + + async getAllDocumentsForSync(): Promise { + await this.ensureReady(); + return ragDatabase + .getAllDocuments() + .map(document => this.snapshot(document)); + } + + async indexSyncedDocument( + document: KnowledgeDocumentSnapshot, + ): Promise { + await this.ensureReady(); + const existing = ragDatabase.getDocumentBySyncId(document.syncId); + if (existing) { + if (existing.enabled !== (document.enabled ? 1 : 0)) { + ragDatabase.toggleEnabled(existing.id, document.enabled); + } + return existing.id; + } + + return this.indexDocument({ + projectId: document.projectId, + filePath: document.filePath, + fileName: document.name, + fileSize: document.fileSize, + syncId: document.syncId, + createdAt: document.createdAt, + enabled: document.enabled, + origin: 'sync', + }); + } + + async setSyncedDocumentEnabled( + syncId: string, + enabled: boolean, + ): Promise { + await this.ensureReady(); + const document = ragDatabase.getDocumentBySyncId(syncId); + if (document) ragDatabase.toggleEnabled(document.id, enabled); + } + + async deleteSyncedDocument(syncId: string): Promise { + await this.ensureReady(); + const document = ragDatabase.getDocumentBySyncId(syncId); + if (document) ragDatabase.deleteDocument(document.id); + } + + private snapshot( + document: import('./database').RagDocument, + ): KnowledgeDocumentSnapshot { + return { + syncId: document.sync_id, + projectId: document.project_id, + name: document.name, + filePath: document.path, + fileSize: document.size, + createdAt: document.created_at, + enabled: document.enabled === 1, + }; } } diff --git a/src/services/sync/knowledgeDocument.ts b/src/services/sync/knowledgeDocument.ts new file mode 100644 index 000000000..6c2b9f56e --- /dev/null +++ b/src/services/sync/knowledgeDocument.ts @@ -0,0 +1,29 @@ +import { callHook, HOOKS } from '../../bootstrap/hookRegistry'; + +export const KNOWLEDGE_DOCUMENT_MIME = + 'application/vnd.offgrid.knowledge-document'; + +export interface KnowledgeDocumentSnapshot { + syncId: string; + projectId: string; + name: string; + filePath: string; + fileSize: number; + createdAt: string; + enabled: boolean; +} + +export type KnowledgeDocumentMutation = + | { kind: 'indexed'; document: KnowledgeDocumentSnapshot } + | { kind: 'enabled'; syncId: string; enabled: boolean } + | { kind: 'deleted'; syncId: string }; + +export function emitKnowledgeDocumentMutation( + mutation: KnowledgeDocumentMutation, +): void { + try { + callHook(HOOKS.syncKnowledgeDocumentMutation, mutation); + } catch { + // Sync is additive. A Pro integration failure must not roll back local RAG. + } +} From d125902e0be3c31b531ad4733eec1b1166367e1e Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Tue, 28 Jul 2026 07:41:12 +0530 Subject: [PATCH 044/332] docs(sync): record knowledge document contract --- docs/SYNC_MOBILE_PROGRESS.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index c15e5a52e..5dafb5468 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -11,6 +11,9 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as - **Transport:** length-prefixed NaCl-encrypted frames over TCP (ephemeral bound port, advertised over mDNS TXT). App messages ride the paired channel via `engine.sendApp(deviceId, channel, data)`. - **Feature gating:** the mobile Sync _experience_ is Pro; the engine is public. +- **Desktop contract:** `shared/docs/DESKTOP_SYNC_INTEGRATION_PLAN.md`. This mobile record names each + landed wire contract and the matching desktop requirement so the two integrations stay visible + to each other. ## Devices (mobile side, on hand for real 2-device tests) @@ -99,6 +102,13 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as project and unfile its conversations without deleting messages. The rendered journey deletes an inbound project through mobile UI and proves the tombstone, unfiled conversation, and preserved message all reach the peer. +- [x] Knowledge-base documents now have a stable RFC 4122 `sync_id`. The RAG migration backfills + existing autoincrement rows once, preserves that identity, and assigns it to new documents. + The RAG owner emits indexed, enabled, and deleted lifecycle intents through the existing + optional Pro hook. Real SQLite coverage: core `9deceba5`. +- [ ] Send verified knowledge-document files through the shared streaming transfer manager, then + index them through each receiver's existing RAG owner. Mobile and Desktop must use the same + MIME metadata and stable `sync_id`; local paths and autoincrement row ids never cross the wire. - [ ] Verify conversation/project/message convergence with the real desktop app on physical iOS and Android devices. @@ -146,7 +156,8 @@ high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphra `f50dea2c` (stable IDs), `b8abb869` (withhold unsafe project tombstones), Pro `07e06ee2` and core `78df85ba` (model settings), and `69f16ccb` (non-destructive project deletion). Pro `afca0d7e` and core `9ced2a55` distinguish Debug Pro from -real Keygen device activation. +real Keygen device activation. Core `9deceba5` gives knowledge documents a stable cross-device +identity and records their lifecycle at the RAG owner. Commits are small + each has rendered integration coverage + hygiene. ## Prior-art decision (2026-07-26) From b51d80a4f14f72cadfa527e4afd89943b05358fc Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Tue, 28 Jul 2026 08:14:24 +0530 Subject: [PATCH 045/332] feat(sync): integrate knowledge document transfer --- ...knowledgeDocumentSync.integration.test.tsx | 387 ++++++++++++++++++ pro | 2 +- src/services/sync/knowledgeDocument.ts | 11 +- src/services/sync/mutation.ts | 17 + 4 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 __tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx diff --git a/__tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx b/__tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx new file mode 100644 index 000000000..f0e0cb7b7 --- /dev/null +++ b/__tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx @@ -0,0 +1,387 @@ +import { installRealSqlite } from '../../harness/sqliteFake'; +import { requireRTL } from '../../harness/nativeBoundary'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + setOptions: jest.fn(), + addListener: jest.fn(() => () => undefined), + }), + useRoute: () => ({ + params: { projectId: '11111111-1111-4111-8111-111111111111' }, + }), +})); + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +jest.mock('react-native-fs', () => { + const { + modelTransferFsBoundary, + } = require('../../utils/modelTransferFsBoundary'); + return { + __esModule: true, + default: modelTransferFsBoundary.module, + ...modelTransferFsBoundary.module, + }; +}); + +async function waitForCondition( + condition: () => boolean | Promise, + message: string, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!(await condition())) { + if (Date.now() >= deadline) throw new Error(message); + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +describe('Pro mobile knowledge document sync journey', () => { + it('stages file-first input, indexes it visibly, sends a picked file back, and applies a tombstone', async () => { + installRealSqlite(); + const React = require('react'); + const rtl = requireRTL(); + const asyncStorageModule = require('@react-native-async-storage/async-storage'); + const AsyncStorage = asyncStorageModule.default ?? asyncStorageModule; + const Keychain = require('react-native-keychain'); + const TcpSocket = require('react-native-tcp-socket').default; + const RNFS = require('react-native-fs').default; + const picker = require('@react-native-documents/picker'); + const { + FileTransferManager, + IncrementalChecksum, + KNOWLEDGE_DOCUMENT_ENTITY, + KNOWLEDGE_DOCUMENT_MIME, + OpLog, + StateSync, + createKnowledgeDocumentStateFields, + createKnowledgeDocumentTransferMetadata, + } = require('@offgrid/sync'); + const { + ProjectDetailScreen, + } = require('../../../src/screens/ProjectDetailScreen'); + const { + HOOKS, + _clearHooksForTesting, + registerHook, + } = require('../../../src/bootstrap/hookRegistry'); + const { useAppStore } = require('../../../src/stores/appStore'); + const { useChatStore } = require('../../../src/stores/chatStore'); + const { useProjectStore } = require('../../../src/stores/projectStore'); + const { ragService } = require('../../../src/services/rag'); + const { embeddingService } = require('../../../src/services/rag/embedding'); + const { buildSyncEngine } = require('../../../src/services/sync/engine'); + const { + knowledgeDocumentSyncService, + } = require('../../../pro/sync/knowledgeDocumentSyncService'); + const { stateSyncService } = require('../../../pro/sync/stateSyncService'); + const { syncService } = require('../../../pro/sync/syncService'); + const { useSyncStore } = require('../../../pro/sync/syncStore'); + const { + getDiscoveryBoundaries, + resetDiscoveryBoundaries, + } = require('../../utils/nativeSyncBoundaries'); + const { + modelTransferFsBoundary, + } = require('../../utils/modelTransferFsBoundary'); + const { createDownloadedModel } = require('../../utils/factories'); + + const remoteProjectId = '11111111-1111-4111-8111-111111111111'; + const remoteDocumentId = '22222222-2222-4222-8222-222222222222'; + const createdAt = '2026-07-28T08:00:00.000Z'; + const remoteBytes = Buffer.from( + 'The OGAD launch brief says the private beta begins on Thursday.', + ); + const remoteDescriptor = { + syncId: remoteDocumentId, + projectId: remoteProjectId, + name: 'launch-brief.txt', + createdAt, + enabled: true, + }; + const receivedByDesktop: Array<{ + request: Record; + bytes: Buffer; + }> = []; + const renderProjectDetail = () => + rtl.render(React.createElement(ProjectDetailScreen)); + + modelTransferFsBoundary.reset(); + resetDiscoveryBoundaries(); + await AsyncStorage.clear(); + _clearHooksForTesting(); + useSyncStore.getState().reset(); + useChatStore.getState().clearAllConversations(); + useProjectStore.setState({ projects: [] }); + useAppStore.getState().setOnboardingComplete(true); + useAppStore + .getState() + .setDownloadedModels([createDownloadedModel({ engine: 'litert' })]); + Keychain.getGenericPassword.mockResolvedValue(false); + Keychain.setGenericPassword.mockResolvedValue(true); + + jest.spyOn(embeddingService, 'load').mockResolvedValue(undefined); + jest + .spyOn(embeddingService, 'embedBatch') + .mockImplementation((async (texts: unknown) => + (texts as string[]).map((_, index) => [1, index + 1])) as never); + + const remoteDevice = { + id: 'desktop-knowledge-peer', + name: 'Off Grid AI Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + const remoteRecords = new Map>(); + const remoteLog = new OpLog({ + deviceId: remoteDevice.id, + materializer: { + put: ( + entity: string, + entityId: string, + fields: Record, + ) => remoteRecords.set(`${entity}:${entityId}`, fields), + remove: (entity: string, entityId: string) => + remoteRecords.delete(`${entity}:${entityId}`), + }, + uuid: (() => { + let index = 0; + return () => `desktop-knowledge-op-${++index}`; + })(), + now: () => Date.now(), + }); + let remoteState: InstanceType; + let remoteTransfers: InstanceType; + const remote = buildSyncEngine({ + localDevice: remoteDevice, + tcpModule: TcpSocket, + onMessage: (deviceId: string, message: Record) => { + remoteTransfers.handleMessage(deviceId, message); + }, + onAppMessage: (deviceId: string, channel: string, data: unknown) => { + if (channel === 'state') remoteState.onMessage(deviceId, data); + }, + }); + remoteState = new StateSync({ + oplog: remoteLog, + send: (deviceId: string, message: unknown) => { + remote.engine.sendApp(deviceId, 'state', message); + }, + }); + remoteTransfers = new FileTransferManager({ + send: (deviceId: string, message: Record) => + remote.engine.send(deviceId, message), + createSink: async (_deviceId: string, request: Record) => { + if (request.payload.mimeType !== KNOWLEDGE_DOCUMENT_MIME) return null; + const bytes = Buffer.alloc(request.payload.fileSize); + return { + prepare: async () => 0, + write: async (offset: number, data: Uint8Array) => { + Buffer.from(data).copy(bytes, offset); + }, + finalize: async () => { + receivedByDesktop.push({ request, bytes }); + return true; + }, + abort: async () => undefined, + }; + }, + }); + + registerHook(HOOKS.syncRecordLocalMutation, (mutation: unknown) => { + stateSyncService.recordMutation(mutation); + }); + registerHook(HOOKS.syncKnowledgeDocumentMutation, (mutation: unknown) => { + knowledgeDocumentSyncService + .handleLocalMutation(mutation) + .catch(() => undefined); + }); + knowledgeDocumentSyncService.start({ + recordStateMutation: (mutation: unknown) => + stateSyncService.recordMutation(mutation), + canShareDocuments: () => stateSyncService.preferences().projects, + }); + + let view: ReturnType | undefined; + try { + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + await stateSyncService.start(); + await syncService.start(); + + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !discovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + const pairing = remote.engine.pair( + { + ...mobile, + host: '127.0.0.1', + port: discovery.publishedPort, + }, + 'blue-otter-42', + ); + await waitForCondition( + () => + useSyncStore.getState().incomingPairingDevice?.id === remoteDevice.id, + 'Mobile did not receive the Desktop pairing request', + ); + syncService.acceptIncomingPairing('blue-otter-42'); + await pairing; + await waitForCondition( + () => + remote.engine.isPaired(mobile.id) && + useSyncStore + .getState() + .knownDevices.some( + (device: { id: string; status: string }) => + device.id === remoteDevice.id && device.status === 'connected', + ), + 'Mobile and Desktop did not reach connected state', + ); + + const remoteChecksum = new IncrementalChecksum(); + remoteChecksum.update(remoteBytes); + await remoteTransfers.sendFile(mobile.id, { + fileName: remoteDescriptor.name, + fileSize: remoteBytes.length, + mimeType: KNOWLEDGE_DOCUMENT_MIME, + metadata: createKnowledgeDocumentTransferMetadata(remoteDescriptor), + checksum: async () => remoteChecksum.digest(), + read: async (offset: number, length: number) => + new Uint8Array(remoteBytes.subarray(offset, offset + length)), + }); + expect(await ragService.getAllDocumentsForSync()).toHaveLength(0); + + const projectOp = remoteLog.record('project', remoteProjectId, 'put', { + name: 'OGAD', + description: 'Launch documents', + system_prompt: 'Use the launch brief.', + icon: null, + include_memory: 1, + created_at: createdAt, + updated_at: createdAt, + }); + const documentOp = remoteLog.record( + KNOWLEDGE_DOCUMENT_ENTITY, + remoteDocumentId, + 'put', + createKnowledgeDocumentStateFields(remoteDescriptor), + ); + remote.engine.sendApp(mobile.id, 'state', { + t: 'ops', + ops: [documentOp, projectOp], + }); + + await waitForCondition( + async () => (await ragService.getAllDocumentsForSync()).length === 1, + 'Mobile did not index the streamed Desktop document', + ); + expect(await ragService.getDocumentsByProject(remoteProjectId)).toEqual([ + expect.objectContaining({ + name: 'launch-brief.txt', + project_id: remoteProjectId, + sync_id: remoteDocumentId, + }), + ]); + + view = renderProjectDetail(); + await rtl.waitFor(() => { + expect(view!.queryByText('launch-brief.txt')).not.toBeNull(); + }); + + await RNFS.writeFile( + '/docs/phone-notes.txt', + 'The phone note confirms the Thursday beta and the Friday review.', + 'utf8', + ); + picker.pick.mockResolvedValue([ + { + uri: 'file:///docs/phone-notes.txt', + name: 'phone-notes.txt', + type: 'text/plain', + size: 65, + }, + ]); + rtl.fireEvent.press(view.getByText('Add')); + await rtl.waitFor( + () => { + expect(view!.queryByText('phone-notes.txt')).not.toBeNull(); + }, + { timeout: 6000 }, + ); + await waitForCondition( + () => + receivedByDesktop.some( + transfer => + transfer.request.payload.metadata.name === 'phone-notes.txt', + ), + 'Desktop did not receive the phone knowledge document', + ); + const phoneTransfer = receivedByDesktop.find( + transfer => + transfer.request.payload.metadata.name === 'phone-notes.txt', + ); + expect(phoneTransfer?.bytes.toString('utf8')).toContain('Thursday beta'); + expect( + [...remoteRecords.entries()].some( + ([key, fields]) => + key.startsWith(`${KNOWLEDGE_DOCUMENT_ENTITY}:`) && + fields.name === 'phone-notes.txt', + ), + ).toBe(true); + + const deleteOp = remoteLog.record( + KNOWLEDGE_DOCUMENT_ENTITY, + remoteDocumentId, + 'delete', + ); + remote.engine.sendApp(mobile.id, 'state', { + t: 'ops', + ops: [deleteOp], + }); + await waitForCondition( + async () => + !( + await ragService.getAllDocumentsForSync() + ).some( + (document: { syncId: string }) => + document.syncId === remoteDocumentId, + ), + 'Mobile did not apply the Desktop knowledge tombstone', + ); + view.unmount(); + view = renderProjectDetail(); + await rtl.waitFor(() => { + expect(view!.queryByText('launch-brief.txt')).toBeNull(); + expect(view!.queryByText('phone-notes.txt')).not.toBeNull(); + }); + } finally { + view?.unmount(); + _clearHooksForTesting(); + await remoteTransfers.dispose(); + await knowledgeDocumentSyncService.stop(); + await stateSyncService.stop(); + await remote.engine.stop(); + await syncService.stop(); + } + }); +}); diff --git a/pro b/pro index 7d61bb2b7..f8b0e91ae 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 7d61bb2b7dfe8cacdda7bec44b3ec979079df92c +Subproject commit f8b0e91ae07e452b337199cf93bdbde319983550 diff --git a/src/services/sync/knowledgeDocument.ts b/src/services/sync/knowledgeDocument.ts index 6c2b9f56e..91f64a18f 100644 --- a/src/services/sync/knowledgeDocument.ts +++ b/src/services/sync/knowledgeDocument.ts @@ -1,16 +1,9 @@ import { callHook, HOOKS } from '../../bootstrap/hookRegistry'; +import type { KnowledgeDocumentDescriptor } from '@offgrid/sync'; -export const KNOWLEDGE_DOCUMENT_MIME = - 'application/vnd.offgrid.knowledge-document'; - -export interface KnowledgeDocumentSnapshot { - syncId: string; - projectId: string; - name: string; +export interface KnowledgeDocumentSnapshot extends KnowledgeDocumentDescriptor { filePath: string; fileSize: number; - createdAt: string; - enabled: boolean; } export type KnowledgeDocumentMutation = diff --git a/src/services/sync/mutation.ts b/src/services/sync/mutation.ts index 023d3047f..04d3a0bac 100644 --- a/src/services/sync/mutation.ts +++ b/src/services/sync/mutation.ts @@ -1,5 +1,10 @@ import { callHook, HOOKS } from '../../bootstrap/hookRegistry'; +import { + KNOWLEDGE_DOCUMENT_ENTITY, + createKnowledgeDocumentStateFields, +} from '@offgrid/sync'; import type { Conversation, Message, Project } from '../../types'; +import type { KnowledgeDocumentSnapshot } from './knowledgeDocument'; import { serializeMessageContext } from './messageContext'; /** Stable wire entity names shared with Off Grid Desktop. */ @@ -7,6 +12,7 @@ export const CORE_SYNC_ENTITIES = { conversation: 'conversation', message: 'message', project: 'project', + knowledgeDocument: KNOWLEDGE_DOCUMENT_ENTITY, modelSetting: 'model_setting', } as const; @@ -163,6 +169,17 @@ export function projectPutMutation(project: Project): SyncMutation { }; } +export function knowledgeDocumentPutMutation( + document: KnowledgeDocumentSnapshot, +): SyncMutation { + return { + entity: CORE_SYNC_ENTITIES.knowledgeDocument, + entityId: document.syncId, + kind: 'put', + fields: { ...createKnowledgeDocumentStateFields(document) }, + }; +} + export function deleteSyncMutation( entity: CoreSyncEntity, entityId: string, From 9f67d2b6a67784c62cfe413d18a9f51af02ac86b Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Tue, 28 Jul 2026 08:14:43 +0530 Subject: [PATCH 046/332] docs(sync): record knowledge document convergence --- docs/SYNC_MOBILE_PROGRESS.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/SYNC_MOBILE_PROGRESS.md b/docs/SYNC_MOBILE_PROGRESS.md index 5dafb5468..80d04d084 100644 --- a/docs/SYNC_MOBILE_PROGRESS.md +++ b/docs/SYNC_MOBILE_PROGRESS.md @@ -106,9 +106,13 @@ coordinate. Path: `off-grid-ai/mobile/docs/SYNC_MOBILE_PROGRESS.md`. Updated as existing autoincrement rows once, preserves that identity, and assigns it to new documents. The RAG owner emits indexed, enabled, and deleted lifecycle intents through the existing optional Pro hook. Real SQLite coverage: core `9deceba5`. -- [ ] Send verified knowledge-document files through the shared streaming transfer manager, then - index them through each receiver's existing RAG owner. Mobile and Desktop must use the same - MIME metadata and stable `sync_id`; local paths and autoincrement row ids never cross the wire. +- [x] Knowledge-document control and verified bytes now sync independently and converge in either + arrival order. Shared `KnowledgeDocumentSync` owns control/file gating, tombstones, project + retry, conflict cleanup, and race serialization; Mobile owns only RNFS staging, the 5 MiB + policy, and RAG adapters. A real encrypted/SQLite/rendered journey proves Desktop file-first + input waits for project state, becomes visible in the project, a document picked on Mobile + streams back with the same stable identity, and a Desktop tombstone removes it. Shared + contracts `84cc414`, `e65086e`, `254c506`, `29dd19a`; Pro `f8b0e91a`; core `b51d80a4`. - [ ] Verify conversation/project/message convergence with the real desktop app on physical iOS and Android devices. @@ -157,7 +161,8 @@ high-entropy auto-generated code + a real KDF (scrypt/argon2) so a weak passphra Pro `07e06ee2` and core `78df85ba` (model settings), and `69f16ccb` (non-destructive project deletion). Pro `afca0d7e` and core `9ced2a55` distinguish Debug Pro from real Keygen device activation. Core `9deceba5` gives knowledge documents a stable cross-device -identity and records their lifecycle at the RAG owner. +identity and records their lifecycle at the RAG owner. Pro `f8b0e91a` and core `b51d80a4` complete +knowledge-document state/file convergence using the shared coordinator and MIME registry. Commits are small + each has rendered integration coverage + hygiene. ## Prior-art decision (2026-07-26) From 3dd8ad4308f7aa09c1db87ed4687d5f1c546ab37 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Tue, 28 Jul 2026 09:02:15 +0530 Subject: [PATCH 047/332] test(sync): automate physical iOS knowledge checks --- ...knowledgeDocumentSync.integration.test.tsx | 8 + package.json | 2 + .../iosKnowledgeSyncDeviceAdapter.test.mjs | 284 ++++++++++ .../physical-sync/iosKnowledgeSyncAdapter.mjs | 233 ++++++++ .../iosKnowledgeSyncDeviceAdapter.mjs | 497 ++++++++++++++++++ .../ProjectDetailKnowledgeBaseSection.tsx | 189 +++++-- 6 files changed, 1167 insertions(+), 46 deletions(-) create mode 100644 scripts/physical-sync/__tests__/iosKnowledgeSyncDeviceAdapter.test.mjs create mode 100644 scripts/physical-sync/iosKnowledgeSyncAdapter.mjs create mode 100644 scripts/physical-sync/iosKnowledgeSyncDeviceAdapter.mjs diff --git a/__tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx b/__tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx index f0e0cb7b7..214dd0d91 100644 --- a/__tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx +++ b/__tests__/pro/sync/knowledgeDocumentSync.integration.test.tsx @@ -306,6 +306,10 @@ describe('Pro mobile knowledge document sync journey', () => { view = renderProjectDetail(); await rtl.waitFor(() => { expect(view!.queryByText('launch-brief.txt')).not.toBeNull(); + expect(view!.queryByLabelText('Use launch-brief.txt')).not.toBeNull(); + expect( + view!.queryByLabelText('Remove launch-brief.txt'), + ).not.toBeNull(); }); await RNFS.writeFile( @@ -325,6 +329,10 @@ describe('Pro mobile knowledge document sync journey', () => { await rtl.waitFor( () => { expect(view!.queryByText('phone-notes.txt')).not.toBeNull(); + expect(view!.queryByLabelText('Use phone-notes.txt')).not.toBeNull(); + expect( + view!.queryByLabelText('Remove phone-notes.txt'), + ).not.toBeNull(); }, { timeout: 6000 }, ); diff --git a/package.json b/package.json index 928492abb..0d31e2c9d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test:e2e": "./scripts/run-tests.sh", "test:e2e:all": "./scripts/run-tests.sh", "test:e2e:single": "maestro test", + "test:e2e:ios-sync-adapter": "node --test scripts/physical-sync/__tests__/iosKnowledgeSyncDeviceAdapter.test.mjs", + "sync:ios-adapter": "node --experimental-strip-types --no-warnings scripts/physical-sync/iosKnowledgeSyncAdapter.mjs", "test:android": "cd android && ./gradlew :app:testDebugUnitTest", "test:ios": "cd ios && xcodebuild test -workspace OffgridMobile.xcworkspace -scheme OffgridMobile -destination 'platform=iOS Simulator,name=iPhone 16e' -only-testing:OffgridMobileTests | (xcpretty 2>/dev/null || cat)", "postinstall": "patch-package", diff --git a/scripts/physical-sync/__tests__/iosKnowledgeSyncDeviceAdapter.test.mjs b/scripts/physical-sync/__tests__/iosKnowledgeSyncDeviceAdapter.test.mjs new file mode 100644 index 000000000..008609694 --- /dev/null +++ b/scripts/physical-sync/__tests__/iosKnowledgeSyncDeviceAdapter.test.mjs @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + IosKnowledgeSyncDeviceAdapter, + PhysicalSyncError, +} from '../iosKnowledgeSyncDeviceAdapter.mjs'; + +let rectIndex = 0; + +function node(label, value, children = []) { + const x = rectIndex * 50; + rectIndex += 1; + return { + label, + value, + rect: { x, y: 10, width: 40, height: 40 }, + children, + }; +} + +class WdaBoundary { + constructor() { + this.screen = 'home'; + this.documents = new Map([['desktop-brief.txt', true]]); + this.pendingFixture = null; + this.screenshots = []; + } + + async isReady() { + return true; + } + + async session() { + this.screen = 'home'; + return 'wda-session'; + } + + async source() { + rectIndex = 0; + let root; + if (this.screen === 'home') { + root = node('root', undefined, [ + node('settings-tab'), + node('projects-tab'), + ]); + } else if (this.screen === 'settings') { + root = node('root', undefined, [node('Settings'), node('Open Sync')]); + } else if (this.screen === 'sync') { + root = node('root', undefined, [ + node('Sync'), + node('paired-row', undefined, [ + node('Off Grid AI Desktop'), + node('macos - Connected'), + ]), + ]); + } else if (this.screen === 'projects') { + root = node('root', undefined, [ + node('Projects'), + node('OGAD'), + node('settings-tab'), + node('projects-tab'), + ]); + } else if (this.screen === 'picker') { + root = node('root', undefined, [node(this.pendingFixture)]); + } else if (this.screen === 'picker-selected') { + root = node('root', undefined, [node(this.pendingFixture), node('Open')]); + } else if (this.screen === 'remove-confirmation') { + root = node('root', undefined, [ + ...this.projectNodes(), + node('Remove Document'), + node('Remove'), + ]); + } else { + root = node('root', undefined, this.projectNodes()); + } + this.lastSource = root; + return root; + } + + projectNodes() { + const documents = [...this.documents.entries()].flatMap( + ([name, enabled]) => [ + node(name), + node(`Use ${name}`, enabled ? '1' : '0'), + node(`Remove ${name}`), + ], + ); + return [ + node('OGAD'), + node('Knowledge Base'), + node('kb-add-document'), + ...documents, + ]; + } + + async tap(x, y) { + let target; + const walk = current => { + const rect = current?.rect; + if ( + rect && + x >= rect.x && + x <= rect.x + rect.width && + y >= rect.y && + y <= rect.y + rect.height + ) { + target = current.label; + } + for (const child of current?.children ?? []) walk(child); + }; + walk(this.lastSource); + if (this.screen === 'home' && target === 'settings-tab') { + this.screen = 'settings'; + } else if (this.screen === 'settings' && target === 'Open Sync') { + this.screen = 'sync'; + } else if (this.screen === 'home' && target === 'projects-tab') { + this.screen = 'projects'; + } else if (this.screen === 'projects' && target === 'OGAD') { + this.screen = 'project'; + } else if (this.screen === 'project' && target === 'kb-add-document') { + this.pendingFixture = 'phone-fixture.txt'; + this.screen = 'picker'; + } else if (this.screen === 'picker' && target === this.pendingFixture) { + this.screen = 'picker-selected'; + } else if (this.screen === 'picker-selected' && target === 'Open') { + this.documents.set(this.pendingFixture, true); + this.screen = 'project'; + } else if (this.screen === 'project' && target?.startsWith('Use ')) { + const name = target.slice(4); + this.documents.set(name, !this.documents.get(name)); + } else if (this.screen === 'project' && target?.startsWith('Remove ')) { + this.pendingFixture = target.slice(7); + this.screen = 'remove-confirmation'; + } else if (this.screen === 'remove-confirmation' && target === 'Remove') { + this.documents.delete(this.pendingFixture); + this.screen = 'project'; + } + } + + async windowSize() { + return { width: 430, height: 932 }; + } + + async swipe() {} + + async back() { + this.screen = 'home'; + } + + async screenshot(path) { + this.screenshots.push(path); + } +} + +test('drives the physical knowledge journey through only WDA and device boundaries', async () => { + const directory = mkdtempSync(join(tmpdir(), 'ios-sync-adapter-')); + const fixture = join(directory, 'phone-fixture.txt'); + writeFileSync(fixture, 'fixture bytes'); + const actor = new WdaBoundary(); + const device = { + preflight: async () => ({ appInstalled: true }), + restartApp: async () => undefined, + }; + const adapter = new IosKnowledgeSyncDeviceAdapter({ + actor, + device, + config: { + wdaUrl: 'http://iphone.local:8100', + deviceId: 'physical-iphone', + bundleId: 'ai.offgridmobile.dev', + pairedDeviceName: 'Off Grid AI Desktop', + artifactDir: directory, + }, + }); + + assert.equal((await adapter.execute('preflight')).observed.wdaReady, true); + assert.equal( + (await adapter.execute('launch', { timeoutMs: 500 })).observed.connected, + true, + ); + assert.equal( + ( + await adapter.execute('wait-document', { + project: 'OGAD', + name: 'desktop-brief.txt', + present: true, + enabled: true, + timeoutMs: 500, + }) + ).observed.visible, + true, + ); + assert.equal( + ( + await adapter.execute('toggle-document', { + project: 'OGAD', + name: 'desktop-brief.txt', + enabled: false, + timeoutMs: 500, + }) + ).observed.enabled, + false, + ); + assert.equal( + ( + await adapter.execute('wait-document', { + project: 'OGAD', + name: 'desktop-brief.txt', + present: true, + enabled: false, + timeoutMs: 500, + }) + ).observed.enabled, + false, + ); + assert.equal( + ( + await adapter.execute('add-fixture', { + project: 'OGAD', + fixture, + timeoutMs: 500, + }) + ).observed.name, + 'phone-fixture.txt', + ); + assert.equal( + ( + await adapter.execute('delete-document', { + project: 'OGAD', + name: 'desktop-brief.txt', + timeoutMs: 500, + }) + ).observed.deleted, + true, + ); + assert.equal( + ( + await adapter.execute('wait-document', { + project: 'OGAD', + name: 'desktop-brief.txt', + present: false, + timeoutMs: 500, + }) + ).observed.present, + false, + ); + assert.ok(actor.screenshots.length >= 5); +}); + +test('rejects a stale WDA URL with the exact relaunch requirement', async () => { + const directory = mkdtempSync(join(tmpdir(), 'ios-sync-stale-wda-')); + const actor = new WdaBoundary(); + actor.isReady = async () => false; + const adapter = new IosKnowledgeSyncDeviceAdapter({ + actor, + device: { + preflight: async () => ({ appInstalled: true }), + restartApp: async () => undefined, + }, + config: { + wdaUrl: 'http://stale-iphone.local:8100', + deviceId: 'physical-iphone', + bundleId: 'ai.offgridmobile.dev', + pairedDeviceName: 'Off Grid AI Desktop', + artifactDir: directory, + }, + }); + + await assert.rejects( + adapter.execute('preflight'), + error => + error instanceof PhysicalSyncError && + error.code === 'WDA_UNAVAILABLE' && + error.message.includes( + 'PROVIT_UDID=physical-iphone node ../provit/src/ios/launchWda.ts', + ) && + error.message.includes( + 'set IOS_SYNC_WDA_URL to the newly printed PROVIT_WDA_URL', + ), + ); +}); diff --git a/scripts/physical-sync/iosKnowledgeSyncAdapter.mjs b/scripts/physical-sync/iosKnowledgeSyncAdapter.mjs new file mode 100644 index 000000000..96efdaa4e --- /dev/null +++ b/scripts/physical-sync/iosKnowledgeSyncAdapter.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env -S node --experimental-strip-types --no-warnings + +import { execFile } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { promisify } from 'node:util'; +import { resolve } from 'node:path'; +import { WdaActor } from '../../../provit/src/ios/wdaActor.ts'; +import { + IosKnowledgeSyncDeviceAdapter, + PhysicalSyncError, +} from './iosKnowledgeSyncDeviceAdapter.mjs'; + +const execFileAsync = promisify(execFile); +const schemaVersion = 1; + +class DevicectlBoundary { + constructor({ deviceId, bundleId }) { + this.deviceId = deviceId; + this.bundleId = bundleId; + } + + async preflight() { + await execFileAsync('xcrun', [ + 'devicectl', + 'device', + 'info', + 'details', + '--device', + this.deviceId, + ]); + const { stdout } = await execFileAsync('xcrun', [ + 'devicectl', + 'device', + 'info', + 'apps', + '--device', + this.deviceId, + ]); + return { appInstalled: stdout.includes(this.bundleId) }; + } + + async restartApp() { + await execFileAsync('xcrun', [ + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + this.deviceId, + '--terminate-existing', + this.bundleId, + ]); + } +} + +function configFromEnvironment() { + const wdaUrl = process.env.IOS_SYNC_WDA_URL; + const deviceId = process.env.IOS_DEVICE_ID; + if (!wdaUrl || !deviceId) { + throw new PhysicalSyncError( + 'MISSING_CONFIG', + 'IOS_SYNC_WDA_URL and IOS_DEVICE_ID are required', + ); + } + return { + wdaUrl: wdaUrl.replace(/\/$/, ''), + deviceId, + bundleId: process.env.IOS_SYNC_BUNDLE_ID ?? 'ai.offgridmobile.dev', + pairedDeviceName: + process.env.IOS_SYNC_PAIRED_DEVICE ?? 'Off Grid AI Desktop', + artifactDir: resolve( + process.env.IOS_SYNC_ARTIFACT_DIR ?? + '.artifacts/physical-sync/ios-knowledge', + ), + }; +} + +function createAdapter() { + const config = configFromEnvironment(); + return new IosKnowledgeSyncDeviceAdapter({ + actor: new WdaActor(config.wdaUrl), + device: new DevicectlBoundary(config), + config, + }); +} + +function responseFor(request, result) { + return { + schemaVersion, + id: request.id ?? null, + action: request.action, + status: 'ok', + ...result, + }; +} + +function errorResponse(request, error) { + return { + schemaVersion, + id: request?.id ?? null, + action: request?.action ?? null, + status: 'error', + error: { + code: + error instanceof PhysicalSyncError ? error.code : 'UNEXPECTED_ERROR', + message: error instanceof Error ? error.message : String(error), + ...(error instanceof PhysicalSyncError && error.details + ? { details: error.details } + : {}), + }, + }; +} + +function writeJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function parseRequest(line) { + const request = JSON.parse(line); + if ( + !request || + typeof request !== 'object' || + typeof request.action !== 'string' + ) { + throw new PhysicalSyncError( + 'INVALID_REQUEST', + 'Each JSONL request requires an action string', + ); + } + return request; +} + +async function runRequest(adapter, request) { + try { + const result = await adapter.execute(request.action, request.args ?? {}); + return responseFor(request, result); + } catch (error) { + return errorResponse(request, error); + } +} + +async function serve(adapter) { + const input = createInterface({ + input: process.stdin, + crlfDelay: Infinity, + }); + for await (const line of input) { + if (!line.trim()) continue; + let request; + try { + request = parseRequest(line); + writeJson(await runRequest(adapter, request)); + } catch (error) { + writeJson(errorResponse(request, error)); + } + } +} + +function oneShotRequest(argv) { + const [action, ...values] = argv; + switch (action) { + case 'preflight': + case 'screenshot': + return { id: 'cli', action, args: values[0] ? { path: values[0] } : {} }; + case 'launch': + return { + id: 'cli', + action, + args: { + restart: values.includes('--restart'), + pairedDeviceName: values.find(value => value !== '--restart'), + }, + }; + case 'wait-document': + case 'delete-document': + return { + id: 'cli', + action, + args: { project: values[0], name: values[1] }, + }; + case 'add-fixture': + return { + id: 'cli', + action, + args: { project: values[0], fixture: values[1] }, + }; + case 'toggle-document': + return { + id: 'cli', + action, + args: { + project: values[0], + name: values[1], + enabled: + values[2] === 'on' ? true : values[2] === 'off' ? false : undefined, + }, + }; + default: + throw new PhysicalSyncError( + 'INVALID_ARGUMENT', + 'Use serve, preflight, launch, wait-document, add-fixture, toggle-document, delete-document, or screenshot', + ); + } +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + let adapter; + try { + adapter = createAdapter(); + } catch (error) { + writeJson(errorResponse({ action: command ?? null }, error)); + process.exitCode = 2; + return; + } + if (command === 'serve') { + await serve(adapter); + return; + } + let request; + try { + request = oneShotRequest([command, ...args]); + } catch (error) { + writeJson(errorResponse({ action: command ?? null }, error)); + process.exitCode = 2; + return; + } + const response = await runRequest(adapter, request); + writeJson(response); + if (response.status === 'error') process.exitCode = 1; +} + +await main(); diff --git a/scripts/physical-sync/iosKnowledgeSyncDeviceAdapter.mjs b/scripts/physical-sync/iosKnowledgeSyncDeviceAdapter.mjs new file mode 100644 index 000000000..795558c0d --- /dev/null +++ b/scripts/physical-sync/iosKnowledgeSyncDeviceAdapter.mjs @@ -0,0 +1,497 @@ +import { basename, dirname, resolve } from 'node:path'; +import { existsSync, mkdirSync } from 'node:fs'; +const DEFAULT_TIMEOUT_MS = 30_000; +const PICKER_TIMEOUT_MS = 120_000; +export class PhysicalSyncError extends Error { + constructor(code, message, details) { + super(message); + this.name = 'PhysicalSyncError'; + this.code = code; + this.details = details; + } +} +function textOf(node) { + return String(node?.label ?? node?.name ?? node?.value ?? '').trim(); +} +function childrenOf(node) { + return Array.isArray(node?.children) ? node.children : []; +} +function findNode(root, predicate) { + if (!root) return null; + if (Array.isArray(root)) { + for (const node of root) { + const found = findNode(node, predicate); + if (found) return found; + } + return null; + } + if (predicate(root)) return root; + for (const child of childrenOf(root)) { + const found = findNode(child, predicate); + if (found) return found; + } + return null; +} +function nodeCenter(node) { + const rect = node?.rect; + if (!rect || rect.width <= 0 || rect.height <= 0) return null; + return { + x: Math.round(rect.x + rect.width / 2), + y: Math.round(rect.y + rect.height / 2), + }; +} +function exactNode(root, label) { + const expected = label.trim().toLocaleLowerCase(); + return findNode( + root, + node => + textOf(node).toLocaleLowerCase() === expected && + nodeCenter(node) !== null, + ); +} +function containsNode(root, label) { + const expected = label.trim().toLocaleLowerCase(); + return findNode( + root, + node => + textOf(node).toLocaleLowerCase().includes(expected) && + nodeCenter(node) !== null, + ); +} +function subtreeText(node) { + return [textOf(node), ...childrenOf(node).map(subtreeText)] + .filter(Boolean) + .join('\n'); +} +function containsPairRow(root, deviceName) { + const expectedName = deviceName.toLocaleLowerCase(); + return Boolean( + findNode(root, node => { + const text = subtreeText(node).toLocaleLowerCase(); + return text.includes(expectedName) && text.includes('connected'); + }), + ); +} +function switchValue(node) { + const value = String(node?.value ?? '').toLocaleLowerCase(); + if (['1', 'true', 'on', 'yes'].includes(value)) return true; + if (['0', 'false', 'off', 'no'].includes(value)) return false; + return undefined; +} + +function delay(ms) { + return new Promise(resolveDelay => setTimeout(resolveDelay, ms)); +} + +export class IosKnowledgeSyncDeviceAdapter { + constructor({ actor, device, config }) { + this.actor = actor; + this.device = device; + this.config = config; + this.hasSession = false; + this.artifactIndex = 0; + mkdirSync(config.artifactDir, { recursive: true }); + } + + async execute(action, args = {}) { + switch (action) { + case 'preflight': + return this.preflight(); + case 'launch': + return this.launch(args); + case 'wait-document': + return this.waitDocument(args); + case 'add-fixture': + return this.addFixture(args); + case 'toggle-document': + return this.toggleDocument(args); + case 'delete-document': + return this.deleteDocument(args); + case 'screenshot': + return this.screenshot(args); + default: + throw new PhysicalSyncError( + 'UNKNOWN_ACTION', + `Unknown Mobile physical Sync action: ${action}`, + ); + } + } + + async preflight() { + const [wdaReady, device] = await Promise.all([ + this.actor.isReady(), + this.device.preflight(), + ]); + if (!wdaReady) { + throw new PhysicalSyncError( + 'WDA_UNAVAILABLE', + `WebDriverAgent is not reachable at ${this.config.wdaUrl}. Run ` + + `PROVIT_UDID=${this.config.deviceId} node ../provit/src/ios/launchWda.ts, ` + + 'then set IOS_SYNC_WDA_URL to the newly printed PROVIT_WDA_URL', + ); + } + if (!device.appInstalled) { + throw new PhysicalSyncError( + 'APP_NOT_INSTALLED', + `${this.config.bundleId} is not installed on ${this.config.deviceId}`, + ); + } + return { + observed: { + wdaReady, + deviceReachable: true, + appInstalled: true, + deviceId: this.config.deviceId, + bundleId: this.config.bundleId, + }, + }; + } + + async launch(args) { + const pairedDeviceName = + args.pairedDeviceName ?? this.config.pairedDeviceName; + if (args.restart === true) { + await this.device.restartApp(); + } + await this.actor.session(this.config.bundleId); + this.hasSession = true; + await this.openSync(); + await this.waitFor( + root => containsPairRow(root, pairedDeviceName), + `paired device ${pairedDeviceName} to report Connected`, + args.timeoutMs, + ); + const artifact = await this.capture('launch-connected'); + return { + observed: { + pairedDeviceName, + paired: true, + connected: true, + restarted: args.restart === true, + }, + artifacts: [artifact], + }; + } + + async waitDocument(args) { + const { project, name } = this.requireDocumentArgs(args); + await this.openProject(project, args.timeoutMs); + const expectedPresent = args.present !== false; + if (!expectedPresent) { + await this.waitFor( + root => + exactNode(root, 'kb-add-document') !== null && + exactNode(root, name) === null, + `document ${name} to disappear from project ${project}`, + args.timeoutMs, + ); + const artifact = await this.capture(`document-absent-${name}`); + return { + observed: { project, name, visible: false, present: false }, + artifacts: [artifact], + }; + } + await this.waitFor( + root => { + if (!exactNode(root, name)) return false; + if (typeof args.enabled !== 'boolean') return true; + const toggle = exactNode(root, `Use ${name}`); + return toggle && switchValue(toggle) === args.enabled; + }, + typeof args.enabled === 'boolean' + ? `document ${name} enabled=${args.enabled} in project ${project}` + : `document ${name} in project ${project}`, + args.timeoutMs, + ); + const artifact = await this.capture(`document-${name}`); + return { + observed: { + project, + name, + visible: true, + present: true, + ...(typeof args.enabled === 'boolean' ? { enabled: args.enabled } : {}), + }, + artifacts: [artifact], + }; + } + + async addFixture(args) { + const project = this.requireString(args.project, 'project'); + const fixture = resolve(this.requireString(args.fixture, 'fixture')); + if (!existsSync(fixture)) { + throw new PhysicalSyncError( + 'FIXTURE_NOT_FOUND', + `Fixture does not exist on the Mac: ${fixture}`, + ); + } + const name = basename(fixture); + await this.openProject(project, args.timeoutMs); + await this.tapRequired('kb-add-document', 'Add document'); + let fixtureNode; + try { + fixtureNode = await this.waitFor( + root => exactNode(root, name), + `fixture ${name} in the iOS Files picker`, + args.timeoutMs ?? PICKER_TIMEOUT_MS, + ); + } catch (error) { + throw new PhysicalSyncError( + 'FIXTURE_NOT_STAGED', + `${name} must already exist in an iOS Files provider before add-fixture runs`, + { fixture, cause: error.message }, + ); + } + await this.tapNode(fixtureNode); + const openButton = await this.waitForOptional( + root => exactNode(root, 'Open'), + 1_500, + ); + if (openButton) await this.tapNode(openButton); + await this.waitFor( + root => exactNode(root, 'kb-add-document') && exactNode(root, name), + `${name} to finish indexing in project ${project}`, + args.timeoutMs ?? PICKER_TIMEOUT_MS, + ); + const artifact = await this.capture(`added-${name}`); + return { + observed: { + project, + name, + visible: true, + picker: 'ios-files', + staging: 'external-files-provider', + }, + artifacts: [artifact], + }; + } + + async toggleDocument(args) { + const { project, name } = this.requireDocumentArgs(args); + if (typeof args.enabled !== 'boolean') { + throw new PhysicalSyncError( + 'INVALID_ARGUMENT', + 'toggle-document requires args.enabled as a boolean', + ); + } + await this.openProject(project, args.timeoutMs); + const label = `Use ${name}`; + const toggle = await this.waitFor( + root => exactNode(root, label), + `toggle for ${name}`, + args.timeoutMs, + ); + const before = switchValue(toggle); + if (before === undefined) { + throw new PhysicalSyncError( + 'UNREADABLE_SWITCH', + `Could not read the enabled state for ${name}`, + ); + } + if (before !== args.enabled) await this.tapNode(toggle); + await this.waitFor( + root => { + const current = exactNode(root, label); + return current && switchValue(current) === args.enabled; + }, + `${name} enabled=${args.enabled}`, + args.timeoutMs, + ); + const artifact = await this.capture( + `${args.enabled ? 'enabled' : 'disabled'}-${name}`, + ); + return { + observed: { + project, + name, + enabled: args.enabled, + changed: before !== args.enabled, + }, + artifacts: [artifact], + }; + } + + async deleteDocument(args) { + const { project, name } = this.requireDocumentArgs(args); + await this.openProject(project, args.timeoutMs); + await this.tapRequired(`Remove ${name}`, `remove control for ${name}`); + await this.waitFor( + root => exactNode(root, 'Remove Document'), + 'Remove Document confirmation', + args.timeoutMs, + ); + await this.tapRequired('Remove', 'Remove confirmation button'); + await this.waitFor( + root => + exactNode(root, 'kb-add-document') !== null && + exactNode(root, name) === null, + `${name} to disappear from project ${project}`, + args.timeoutMs, + ); + const artifact = await this.capture(`deleted-${name}`); + return { + observed: { project, name, visible: false, deleted: true }, + artifacts: [artifact], + }; + } + + async screenshot(args) { + await this.ensureSession(); + const path = args.path + ? resolve(args.path) + : this.artifactPath(args.label ?? 'screenshot'); + mkdirSync(dirname(path), { recursive: true }); + await this.actor.screenshot(path); + return { observed: { captured: true }, artifacts: [path] }; + } + + async ensureSession() { + if (this.hasSession) return; + await this.actor.session(this.config.bundleId); + this.hasSession = true; + } + + async openSync() { + await this.ensureSession(); + await this.returnToTabs('settings-tab'); + await this.tapRequired('settings-tab', 'Settings tab'); + await this.waitFor(root => exactNode(root, 'Settings'), 'Settings screen'); + const openSync = await this.findWithScroll('Open Sync'); + await this.tapNode(openSync); + await this.waitFor(root => exactNode(root, 'Sync'), 'Sync screen'); + } + + async openProject(project, timeoutMs) { + await this.ensureSession(); + const current = await this.actor.source(); + if (exactNode(current, 'Knowledge Base') && exactNode(current, project)) { + return; + } + await this.returnToTabs('projects-tab'); + await this.tapRequired('projects-tab', 'Projects tab'); + await this.waitFor(root => exactNode(root, 'Projects'), 'Projects screen'); + const projectNode = await this.findWithScroll(project, timeoutMs); + await this.tapNode(projectNode); + await this.waitFor( + root => exactNode(root, 'Knowledge Base') && exactNode(root, project), + `project ${project}`, + timeoutMs, + ); + } + + async returnToTabs(tabLabel) { + for (let attempt = 0; attempt < 5; attempt += 1) { + const root = await this.actor.source(); + if (exactNode(root, tabLabel)) return; + await this.actor.back(); + const found = await this.waitForOptional( + next => exactNode(next, tabLabel), + 2_000, + ); + if (found) return; + } + throw new PhysicalSyncError( + 'NAVIGATION_FAILED', + `Could not return to the tab bar for ${tabLabel}`, + ); + } + + async findWithScroll(label, timeoutMs = DEFAULT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + const { width, height } = await this.actor.windowSize(); + while (Date.now() < deadline) { + const root = await this.actor.source(); + const found = exactNode(root, label) ?? containsNode(root, label); + if (found) return found; + await this.actor.swipe( + Math.round(width / 2), + Math.round(height * 0.78), + Math.round(width / 2), + Math.round(height * 0.28), + ); + } + throw new PhysicalSyncError( + 'ELEMENT_TIMEOUT', + `Timed out waiting for ${label}`, + ); + } + + async tapRequired(label, description) { + const node = await this.waitFor( + root => exactNode(root, label) ?? containsNode(root, label), + description, + ); + await this.tapNode(node); + } + + async tapNode(node) { + const center = nodeCenter(node); + if (!center) { + throw new PhysicalSyncError( + 'ELEMENT_NOT_TAPPABLE', + `Element ${textOf(node)} has no tappable rectangle`, + ); + } + await this.actor.tap(center.x, center.y); + } + + async waitFor(predicate, description, timeoutMs = DEFAULT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + do { + const root = await this.actor.source(); + const result = predicate(root); + if (result) return result; + await delay(150); + } while (Date.now() < deadline); + throw new PhysicalSyncError( + 'ELEMENT_TIMEOUT', + `Timed out waiting for ${description}`, + ); + } + + async waitForOptional(predicate, timeoutMs) { + try { + return await this.waitFor(predicate, 'optional element', timeoutMs); + } catch (error) { + if ( + error instanceof PhysicalSyncError && + error.code === 'ELEMENT_TIMEOUT' + ) { + return null; + } + throw error; + } + } + + async capture(label) { + const path = this.artifactPath(label); + await this.actor.screenshot(path); + return path; + } + + artifactPath(label) { + this.artifactIndex += 1; + const safe = label.replaceAll(/[^a-z0-9._-]+/gi, '-').toLocaleLowerCase(); + return resolve( + this.config.artifactDir, + `${String(this.artifactIndex).padStart(2, '0')}-${safe}.png`, + ); + } + + requireDocumentArgs(args) { + return { + project: this.requireString(args.project, 'project'), + name: this.requireString(args.name, 'name'), + }; + } + + requireString(value, field) { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new PhysicalSyncError( + 'INVALID_ARGUMENT', + `${field} must be a non-empty string`, + ); + } + return value.trim(); + } +} diff --git a/src/screens/ProjectDetailKnowledgeBaseSection.tsx b/src/screens/ProjectDetailKnowledgeBaseSection.tsx index 343642880..78ba6bfc6 100644 --- a/src/screens/ProjectDetailKnowledgeBaseSection.tsx +++ b/src/screens/ProjectDetailKnowledgeBaseSection.tsx @@ -1,7 +1,19 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { View, Text, TouchableOpacity, Switch, ActivityIndicator, ScrollView, Platform } from 'react-native'; +import { + View, + Text, + TouchableOpacity, + Switch, + ActivityIndicator, + ScrollView, + Platform, +} from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; -import { pick, isErrorWithCode, errorCodes } from '@react-native-documents/picker'; +import { + pick, + isErrorWithCode, + errorCodes, +} from '@react-native-documents/picker'; import { resolvePickedFileUri } from '../utils/resolvePickedFileUri'; import { Button } from '../components/Button'; @@ -12,7 +24,9 @@ import { isPickerStuck } from '../utils/pickerErrorUtils'; const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; - return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return bytes < 1024 * 1024 + ? `${(bytes / 1024).toFixed(1)} KB` + : `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; export interface KBSectionProps { @@ -24,18 +38,32 @@ export interface KBSectionProps { onDocumentPress: (doc: RagDocument) => void; } -export const KnowledgeBaseSection: React.FC = ({ projectId, colors, styles, setAlertState, onNavigateToKb, onDocumentPress }) => { +export const KnowledgeBaseSection: React.FC = ({ + projectId, + colors, + styles, + setAlertState, + onNavigateToKb, + onDocumentPress, +}) => { const [kbDocs, setKbDocs] = useState([]); const [indexingFile, setIndexingFile] = useState(null); const [isPicking, setIsPicking] = useState(false); const isPickingRef = useRef(false); const loadKbDocs = useCallback(async () => { - try { setKbDocs(await ragService.getDocumentsByProject(projectId)); } - catch (err: any) { setAlertState(showAlert('Error', err?.message || 'Failed to load documents')); } + try { + setKbDocs(await ragService.getDocumentsByProject(projectId)); + } catch (err: any) { + setAlertState( + showAlert('Error', err?.message || 'Failed to load documents'), + ); + } }, [projectId, setAlertState]); - useEffect(() => { loadKbDocs(); }, [loadKbDocs]); + useEffect(() => { + loadKbDocs(); + }, [loadKbDocs]); const handleAddDocument = async () => { if (isPickingRef.current) return; @@ -44,31 +72,46 @@ export const KnowledgeBaseSection: React.FC = ({ projectId, colo try { // iOS: 'import' → Apple copies the file before handing it to us, original untouched. // Android: 'open' → returns a content:// URI; keepLocalCopy() copies it to a real path. - const files = Platform.OS === 'android' - ? await pick({ mode: 'open', allowMultiSelection: true }) - : await pick({ mode: 'import', allowMultiSelection: true }); + const files = + Platform.OS === 'android' + ? await pick({ mode: 'open', allowMultiSelection: true }) + : await pick({ mode: 'import', allowMultiSelection: true }); if (!files?.length) return; for (let i = 0; i < files.length; i++) { const file = files[i]; const fileName = file.name || 'document'; - setIndexingFile(files.length > 1 ? `${fileName} (${i + 1}/${files.length})` : fileName); + setIndexingFile( + files.length > 1 + ? `${fileName} (${i + 1}/${files.length})` + : fileName, + ); const pathForDb = await resolvePickedFileUri(file.uri, fileName); - await ragService.indexDocument({ projectId, filePath: pathForDb, fileName, fileSize: file.size || 0 }); + await ragService.indexDocument({ + projectId, + filePath: pathForDb, + fileName, + fileSize: file.size || 0, + }); await loadKbDocs(); } } catch (err: any) { - if (isErrorWithCode(err) && err.code === errorCodes.OPERATION_CANCELED) return; + if (isErrorWithCode(err) && err.code === errorCodes.OPERATION_CANCELED) + return; if (isPickerStuck(err)) { - setAlertState(showAlert( - 'File Picker Unavailable', - "The file picker isn't responding. Please close and reopen the app, then try again.", - )); + setAlertState( + showAlert( + 'File Picker Unavailable', + "The file picker isn't responding. Please close and reopen the app, then try again.", + ), + ); return; } - setAlertState(showAlert('Error', err.message || 'Failed to index document')); + setAlertState( + showAlert('Error', err.message || 'Failed to index document'), + ); } finally { isPickingRef.current = false; setIsPicking(false); @@ -77,47 +120,83 @@ export const KnowledgeBaseSection: React.FC = ({ projectId, colo }; const handleToggleDocument = async (docId: number, enabled: boolean) => { - try { await ragService.toggleDocument(docId, enabled); await loadKbDocs(); } - catch (err: any) { setAlertState(showAlert('Error', err?.message || 'Failed to update document')); } + try { + await ragService.toggleDocument(docId, enabled); + await loadKbDocs(); + } catch (err: any) { + setAlertState( + showAlert('Error', err?.message || 'Failed to update document'), + ); + } }; const handleDeleteDocument = (doc: RagDocument) => { - setAlertState(showAlert( - 'Remove Document', - `Remove "${doc.name}" from the knowledge base?`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Remove', - style: 'destructive', - onPress: () => { - ragService.deleteDocument(doc.id) - .then(() => loadKbDocs()) - .catch((err: any) => setAlertState(showAlert('Error', err?.message || 'Failed to remove document'))); + setAlertState( + showAlert( + 'Remove Document', + `Remove "${doc.name}" from the knowledge base?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: () => { + ragService + .deleteDocument(doc.id) + .then(() => loadKbDocs()) + .catch((err: any) => + setAlertState( + showAlert( + 'Error', + err?.message || 'Failed to remove document', + ), + ), + ); + }, }, - }, - ])); + ], + ), + ); }; return ( - + Knowledge Base - {kbDocs.length > 0 && {kbDocs.length}} + {kbDocs.length > 0 && ( + {kbDocs.length} + )} -