@unified-desktop/bridge 0.0.0-stage → 0.2.4

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.
@@ -0,0 +1,394 @@
1
+ import {
2
+ BridgeError,
3
+ assertSafeExternalUrl,
4
+ createCommonBridge,
5
+ createDialogBridge,
6
+ createLogger,
7
+ createWebPermissionBridge,
8
+ getBridgeConfig,
9
+ toBridgeError
10
+ } from "./chunk-XDKQZC4S.js";
11
+
12
+ // src/tauri.ts
13
+ import { invoke } from "@tauri-apps/api/core";
14
+ import { listen } from "@tauri-apps/api/event";
15
+ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
16
+ import { readText as readClipboardText, writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
17
+ import { confirm as showConfirmDialog, message as showMessageDialog } from "@tauri-apps/plugin-dialog";
18
+ import {
19
+ isPermissionGranted as isNotificationPermissionGranted,
20
+ requestPermission as requestNotificationPermission,
21
+ sendNotification
22
+ } from "@tauri-apps/plugin-notification";
23
+ import { openUrl } from "@tauri-apps/plugin-opener";
24
+ import { load } from "@tauri-apps/plugin-store";
25
+ import { Stronghold } from "@tauri-apps/plugin-stronghold";
26
+ import { appDataDir } from "@tauri-apps/api/path";
27
+ import { check as checkForTauriUpdate } from "@tauri-apps/plugin-updater";
28
+ async function invokeCommand(command, args) {
29
+ try {
30
+ return await invoke(command, args);
31
+ } catch (error) {
32
+ throw toBridgeError(error);
33
+ }
34
+ }
35
+ function createTauriBridge(overrides = {}) {
36
+ const updaterConfig = getBridgeConfig().updater;
37
+ const isEnabled = (name) => getBridgeConfig().runtimePermissions?.[name]?.enabled === true;
38
+ const assertEnabled = (name) => {
39
+ if (!isEnabled(name)) throw new BridgeError({ code: "UNSUPPORTED", message: `${name} is disabled by the application policy.`, retryable: false });
40
+ };
41
+ let pendingUpdate;
42
+ const updateListeners = /* @__PURE__ */ new Set();
43
+ const emitUpdateProgress = (progress) => updateListeners.forEach((listener) => listener(progress));
44
+ let storePromise;
45
+ const getStore = () => {
46
+ storePromise ?? (storePromise = load("unified-desktop-store.json", { autoSave: 100 }));
47
+ return storePromise;
48
+ };
49
+ const fileSystem = {
50
+ readTextFile: (path) => invokeCommand("read_text_file", { path }),
51
+ saveTextFile: ({ suggestedName, contents }) => invokeCommand("save_text_file", { suggestedName, contents }),
52
+ async pickFiles(options) {
53
+ return invokeCommand("file_pick_open", { options });
54
+ },
55
+ async pickDirectory(options) {
56
+ return invokeCommand("file_pick_directory", { options });
57
+ },
58
+ async pickSaveFile(options) {
59
+ return invokeCommand("file_pick_save", { options });
60
+ },
61
+ async readFile(handle) {
62
+ return new Uint8Array(await invokeCommand("file_read", { id: handle.id }));
63
+ },
64
+ writeFile: (handle, data) => invokeCommand("file_write", { id: handle.id, data: Array.from(data) }),
65
+ releaseFileHandle: (handle) => invokeCommand("file_release_handle", { id: handle.id })
66
+ };
67
+ let strongholdPromise;
68
+ const getSecureStore = async () => {
69
+ strongholdPromise ?? (strongholdPromise = (async () => {
70
+ const password = await invokeCommand("secure_vault_password");
71
+ const vaultPath = `${await appDataDir()}unified-desktop-secure.hold`;
72
+ const stronghold = await Stronghold.load(vaultPath, password);
73
+ let client;
74
+ try {
75
+ client = await stronghold.loadClient("unified-desktop");
76
+ } catch {
77
+ client = await stronghold.createClient("unified-desktop");
78
+ }
79
+ return { stronghold, store: client.getStore() };
80
+ })());
81
+ return strongholdPromise;
82
+ };
83
+ const secureStorage = {
84
+ async get(key) {
85
+ try {
86
+ const value = await (await getSecureStore()).store.get(key);
87
+ return value === null ? null : new TextDecoder().decode(new Uint8Array(value));
88
+ } catch (error) {
89
+ throw toBridgeError(error);
90
+ }
91
+ },
92
+ async set(key, value) {
93
+ try {
94
+ const secure = await getSecureStore();
95
+ await secure.store.insert(key, Array.from(new TextEncoder().encode(value)));
96
+ await secure.stronghold.save();
97
+ } catch (error) {
98
+ throw toBridgeError(error);
99
+ }
100
+ },
101
+ async has(key) {
102
+ try {
103
+ const value = await (await getSecureStore()).store.get(key);
104
+ return value !== null;
105
+ } catch (error) {
106
+ throw toBridgeError(error);
107
+ }
108
+ },
109
+ async delete(key) {
110
+ try {
111
+ const secure = await getSecureStore();
112
+ const previous = await secure.store.remove(key);
113
+ if (previous === null) return false;
114
+ await secure.stronghold.save();
115
+ return true;
116
+ } catch (error) {
117
+ throw toBridgeError(error);
118
+ }
119
+ }
120
+ };
121
+ const browserPermissions = createWebPermissionBridge();
122
+ const permissions = {
123
+ async query(name, options) {
124
+ try {
125
+ if (!isEnabled(name)) return { name, state: "unsupported", scope: "security-policy" };
126
+ if (name === "notifications") {
127
+ const granted = await isNotificationPermissionGranted();
128
+ return { name, state: granted ? "granted" : "prompt", scope: "native-notification" };
129
+ }
130
+ const native = await invokeCommand("permission_query", { name, handleId: options?.handleId });
131
+ return native.state === "prompt" && !name.startsWith("file-") && !name.startsWith("directory-") ? browserPermissions.query(name, options) : native;
132
+ } catch (error) {
133
+ throw toBridgeError(error);
134
+ }
135
+ },
136
+ async request(name, options) {
137
+ try {
138
+ if (!isEnabled(name)) return { name, state: "unsupported", scope: "security-policy" };
139
+ if (name === "notifications") {
140
+ const permission = await requestNotificationPermission();
141
+ return {
142
+ name,
143
+ state: permission === "default" ? "prompt" : permission,
144
+ scope: "native-notification"
145
+ };
146
+ }
147
+ const native = await invokeCommand("permission_request", { name, handleId: options?.handleId });
148
+ return native.state === "prompt" && !name.startsWith("file-") && !name.startsWith("directory-") ? browserPermissions.request(name, options) : native;
149
+ } catch (error) {
150
+ throw toBridgeError(error);
151
+ }
152
+ }
153
+ };
154
+ const clipboard = {
155
+ async readText() {
156
+ assertEnabled("clipboard-read");
157
+ try {
158
+ return await readClipboardText();
159
+ } catch (error) {
160
+ throw toBridgeError(error, { code: "PERMISSION_DENIED", retryable: false });
161
+ }
162
+ },
163
+ async writeText(text) {
164
+ assertEnabled("clipboard-write");
165
+ try {
166
+ await writeClipboardText(text);
167
+ } catch (error) {
168
+ throw toBridgeError(error, { code: "PERMISSION_DENIED", retryable: false });
169
+ }
170
+ }
171
+ };
172
+ const notification = {
173
+ async requestPermission() {
174
+ assertEnabled("notifications");
175
+ try {
176
+ const granted = await isNotificationPermissionGranted();
177
+ if (granted) return "granted";
178
+ return await requestNotificationPermission();
179
+ } catch (error) {
180
+ throw toBridgeError(error, { code: "PERMISSION_DENIED", retryable: false });
181
+ }
182
+ },
183
+ async show({ title, body }) {
184
+ assertEnabled("notifications");
185
+ try {
186
+ let granted = await isNotificationPermissionGranted();
187
+ if (!granted) granted = await requestNotificationPermission() === "granted";
188
+ if (!granted) throw new Error("Notification permission was not granted.");
189
+ sendNotification({ title, body });
190
+ } catch (error) {
191
+ throw toBridgeError(error, { code: "PERMISSION_DENIED", retryable: false });
192
+ }
193
+ }
194
+ };
195
+ const webDialog = createDialogBridge();
196
+ const nativeDialog = {
197
+ async alert(message, title) {
198
+ try {
199
+ await showMessageDialog(message, { title, kind: "info" });
200
+ } catch (error) {
201
+ throw toBridgeError(error);
202
+ }
203
+ },
204
+ async confirm(message, title) {
205
+ try {
206
+ return await showConfirmDialog(message, { title, kind: "info" });
207
+ } catch (error) {
208
+ throw toBridgeError(error);
209
+ }
210
+ },
211
+ prompt: webDialog.prompt
212
+ };
213
+ const store = {
214
+ get: async (key) => {
215
+ try {
216
+ return await (await getStore()).get(key);
217
+ } catch (error) {
218
+ throw toBridgeError(error);
219
+ }
220
+ },
221
+ set: async (key, value) => {
222
+ try {
223
+ await (await getStore()).set(key, value);
224
+ } catch (error) {
225
+ throw toBridgeError(error);
226
+ }
227
+ },
228
+ has: async (key) => {
229
+ try {
230
+ return await (await getStore()).has(key);
231
+ } catch (error) {
232
+ throw toBridgeError(error);
233
+ }
234
+ },
235
+ delete: async (key) => {
236
+ try {
237
+ return await (await getStore()).delete(key);
238
+ } catch (error) {
239
+ throw toBridgeError(error);
240
+ }
241
+ },
242
+ clear: async () => {
243
+ try {
244
+ await (await getStore()).clear();
245
+ } catch (error) {
246
+ throw toBridgeError(error);
247
+ }
248
+ },
249
+ keys: async () => {
250
+ try {
251
+ return await (await getStore()).keys();
252
+ } catch (error) {
253
+ throw toBridgeError(error);
254
+ }
255
+ },
256
+ entries: async () => {
257
+ try {
258
+ return await (await getStore()).entries();
259
+ } catch (error) {
260
+ throw toBridgeError(error);
261
+ }
262
+ },
263
+ save: async () => {
264
+ try {
265
+ await (await getStore()).save();
266
+ } catch (error) {
267
+ throw toBridgeError(error);
268
+ }
269
+ },
270
+ subscribe(listener) {
271
+ let disposed = false;
272
+ let unlisten;
273
+ void getStore().then((loadedStore) => loadedStore.onChange((key, value) => listener({ key, value }))).then((stop) => {
274
+ if (disposed) stop();
275
+ else unlisten = stop;
276
+ }).catch(() => void 0);
277
+ return () => {
278
+ disposed = true;
279
+ unlisten?.();
280
+ };
281
+ }
282
+ };
283
+ const deepLink = {
284
+ async getInitialUrl() {
285
+ try {
286
+ const urls = await getCurrent();
287
+ return urls?.[0] ?? null;
288
+ } catch (error) {
289
+ throw toBridgeError(error);
290
+ }
291
+ },
292
+ subscribe(listener) {
293
+ let disposed = false;
294
+ let unlisten;
295
+ void onOpenUrl((urls) => urls.forEach(listener)).then((stop) => {
296
+ if (disposed) stop();
297
+ else unlisten = stop;
298
+ });
299
+ return () => {
300
+ disposed = true;
301
+ unlisten?.();
302
+ };
303
+ }
304
+ };
305
+ const windowState = {
306
+ get: () => invokeCommand("window_get_state"),
307
+ set: (bounds) => invokeCommand("window_set_state", { bounds }),
308
+ subscribe(listener) {
309
+ let disposed = false;
310
+ let unlisten;
311
+ void listen("window-state-changed", (event) => listener(event.payload)).then((stop) => {
312
+ if (disposed) stop();
313
+ else unlisten = stop;
314
+ });
315
+ return () => {
316
+ disposed = true;
317
+ unlisten?.();
318
+ };
319
+ }
320
+ };
321
+ return createCommonBridge("tauri", {
322
+ logger: createLogger("tauri", (level, message, context) => {
323
+ void invokeCommand("log_write", { level, message, context }).catch(() => void 0);
324
+ }),
325
+ fileSystem,
326
+ clipboard,
327
+ dialog: nativeDialog,
328
+ notification,
329
+ shell: {
330
+ openExternal: async (url) => {
331
+ try {
332
+ await openUrl(assertSafeExternalUrl(url));
333
+ } catch (error) {
334
+ throw toBridgeError(error);
335
+ }
336
+ },
337
+ openPath: (path) => invokeCommand("shell_open_path", { path }),
338
+ revealItem: (path) => invokeCommand("shell_reveal_item", { path })
339
+ },
340
+ process: {
341
+ info: () => invokeCommand("process_info"),
342
+ exit: (code) => invokeCommand("process_exit", { code }),
343
+ restart: () => invokeCommand("process_restart")
344
+ },
345
+ deepLink,
346
+ updater: {
347
+ async check() {
348
+ if (!updaterConfig.enabled) return null;
349
+ try {
350
+ pendingUpdate = await checkForTauriUpdate({ timeout: updaterConfig.requestTimeoutMs }) ?? void 0;
351
+ if (!pendingUpdate) return null;
352
+ return {
353
+ version: pendingUpdate.version,
354
+ notes: pendingUpdate.body,
355
+ date: pendingUpdate.date,
356
+ channel: updaterConfig.channel
357
+ };
358
+ } catch (error) {
359
+ throw toBridgeError(error, { code: "NETWORK_ERROR", retryable: true });
360
+ }
361
+ },
362
+ async downloadAndInstall() {
363
+ if (!updaterConfig.enabled) throw toBridgeError("The Tauri updater is disabled.", { code: "UNSUPPORTED", retryable: false });
364
+ try {
365
+ pendingUpdate ?? (pendingUpdate = await checkForTauriUpdate({ timeout: updaterConfig.requestTimeoutMs }) ?? void 0);
366
+ if (!pendingUpdate) return;
367
+ let downloaded = 0;
368
+ let total = 0;
369
+ await pendingUpdate.downloadAndInstall((event) => {
370
+ if (event.event === "Started") total = event.data.contentLength ?? 0;
371
+ if (event.event === "Progress") downloaded += event.data.chunkLength;
372
+ emitUpdateProgress(total > 0 ? Math.min(100, downloaded / total * 100) : 0);
373
+ });
374
+ emitUpdateProgress(100);
375
+ pendingUpdate = void 0;
376
+ await invokeCommand("process_restart");
377
+ } catch (error) {
378
+ throw toBridgeError(error, { code: "NETWORK_ERROR", retryable: true });
379
+ }
380
+ },
381
+ subscribe(listener) {
382
+ updateListeners.add(listener);
383
+ return () => updateListeners.delete(listener);
384
+ }
385
+ },
386
+ windowState,
387
+ store,
388
+ secureStorage,
389
+ permissions
390
+ }, overrides);
391
+ }
392
+ export {
393
+ createTauriBridge
394
+ };
@@ -0,0 +1,3 @@
1
+ import { type BridgeOverrides } from './common/bridge';
2
+ import type { AppBridge } from './types';
3
+ export declare function createTauriBridge(overrides?: BridgeOverrides): AppBridge;
@@ -0,0 +1,57 @@
1
+ import type { ClipboardBridge } from './common/clipboard';
2
+ import type { DeepLinkBridge } from './common/deep-link';
3
+ import type { DialogBridge } from './common/dialog';
4
+ import type { FileSystemBridge } from './common/file-system';
5
+ import type { HttpClient } from './common/http';
6
+ import type { Logger } from './common/logging';
7
+ import type { NotificationBridge } from './common/notification';
8
+ import type { ProcessBridge } from './common/process';
9
+ import type { ShellBridge } from './common/shell';
10
+ import type { UpdaterBridge } from './common/updater';
11
+ import type { UploadBridge } from './common/upload';
12
+ import type { WebSocketBridge } from './common/websocket';
13
+ import type { WindowStateBridge } from './common/window-state';
14
+ import type { StoreBridge } from './common/store';
15
+ import type { SecureStorageBridge } from './common/secure-storage';
16
+ import type { PermissionBridge } from './common/permissions';
17
+ export type RuntimeKind = 'electron' | 'tauri' | 'web';
18
+ export type { AppErrorCode, AppErrorShape, BridgeError } from './errors';
19
+ export interface UpdateInfo {
20
+ version: string;
21
+ notes?: string;
22
+ url?: string;
23
+ date?: string;
24
+ channel?: string;
25
+ }
26
+ export interface SaveTextFileInput {
27
+ suggestedName: string;
28
+ contents: string;
29
+ }
30
+ /**
31
+ * The only native API surface the UI should depend on.
32
+ * Add capabilities here first, then implement them in each shell adapter.
33
+ */
34
+ export interface AppBridge {
35
+ readonly runtime: RuntimeKind;
36
+ readonly http: HttpClient;
37
+ readonly logger: Logger;
38
+ readonly fileSystem: FileSystemBridge;
39
+ readonly deepLink: DeepLinkBridge;
40
+ readonly clipboard: ClipboardBridge;
41
+ readonly dialog: DialogBridge;
42
+ readonly notification: NotificationBridge;
43
+ readonly process: ProcessBridge;
44
+ readonly shell: ShellBridge;
45
+ readonly updater: UpdaterBridge;
46
+ readonly upload: UploadBridge;
47
+ readonly websocket: WebSocketBridge;
48
+ readonly windowState: WindowStateBridge;
49
+ readonly store: StoreBridge;
50
+ readonly secureStorage: SecureStorageBridge;
51
+ readonly permissions: PermissionBridge;
52
+ /** Backward-compatible aliases. Prefer the namespaced capabilities above. */
53
+ readTextFile(path: string): Promise<string>;
54
+ saveTextFile(input: SaveTextFileInput): Promise<void>;
55
+ openExternal(url: string): Promise<void>;
56
+ checkUpdate(): Promise<UpdateInfo | null>;
57
+ }
@@ -0,0 +1,11 @@
1
+ import {
2
+ createCommonBridge
3
+ } from "./chunk-XDKQZC4S.js";
4
+
5
+ // src/web.ts
6
+ function createWebBridge(overrides = {}) {
7
+ return createCommonBridge("web", overrides);
8
+ }
9
+ export {
10
+ createWebBridge
11
+ };
package/dist/web.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { type BridgeOverrides } from './common/bridge';
2
+ import type { AppBridge } from './types';
3
+ export declare function createWebBridge(overrides?: BridgeOverrides): AppBridge;
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@unified-desktop/bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.4",
4
+ "type": "module",
5
+ "description": "Framework-neutral Web/Electron/Tauri bridge",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "node build.mjs",
21
+ "test": "vitest run --config vitest.config.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "@tauri-apps/api": "^2.8.0",
26
+ "@tauri-apps/plugin-clipboard-manager": "^2.3.3",
27
+ "@tauri-apps/plugin-deep-link": "^2.4.0",
28
+ "@tauri-apps/plugin-dialog": "^2.7.3",
29
+ "@tauri-apps/plugin-notification": "^2.4.0",
30
+ "@tauri-apps/plugin-opener": "^2.4.0",
31
+ "@tauri-apps/plugin-store": "^2.4.0",
32
+ "@tauri-apps/plugin-stronghold": "^2.3.2",
33
+ "@tauri-apps/plugin-updater": "^2.11.0"
34
+ },
35
+ "devDependencies": {
36
+ "esbuild": "^0.28.2",
37
+ "typescript": "^5.9.2",
38
+ "vitest": "^4.1.12"
39
+ }
40
+ }