@syncular/tauri 0.15.13 → 0.15.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.js +81 -4
- package/package.json +3 -3
- package/src/index.ts +116 -7
package/README.md
CHANGED
|
@@ -28,6 +28,55 @@ for validating the server-authoritative directive, gating subscriptions before
|
|
|
28
28
|
the purge, deleting app-owned drafts/files, and removing the corresponding key
|
|
29
29
|
from the OS secure store after SQLite cleanup succeeds.
|
|
30
30
|
|
|
31
|
+
## Secure preflight and native disposal
|
|
32
|
+
|
|
33
|
+
Create with `securityPreflight: true` when authentication, signed device
|
|
34
|
+
quarantine, or crash-resumed cleanup must finish before clinical data is
|
|
35
|
+
available. The native database opens and migrates, but query/snapshot, mutation,
|
|
36
|
+
subscription, sync, realtime, presence, blob, and automatic retry work fails
|
|
37
|
+
with `client.security_preflight_required`. Status, local revision, lifecycle,
|
|
38
|
+
and `purgeLocalData` remain available.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const client = await createTauriSyncClient({
|
|
42
|
+
schema,
|
|
43
|
+
securityPreflight: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await client.purgeLocalData(directive.plan);
|
|
47
|
+
await client.activateSecurity({ encryption: acceptedKeyring });
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`beginSecurityPreflight()` closes the JavaScript gate synchronously, waits for
|
|
51
|
+
the mutable owner and independent SQLite snapshot reader, disconnects realtime,
|
|
52
|
+
and removes the Rust keyring. `close()` now issues native shutdown before
|
|
53
|
+
detaching listeners, so disposing a resource does not leave a key-bearing core
|
|
54
|
+
behind. The Rust core overwrites owned key buffers on replacement/drop; the app
|
|
55
|
+
still owns OS secure-store deletion and any key buffers it supplied.
|
|
56
|
+
|
|
57
|
+
## React availability guard
|
|
58
|
+
|
|
59
|
+
The Tauri bridge carries `currentSchemaVersion`, `schemaFloor`, and migration
|
|
60
|
+
status through the same public React boundary as the browser worker. Guard the
|
|
61
|
+
application once instead of parsing native error strings:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
<SyncProvider
|
|
65
|
+
client={clientResource}
|
|
66
|
+
renderBoundary={(state, actions) => (
|
|
67
|
+
<SyncBlockedScreen state={state} onRetry={actions.retry} />
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
<App />
|
|
71
|
+
</SyncProvider>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The state is a discriminated union covering startup, migration,
|
|
75
|
+
`client-upgrade-required`, `server-behind`, and `incompatible-schema`.
|
|
76
|
+
Compatibility recovery automatically restores the provider's children. Live
|
|
77
|
+
queries report `phase === 'blocked'` with `isLoading === false` while retaining
|
|
78
|
+
previously safe rows for an explicitly read-only view.
|
|
79
|
+
|
|
31
80
|
Part of [Syncular](https://syncular.dev) — an offline-first sync framework.
|
|
32
81
|
See the [Syncular repository](https://github.com/syncular/syncular) for docs.
|
|
33
82
|
|
package/dist/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* `invoke`/`listen` either from its ESM entry points, from the ambient
|
|
27
27
|
* `window.__TAURI__`, or via injected doubles (tests).
|
|
28
28
|
*/
|
|
29
|
-
import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
|
|
29
|
+
import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, EncryptionKeyringConfig, InvalidationListener, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
|
|
30
30
|
/** One event pushed on `syncular://event` (the derived client-observable set). */
|
|
31
31
|
interface SyncularEvent {
|
|
32
32
|
readonly type: string;
|
|
@@ -58,6 +58,8 @@ export interface TauriSyncClientConfig {
|
|
|
58
58
|
* encoded into the native command envelope and never sent to the server.
|
|
59
59
|
*/
|
|
60
60
|
readonly encryption?: EncryptionKeyringConfig;
|
|
61
|
+
/** Open the native replica behind the fail-closed security gate. */
|
|
62
|
+
readonly securityPreflight?: boolean;
|
|
61
63
|
/**
|
|
62
64
|
* The Tauri primitives. Omit in a real Tauri webview to auto-resolve from
|
|
63
65
|
* `@tauri-apps/api` (peer dep) or the ambient `window.__TAURI__`; inject in
|
|
@@ -77,9 +79,14 @@ export type BytesEnvelope = {
|
|
|
77
79
|
export declare class TauriSyncClient {
|
|
78
80
|
#private;
|
|
79
81
|
/** @internal — use {@link createTauriSyncClient}. */
|
|
80
|
-
constructor(tauri: TauriApi, unlisten: () => void);
|
|
82
|
+
constructor(tauri: TauriApi, unlisten: () => void, securityLifecycle?: SecurityLifecycle);
|
|
81
83
|
/** @internal — fan an incoming plugin event out to the local listeners. */
|
|
82
84
|
__dispatchEvent(event: SyncularEvent): void;
|
|
85
|
+
securityLifecycle(): Promise<SecurityLifecycle>;
|
|
86
|
+
beginSecurityPreflight(): Promise<void>;
|
|
87
|
+
activateSecurity(options?: {
|
|
88
|
+
readonly encryption?: EncryptionKeyringConfig;
|
|
89
|
+
}): Promise<void>;
|
|
83
90
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
84
91
|
onChange(listener: ClientChangeListener): () => void;
|
|
85
92
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
@@ -135,7 +142,7 @@ export declare class TauriSyncClient {
|
|
|
135
142
|
setPresence(scopeKey: string, doc: Record<string, unknown> | null): Promise<void>;
|
|
136
143
|
connectRealtime(): Promise<void>;
|
|
137
144
|
disconnectRealtime(): Promise<void>;
|
|
138
|
-
/**
|
|
145
|
+
/** Shut down the native core, release its keyring, then detach listeners. */
|
|
139
146
|
close(): Promise<void>;
|
|
140
147
|
}
|
|
141
148
|
/** The error a `{error}` reply surfaces (mirrors the web-client `ClientSyncError`). */
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
* `invoke`/`listen` either from its ESM entry points, from the ambient
|
|
27
27
|
* `window.__TAURI__`, or via injected doubles (tests).
|
|
28
28
|
*/
|
|
29
|
+
// -- Types the bridge speaks (structurally the web-client's) -----------------
|
|
30
|
+
// Most imports stay type-only; the stable preflight error code is shared at
|
|
31
|
+
// runtime so every host surfaces byte-identical policy evidence.
|
|
32
|
+
import { SECURITY_PREFLIGHT_REQUIRED_CODE } from '@syncular/client';
|
|
29
33
|
/** The plugin's Tauri event name — mirror of `tauri-plugin-syncular`. */
|
|
30
34
|
export const SYNCULAR_EVENT = 'syncular://event';
|
|
31
35
|
const PLUGIN = 'plugin:syncular|';
|
|
@@ -151,19 +155,44 @@ export class TauriSyncClient {
|
|
|
151
155
|
#presenceListeners = new Set();
|
|
152
156
|
#unlisten;
|
|
153
157
|
#closed = false;
|
|
158
|
+
#securityLifecycle;
|
|
159
|
+
#preflightBarrier;
|
|
154
160
|
/** @internal — use {@link createTauriSyncClient}. */
|
|
155
|
-
constructor(tauri, unlisten) {
|
|
161
|
+
constructor(tauri, unlisten, securityLifecycle = 'active') {
|
|
156
162
|
this.#tauri = tauri;
|
|
157
163
|
this.#unlisten = unlisten;
|
|
164
|
+
this.#securityLifecycle = securityLifecycle;
|
|
158
165
|
}
|
|
159
166
|
/** Dispatch a `syncular_command` and unwrap `{result}` / throw on `{error}`. */
|
|
160
167
|
async #command(method, params) {
|
|
168
|
+
if (this.#closed) {
|
|
169
|
+
throw new TauriSyncError('client.closed', 'the Tauri sync client is closed');
|
|
170
|
+
}
|
|
171
|
+
if (this.#securityLifecycle === 'preflight' &&
|
|
172
|
+
![
|
|
173
|
+
'securityLifecycle',
|
|
174
|
+
'beginSecurityPreflight',
|
|
175
|
+
'activateSecurity',
|
|
176
|
+
'purgeLocalData',
|
|
177
|
+
'localRevision',
|
|
178
|
+
'statusSnapshot',
|
|
179
|
+
'shutdown',
|
|
180
|
+
].includes(method)) {
|
|
181
|
+
this.#throwSecurityPreflight();
|
|
182
|
+
}
|
|
161
183
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_command`, { command: { method, params } });
|
|
162
184
|
if (reply.error !== undefined) {
|
|
163
185
|
throw new TauriSyncError(reply.error.code, reply.error.message);
|
|
164
186
|
}
|
|
165
187
|
return reply.result;
|
|
166
188
|
}
|
|
189
|
+
#throwSecurityPreflight() {
|
|
190
|
+
throw new TauriSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data');
|
|
191
|
+
}
|
|
192
|
+
#requireActive() {
|
|
193
|
+
if (this.#securityLifecycle === 'preflight')
|
|
194
|
+
this.#throwSecurityPreflight();
|
|
195
|
+
}
|
|
167
196
|
/** @internal — fan an incoming plugin event out to the local listeners. */
|
|
168
197
|
__dispatchEvent(event) {
|
|
169
198
|
switch (event.type) {
|
|
@@ -211,6 +240,40 @@ export class TauriSyncClient {
|
|
|
211
240
|
}
|
|
212
241
|
}
|
|
213
242
|
// -- SyncClientLike --------------------------------------------------------
|
|
243
|
+
securityLifecycle() {
|
|
244
|
+
return Promise.resolve(this.#securityLifecycle);
|
|
245
|
+
}
|
|
246
|
+
beginSecurityPreflight() {
|
|
247
|
+
if (this.#closed) {
|
|
248
|
+
return Promise.reject(new TauriSyncError('client.closed', 'the Tauri sync client is closed'));
|
|
249
|
+
}
|
|
250
|
+
if (this.#preflightBarrier !== undefined)
|
|
251
|
+
return this.#preflightBarrier;
|
|
252
|
+
// Flip synchronously so a same-webview query cannot race the IPC barrier.
|
|
253
|
+
this.#securityLifecycle = 'preflight';
|
|
254
|
+
const barrier = this.#command('beginSecurityPreflight', {}).then(() => { });
|
|
255
|
+
this.#preflightBarrier = barrier;
|
|
256
|
+
void barrier.then(() => {
|
|
257
|
+
if (this.#preflightBarrier === barrier)
|
|
258
|
+
this.#preflightBarrier = undefined;
|
|
259
|
+
}, () => {
|
|
260
|
+
if (this.#preflightBarrier === barrier)
|
|
261
|
+
this.#preflightBarrier = undefined;
|
|
262
|
+
});
|
|
263
|
+
return barrier;
|
|
264
|
+
}
|
|
265
|
+
async activateSecurity(options = {}) {
|
|
266
|
+
if (this.#securityLifecycle === 'active') {
|
|
267
|
+
throw new TauriSyncError('sync.invalid_request', 'activateSecurity requires the client to be in security preflight');
|
|
268
|
+
}
|
|
269
|
+
await this.#preflightBarrier;
|
|
270
|
+
await this.#command('activateSecurity', {
|
|
271
|
+
...(options.encryption !== undefined
|
|
272
|
+
? { encryption: encodeEncryption(options.encryption) }
|
|
273
|
+
: {}),
|
|
274
|
+
});
|
|
275
|
+
this.#securityLifecycle = 'active';
|
|
276
|
+
}
|
|
214
277
|
onInvalidate(listener) {
|
|
215
278
|
this.#invalidationListeners.add(listener);
|
|
216
279
|
return () => this.#invalidationListeners.delete(listener);
|
|
@@ -224,6 +287,7 @@ export class TauriSyncClient {
|
|
|
224
287
|
return () => this.#presenceListeners.delete(listener);
|
|
225
288
|
}
|
|
226
289
|
async query(sql, params) {
|
|
290
|
+
this.#requireActive();
|
|
227
291
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query`, { sql, params: (params ?? []).map(encodeParam) });
|
|
228
292
|
if (reply.error !== undefined) {
|
|
229
293
|
throw new TauriSyncError(reply.error.code, reply.error.message);
|
|
@@ -232,6 +296,7 @@ export class TauriSyncClient {
|
|
|
232
296
|
return rows.map((r) => decodeRow(r));
|
|
233
297
|
}
|
|
234
298
|
async querySnapshot(spec) {
|
|
299
|
+
this.#requireActive();
|
|
235
300
|
const reply = await this.#tauri.invoke(`${PLUGIN}syncular_query_snapshot`, {
|
|
236
301
|
sql: spec.sql,
|
|
237
302
|
params: (spec.params ?? []).map(encodeParam),
|
|
@@ -424,11 +489,16 @@ export class TauriSyncClient {
|
|
|
424
489
|
async disconnectRealtime() {
|
|
425
490
|
await this.#command('disconnectRealtime', {});
|
|
426
491
|
}
|
|
427
|
-
/**
|
|
492
|
+
/** Shut down the native core, release its keyring, then detach listeners. */
|
|
428
493
|
async close() {
|
|
429
494
|
if (this.#closed)
|
|
430
495
|
return;
|
|
431
|
-
|
|
496
|
+
try {
|
|
497
|
+
await this.#command('shutdown', {});
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
this.#closed = true;
|
|
501
|
+
}
|
|
432
502
|
this.#unlisten?.();
|
|
433
503
|
this.#unlisten = undefined;
|
|
434
504
|
this.#invalidationListeners.clear();
|
|
@@ -544,7 +614,11 @@ export async function createTauriSyncClient(config) {
|
|
|
544
614
|
const unlisten = await tauri.listen(SYNCULAR_EVENT, (event) => {
|
|
545
615
|
clientRef.client?.__dispatchEvent(event.payload);
|
|
546
616
|
});
|
|
547
|
-
|
|
617
|
+
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
618
|
+
unlisten();
|
|
619
|
+
throw new TauriSyncError('sync.invalid_request', 'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight');
|
|
620
|
+
}
|
|
621
|
+
const client = new TauriSyncClient(tauri, unlisten, config.securityPreflight === true ? 'preflight' : 'active');
|
|
548
622
|
clientRef.client = client;
|
|
549
623
|
// The native side owns the db path (plugin config); the JS side supplies the
|
|
550
624
|
// schema, clientId, and limits. `dbPath` is injected by the plugin.
|
|
@@ -558,6 +632,9 @@ export async function createTauriSyncClient(config) {
|
|
|
558
632
|
...(config.encryption !== undefined
|
|
559
633
|
? { encryption: encodeEncryption(config.encryption) }
|
|
560
634
|
: {}),
|
|
635
|
+
...(config.securityPreflight !== undefined
|
|
636
|
+
? { securityPreflight: config.securityPreflight }
|
|
637
|
+
: {}),
|
|
561
638
|
},
|
|
562
639
|
},
|
|
563
640
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/tauri",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.15",
|
|
4
4
|
"description": "Tauri integration for the Syncular client",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"test": "bun test"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@syncular/client": "0.15.
|
|
51
|
+
"@syncular/client": "0.15.15"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@tauri-apps/api": ">=2.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@syncular/react": "0.15.
|
|
57
|
+
"@syncular/react": "0.15.15"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -27,9 +27,6 @@
|
|
|
27
27
|
* `window.__TAURI__`, or via injected doubles (tests).
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
// -- Types the bridge speaks (structurally the web-client's) -----------------
|
|
31
|
-
// Imported as types only, so the bridge has no runtime dependency on
|
|
32
|
-
// @syncular/client (the app already carries it via @syncular/react).
|
|
33
30
|
import type {
|
|
34
31
|
ClientChangeBatch,
|
|
35
32
|
ClientChangeListener,
|
|
@@ -49,12 +46,17 @@ import type {
|
|
|
49
46
|
RejectionRecord,
|
|
50
47
|
ResolveCommitOutcomeInput,
|
|
51
48
|
SchemaFloor,
|
|
49
|
+
SecurityLifecycle,
|
|
52
50
|
SqlRow,
|
|
53
51
|
SqlValue,
|
|
54
52
|
SyncStatusSnapshot,
|
|
55
53
|
WindowBase,
|
|
56
54
|
WindowState,
|
|
57
55
|
} from '@syncular/client';
|
|
56
|
+
// -- Types the bridge speaks (structurally the web-client's) -----------------
|
|
57
|
+
// Most imports stay type-only; the stable preflight error code is shared at
|
|
58
|
+
// runtime so every host surfaces byte-identical policy evidence.
|
|
59
|
+
import { SECURITY_PREFLIGHT_REQUIRED_CODE } from '@syncular/client';
|
|
58
60
|
|
|
59
61
|
/** A driver-protocol reply: `{result}` on success or `{error}` on failure. */
|
|
60
62
|
interface CommandReply {
|
|
@@ -99,6 +101,8 @@ export interface TauriSyncClientConfig {
|
|
|
99
101
|
* encoded into the native command envelope and never sent to the server.
|
|
100
102
|
*/
|
|
101
103
|
readonly encryption?: EncryptionKeyringConfig;
|
|
104
|
+
/** Open the native replica behind the fail-closed security gate. */
|
|
105
|
+
readonly securityPreflight?: boolean;
|
|
102
106
|
/**
|
|
103
107
|
* The Tauri primitives. Omit in a real Tauri webview to auto-resolve from
|
|
104
108
|
* `@tauri-apps/api` (peer dep) or the ambient `window.__TAURI__`; inject in
|
|
@@ -249,11 +253,18 @@ export class TauriSyncClient {
|
|
|
249
253
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
250
254
|
#unlisten: (() => void) | undefined;
|
|
251
255
|
#closed = false;
|
|
256
|
+
#securityLifecycle: SecurityLifecycle;
|
|
257
|
+
#preflightBarrier: Promise<void> | undefined;
|
|
252
258
|
|
|
253
259
|
/** @internal — use {@link createTauriSyncClient}. */
|
|
254
|
-
constructor(
|
|
260
|
+
constructor(
|
|
261
|
+
tauri: TauriApi,
|
|
262
|
+
unlisten: () => void,
|
|
263
|
+
securityLifecycle: SecurityLifecycle = 'active',
|
|
264
|
+
) {
|
|
255
265
|
this.#tauri = tauri;
|
|
256
266
|
this.#unlisten = unlisten;
|
|
267
|
+
this.#securityLifecycle = securityLifecycle;
|
|
257
268
|
}
|
|
258
269
|
|
|
259
270
|
/** Dispatch a `syncular_command` and unwrap `{result}` / throw on `{error}`. */
|
|
@@ -261,6 +272,26 @@ export class TauriSyncClient {
|
|
|
261
272
|
method: string,
|
|
262
273
|
params: Record<string, unknown>,
|
|
263
274
|
): Promise<unknown> {
|
|
275
|
+
if (this.#closed) {
|
|
276
|
+
throw new TauriSyncError(
|
|
277
|
+
'client.closed',
|
|
278
|
+
'the Tauri sync client is closed',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
this.#securityLifecycle === 'preflight' &&
|
|
283
|
+
![
|
|
284
|
+
'securityLifecycle',
|
|
285
|
+
'beginSecurityPreflight',
|
|
286
|
+
'activateSecurity',
|
|
287
|
+
'purgeLocalData',
|
|
288
|
+
'localRevision',
|
|
289
|
+
'statusSnapshot',
|
|
290
|
+
'shutdown',
|
|
291
|
+
].includes(method)
|
|
292
|
+
) {
|
|
293
|
+
this.#throwSecurityPreflight();
|
|
294
|
+
}
|
|
264
295
|
const reply = await this.#tauri.invoke<CommandReply>(
|
|
265
296
|
`${PLUGIN}syncular_command`,
|
|
266
297
|
{ command: { method, params } },
|
|
@@ -271,6 +302,17 @@ export class TauriSyncClient {
|
|
|
271
302
|
return reply.result;
|
|
272
303
|
}
|
|
273
304
|
|
|
305
|
+
#throwSecurityPreflight(): never {
|
|
306
|
+
throw new TauriSyncError(
|
|
307
|
+
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
308
|
+
'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#requireActive(): void {
|
|
313
|
+
if (this.#securityLifecycle === 'preflight') this.#throwSecurityPreflight();
|
|
314
|
+
}
|
|
315
|
+
|
|
274
316
|
/** @internal — fan an incoming plugin event out to the local listeners. */
|
|
275
317
|
__dispatchEvent(event: SyncularEvent): void {
|
|
276
318
|
switch (event.type) {
|
|
@@ -316,6 +358,52 @@ export class TauriSyncClient {
|
|
|
316
358
|
|
|
317
359
|
// -- SyncClientLike --------------------------------------------------------
|
|
318
360
|
|
|
361
|
+
securityLifecycle(): Promise<SecurityLifecycle> {
|
|
362
|
+
return Promise.resolve(this.#securityLifecycle);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
beginSecurityPreflight(): Promise<void> {
|
|
366
|
+
if (this.#closed) {
|
|
367
|
+
return Promise.reject(
|
|
368
|
+
new TauriSyncError('client.closed', 'the Tauri sync client is closed'),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (this.#preflightBarrier !== undefined) return this.#preflightBarrier;
|
|
372
|
+
// Flip synchronously so a same-webview query cannot race the IPC barrier.
|
|
373
|
+
this.#securityLifecycle = 'preflight';
|
|
374
|
+
const barrier = this.#command('beginSecurityPreflight', {}).then(() => {});
|
|
375
|
+
this.#preflightBarrier = barrier;
|
|
376
|
+
void barrier.then(
|
|
377
|
+
() => {
|
|
378
|
+
if (this.#preflightBarrier === barrier)
|
|
379
|
+
this.#preflightBarrier = undefined;
|
|
380
|
+
},
|
|
381
|
+
() => {
|
|
382
|
+
if (this.#preflightBarrier === barrier)
|
|
383
|
+
this.#preflightBarrier = undefined;
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
return barrier;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async activateSecurity(
|
|
390
|
+
options: { readonly encryption?: EncryptionKeyringConfig } = {},
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
if (this.#securityLifecycle === 'active') {
|
|
393
|
+
throw new TauriSyncError(
|
|
394
|
+
'sync.invalid_request',
|
|
395
|
+
'activateSecurity requires the client to be in security preflight',
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
await this.#preflightBarrier;
|
|
399
|
+
await this.#command('activateSecurity', {
|
|
400
|
+
...(options.encryption !== undefined
|
|
401
|
+
? { encryption: encodeEncryption(options.encryption) }
|
|
402
|
+
: {}),
|
|
403
|
+
});
|
|
404
|
+
this.#securityLifecycle = 'active';
|
|
405
|
+
}
|
|
406
|
+
|
|
319
407
|
onInvalidate(listener: InvalidationListener): () => void {
|
|
320
408
|
this.#invalidationListeners.add(listener);
|
|
321
409
|
return () => this.#invalidationListeners.delete(listener);
|
|
@@ -332,6 +420,7 @@ export class TauriSyncClient {
|
|
|
332
420
|
}
|
|
333
421
|
|
|
334
422
|
async query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]> {
|
|
423
|
+
this.#requireActive();
|
|
335
424
|
const reply = await this.#tauri.invoke<CommandReply>(
|
|
336
425
|
`${PLUGIN}syncular_query`,
|
|
337
426
|
{ sql, params: (params ?? []).map(encodeParam) },
|
|
@@ -346,6 +435,7 @@ export class TauriSyncClient {
|
|
|
346
435
|
async querySnapshot<Row = SqlRow>(
|
|
347
436
|
spec: QueryReadSpec,
|
|
348
437
|
): Promise<QuerySnapshot<Row>> {
|
|
438
|
+
this.#requireActive();
|
|
349
439
|
const reply = await this.#tauri.invoke<CommandReply>(
|
|
350
440
|
`${PLUGIN}syncular_query_snapshot`,
|
|
351
441
|
{
|
|
@@ -643,10 +733,14 @@ export class TauriSyncClient {
|
|
|
643
733
|
await this.#command('disconnectRealtime', {});
|
|
644
734
|
}
|
|
645
735
|
|
|
646
|
-
/**
|
|
736
|
+
/** Shut down the native core, release its keyring, then detach listeners. */
|
|
647
737
|
async close(): Promise<void> {
|
|
648
738
|
if (this.#closed) return;
|
|
649
|
-
|
|
739
|
+
try {
|
|
740
|
+
await this.#command('shutdown', {});
|
|
741
|
+
} finally {
|
|
742
|
+
this.#closed = true;
|
|
743
|
+
}
|
|
650
744
|
this.#unlisten?.();
|
|
651
745
|
this.#unlisten = undefined;
|
|
652
746
|
this.#invalidationListeners.clear();
|
|
@@ -770,7 +864,19 @@ export async function createTauriSyncClient(
|
|
|
770
864
|
},
|
|
771
865
|
);
|
|
772
866
|
|
|
773
|
-
|
|
867
|
+
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
868
|
+
unlisten();
|
|
869
|
+
throw new TauriSyncError(
|
|
870
|
+
'sync.invalid_request',
|
|
871
|
+
'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const client = new TauriSyncClient(
|
|
876
|
+
tauri,
|
|
877
|
+
unlisten,
|
|
878
|
+
config.securityPreflight === true ? 'preflight' : 'active',
|
|
879
|
+
);
|
|
774
880
|
clientRef.client = client;
|
|
775
881
|
|
|
776
882
|
// The native side owns the db path (plugin config); the JS side supplies the
|
|
@@ -785,6 +891,9 @@ export async function createTauriSyncClient(
|
|
|
785
891
|
...(config.encryption !== undefined
|
|
786
892
|
? { encryption: encodeEncryption(config.encryption) }
|
|
787
893
|
: {}),
|
|
894
|
+
...(config.securityPreflight !== undefined
|
|
895
|
+
? { securityPreflight: config.securityPreflight }
|
|
896
|
+
: {}),
|
|
788
897
|
},
|
|
789
898
|
},
|
|
790
899
|
});
|