@sublime-ui/desktop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/bridge/main-router.d.ts +30 -0
- package/dist/bridge/main-router.js +22 -0
- package/dist/bridge/preload.d.ts +32 -0
- package/dist/bridge/preload.js +9 -0
- package/dist/bridge/proxy.d.ts +24 -0
- package/dist/bridge/proxy.js +10 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +16 -0
- package/dist/define-native.d.ts +18 -0
- package/dist/define-native.js +6 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +29 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +46 -0
- package/dist/registry.d.ts +22 -0
- package/dist/registry.js +21 -0
- package/dist/services/clipboard.d.ts +16 -0
- package/dist/services/clipboard.js +15 -0
- package/dist/services/dialog.d.ts +19 -0
- package/dist/services/dialog.js +23 -0
- package/dist/services/fs.d.ts +20 -0
- package/dist/services/fs.js +26 -0
- package/dist/services/get-electron.d.ts +14 -0
- package/dist/services/get-electron.js +6 -0
- package/dist/services/index.d.ts +7 -0
- package/dist/services/index.js +12 -0
- package/dist/services/notifications.d.ts +21 -0
- package/dist/services/notifications.js +11 -0
- package/dist/services/shell.d.ts +16 -0
- package/dist/services/shell.js +19 -0
- package/dist/shell/create-window.d.ts +48 -0
- package/dist/shell/create-window.js +28 -0
- package/dist/shell/main.d.ts +53 -0
- package/dist/shell/main.js +18 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.js +0 -0
- package/dist/use-native.d.ts +24 -0
- package/dist/use-native.js +21 -0
- package/package.json +35 -0
- package/src/bridge/main-router.ts +52 -0
- package/src/bridge/preload.ts +43 -0
- package/src/bridge/proxy.ts +31 -0
- package/src/client.ts +33 -0
- package/src/define-native.ts +21 -0
- package/src/errors.ts +45 -0
- package/src/index.ts +68 -0
- package/src/registry.ts +44 -0
- package/src/services/clipboard.ts +21 -0
- package/src/services/dialog.ts +31 -0
- package/src/services/fs.ts +32 -0
- package/src/services/get-electron.ts +14 -0
- package/src/services/index.ts +13 -0
- package/src/services/notifications.ts +24 -0
- package/src/services/shell.ts +24 -0
- package/src/shell/create-window.ts +75 -0
- package/src/shell/main.ts +70 -0
- package/src/types.ts +16 -0
- package/src/use-native.ts +57 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, mkdir as mkdirFs, rm, access } from 'node:fs/promises';
|
|
2
|
+
import { defineNative } from '../define-native';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Built-in `fs` native service.
|
|
6
|
+
*
|
|
7
|
+
* A thin, async wrapper over `node:fs/promises` exposed through the native
|
|
8
|
+
* bridge. Text files are read/written as UTF-8 strings; `mkdir` is recursive
|
|
9
|
+
* and `remove` maps to `rm` with `recursive` + `force` so it never throws on
|
|
10
|
+
* a missing path.
|
|
11
|
+
*/
|
|
12
|
+
export const fs = defineNative('fs', {
|
|
13
|
+
readFile: (path: string): Promise<string> => readFile(path, 'utf8'),
|
|
14
|
+
writeFile: async (path: string, data: string): Promise<void> => {
|
|
15
|
+
await writeFile(path, data, 'utf8');
|
|
16
|
+
},
|
|
17
|
+
exists: async (path: string): Promise<boolean> => {
|
|
18
|
+
try {
|
|
19
|
+
await access(path);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
readDir: (path: string): Promise<string[]> => readdir(path),
|
|
26
|
+
mkdir: async (path: string): Promise<void> => {
|
|
27
|
+
await mkdirFs(path, { recursive: true });
|
|
28
|
+
},
|
|
29
|
+
remove: async (path: string): Promise<void> => {
|
|
30
|
+
await rm(path, { recursive: true, force: true });
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type * as Electron from 'electron';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy, mockable accessor for the `electron` runtime module.
|
|
5
|
+
*
|
|
6
|
+
* The built-in native services run in the main process, where importing
|
|
7
|
+
* `electron` is permitted. Going through this single indirection keeps the
|
|
8
|
+
* import out of module-evaluation time (so the package can be loaded in
|
|
9
|
+
* environments without Electron) and gives unit tests one seam to mock via
|
|
10
|
+
* `vi.mock('electron', …)`.
|
|
11
|
+
*/
|
|
12
|
+
export async function getElectron(): Promise<typeof Electron> {
|
|
13
|
+
return import('electron');
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in native services barrel.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the five batteries-included services authored with
|
|
5
|
+
* `defineNative` in the main process. Register the ones an app needs via
|
|
6
|
+
* `registerNative([...])`, then consume them in the renderer with `useNative`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { fs } from './fs';
|
|
10
|
+
export { dialog } from './dialog';
|
|
11
|
+
export { shell } from './shell';
|
|
12
|
+
export { clipboard } from './clipboard';
|
|
13
|
+
export { notifications, type NotifyOptions } from './notifications';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineNative } from '../define-native';
|
|
2
|
+
import { getElectron } from './get-electron';
|
|
3
|
+
|
|
4
|
+
/** Options for {@link notifications}'s `notify` method. */
|
|
5
|
+
export interface NotifyOptions {
|
|
6
|
+
/** Title line of the notification. */
|
|
7
|
+
title: string;
|
|
8
|
+
/** Body text of the notification. */
|
|
9
|
+
body: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Built-in `notifications` native service.
|
|
14
|
+
*
|
|
15
|
+
* Constructs and shows a native OS notification via Electron's `Notification`
|
|
16
|
+
* class. The Electron module is resolved lazily so the package loads without
|
|
17
|
+
* Electron and unit tests can mock it.
|
|
18
|
+
*/
|
|
19
|
+
export const notifications = defineNative('notifications', {
|
|
20
|
+
notify: async ({ title, body }: NotifyOptions): Promise<void> => {
|
|
21
|
+
const { Notification } = await getElectron();
|
|
22
|
+
new Notification({ title, body }).show();
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineNative } from '../define-native';
|
|
2
|
+
import { getElectron } from './get-electron';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Built-in `shell` native service.
|
|
6
|
+
*
|
|
7
|
+
* Thin async wrappers over Electron's `shell` module for opening URLs, paths,
|
|
8
|
+
* and revealing items in the OS file manager. The Electron module is resolved
|
|
9
|
+
* lazily so the package loads without Electron and unit tests can mock it.
|
|
10
|
+
*/
|
|
11
|
+
export const shell = defineNative('shell', {
|
|
12
|
+
openExternal: async (url: string): Promise<void> => {
|
|
13
|
+
const { shell: s } = await getElectron();
|
|
14
|
+
await s.openExternal(url);
|
|
15
|
+
},
|
|
16
|
+
openPath: async (path: string): Promise<void> => {
|
|
17
|
+
const { shell: s } = await getElectron();
|
|
18
|
+
await s.openPath(path);
|
|
19
|
+
},
|
|
20
|
+
showItemInFolder: async (path: string): Promise<void> => {
|
|
21
|
+
const { shell: s } = await getElectron();
|
|
22
|
+
s.showItemInFolder(path);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure `BrowserWindow` factory.
|
|
3
|
+
*
|
|
4
|
+
* Constructs an Electron window with the hardened defaults the bridge relies
|
|
5
|
+
* on — `contextIsolation: true`, `nodeIntegration: false`, and the given
|
|
6
|
+
* preload — so the renderer can only reach native functionality through the
|
|
7
|
+
* registry-validated `native:invoke` router. The `BrowserWindow` constructor
|
|
8
|
+
* is injectable so unit tests can assert the wiring without launching Electron;
|
|
9
|
+
* in production the real constructor is resolved from the `electron` runtime
|
|
10
|
+
* (this runs in the main process, where requiring `electron` is permitted).
|
|
11
|
+
*
|
|
12
|
+
* `entry` is loaded via `loadURL` when it is an `http(s)` URL (dev server) and
|
|
13
|
+
* via `loadFile` otherwise (packaged HTML on disk).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import type { BrowserWindow } from 'electron';
|
|
18
|
+
|
|
19
|
+
/** Minimal `BrowserWindow` surface the factory drives. */
|
|
20
|
+
export interface BrowserWindowLike {
|
|
21
|
+
loadURL(url: string): unknown;
|
|
22
|
+
loadFile(file: string): unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Constructor signature for an injectable `BrowserWindow`. */
|
|
26
|
+
export type BrowserWindowCtor = new (opts: {
|
|
27
|
+
webPreferences: {
|
|
28
|
+
contextIsolation: boolean;
|
|
29
|
+
nodeIntegration: boolean;
|
|
30
|
+
preload: string;
|
|
31
|
+
};
|
|
32
|
+
}) => BrowserWindow;
|
|
33
|
+
|
|
34
|
+
/** Options for {@link createWindow}. */
|
|
35
|
+
export interface CreateWindowOptions {
|
|
36
|
+
/** URL (dev server) or file path (packaged) to load. */
|
|
37
|
+
entry: string;
|
|
38
|
+
/** Absolute path to the preload script. */
|
|
39
|
+
preload: string;
|
|
40
|
+
/** Injectable `BrowserWindow` constructor; defaults to Electron's. */
|
|
41
|
+
BrowserWindowCtor?: BrowserWindowCtor;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveCtor(): BrowserWindowCtor {
|
|
45
|
+
const require = createRequire(import.meta.url);
|
|
46
|
+
const electron = require('electron') as { BrowserWindow: BrowserWindowCtor };
|
|
47
|
+
return electron.BrowserWindow;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isUrl(entry: string): boolean {
|
|
51
|
+
return /^https?:\/\//.test(entry);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Construct a hardened `BrowserWindow` and load `entry`.
|
|
56
|
+
*
|
|
57
|
+
* @param opts Window options; `BrowserWindowCtor` is injectable for tests.
|
|
58
|
+
* @returns The constructed window.
|
|
59
|
+
*/
|
|
60
|
+
export function createWindow(opts: CreateWindowOptions): BrowserWindow {
|
|
61
|
+
const Ctor = opts.BrowserWindowCtor ?? resolveCtor();
|
|
62
|
+
const win = new Ctor({
|
|
63
|
+
webPreferences: {
|
|
64
|
+
contextIsolation: true,
|
|
65
|
+
nodeIntegration: false,
|
|
66
|
+
preload: opts.preload,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (isUrl(opts.entry)) {
|
|
70
|
+
win.loadURL(opts.entry);
|
|
71
|
+
} else {
|
|
72
|
+
win.loadFile(opts.entry);
|
|
73
|
+
}
|
|
74
|
+
return win;
|
|
75
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron application entry for the native bridge.
|
|
3
|
+
*
|
|
4
|
+
* `startDesktop` is the single bootstrap the packaged main process calls: once
|
|
5
|
+
* Electron's `app` reports ready it installs the `native:invoke` router onto
|
|
6
|
+
* `ipcMain` and opens the hardened {@link createWindow}. `app` and `ipcMain`
|
|
7
|
+
* are injected so unit tests can drive the flow with fakes (an
|
|
8
|
+
* immediately-resolving `whenReady`, a recording `ipcMain`) without launching
|
|
9
|
+
* Electron. `isDev` selects how the entry is loaded — a dev server URL via
|
|
10
|
+
* `loadURL` versus packaged HTML on disk via `loadFile`, which `createWindow`
|
|
11
|
+
* derives from the `entry` shape.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createWindow, type BrowserWindowCtor } from './create-window';
|
|
15
|
+
import { installNativeRouter, type IpcMainLike } from '../bridge/main-router';
|
|
16
|
+
|
|
17
|
+
/** Minimal Electron `app` surface needed to bootstrap. Injectable. */
|
|
18
|
+
export interface AppLike {
|
|
19
|
+
whenReady(): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Options for {@link startDesktop}. */
|
|
23
|
+
export interface StartDesktopOptions {
|
|
24
|
+
/** Electron's `app` (or a compatible fake for tests). */
|
|
25
|
+
app: AppLike;
|
|
26
|
+
/** Electron's `ipcMain` (or a compatible fake for tests). */
|
|
27
|
+
ipcMain: IpcMainLike;
|
|
28
|
+
/** URL (dev) or file path (packaged) to load in the window. */
|
|
29
|
+
entry: string;
|
|
30
|
+
/** Absolute path to the preload script. */
|
|
31
|
+
preload: string;
|
|
32
|
+
/** Whether running against a dev server. */
|
|
33
|
+
isDev: boolean;
|
|
34
|
+
/** Injectable `BrowserWindow` constructor; defaults to Electron's. */
|
|
35
|
+
BrowserWindowCtor?: BrowserWindowCtor;
|
|
36
|
+
/**
|
|
37
|
+
* Called if `whenReady` rejects or window creation throws. Defaults to
|
|
38
|
+
* logging the error, so a failed bootstrap never becomes an unhandled
|
|
39
|
+
* promise rejection.
|
|
40
|
+
*/
|
|
41
|
+
onError?: (error: unknown) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bootstrap the desktop shell: install the native router and open the window
|
|
46
|
+
* once the app is ready.
|
|
47
|
+
*
|
|
48
|
+
* @param opts Bootstrap options; `app`/`ipcMain`/`BrowserWindowCtor` are
|
|
49
|
+
* injectable for tests.
|
|
50
|
+
*/
|
|
51
|
+
export function startDesktop(opts: StartDesktopOptions): void {
|
|
52
|
+
const onError =
|
|
53
|
+
opts.onError ??
|
|
54
|
+
((error: unknown): void => {
|
|
55
|
+
console.error('[sublime/desktop] startDesktop failed:', error);
|
|
56
|
+
});
|
|
57
|
+
void opts.app
|
|
58
|
+
.whenReady()
|
|
59
|
+
.then(() => {
|
|
60
|
+
installNativeRouter(opts.ipcMain);
|
|
61
|
+
createWindow({
|
|
62
|
+
entry: opts.entry,
|
|
63
|
+
preload: opts.preload,
|
|
64
|
+
...(opts.BrowserWindowCtor !== undefined
|
|
65
|
+
? { BrowserWindowCtor: opts.BrowserWindowCtor }
|
|
66
|
+
: {}),
|
|
67
|
+
});
|
|
68
|
+
})
|
|
69
|
+
.catch(onError);
|
|
70
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core contract types for native services.
|
|
3
|
+
*
|
|
4
|
+
* A native service is a named bag of async methods authored in the main
|
|
5
|
+
* process. Methods are always async because every call crosses the
|
|
6
|
+
* `native:invoke` IPC boundary.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A map of async native methods keyed by method name. */
|
|
10
|
+
export type NativeMethods = Record<string, (...args: any[]) => Promise<any>>;
|
|
11
|
+
|
|
12
|
+
/** A named native service: its `name` plus its async `methods`. */
|
|
13
|
+
export interface NativeService<M extends NativeMethods = NativeMethods> {
|
|
14
|
+
name: string;
|
|
15
|
+
methods: M;
|
|
16
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer hook for consuming a native service from React.
|
|
3
|
+
*
|
|
4
|
+
* `useNative('fs')` reads the `window.sublimeNative` bridge exposed by the
|
|
5
|
+
* preload (see `exposeNativeBridge`). On plain web — where no preload ran and
|
|
6
|
+
* the bridge is absent — it returns `null`, letting components gracefully
|
|
7
|
+
* degrade. Inside the Electron shell it returns a typed {@link createProxy}
|
|
8
|
+
* whose `invoke` forwards over the one `native:invoke` channel and revives any
|
|
9
|
+
* `{ __nativeError }` envelope (produced by the main router) back into a
|
|
10
|
+
* {@link NativeError} so the caller sees a normal rejected promise.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createProxy } from './bridge/proxy';
|
|
14
|
+
import { deserializeError } from './errors';
|
|
15
|
+
import type { SerializedError } from './errors';
|
|
16
|
+
import type { NativeMethods } from './types';
|
|
17
|
+
|
|
18
|
+
/** Shape of the bridge exposed at `window.sublimeNative` by the preload. */
|
|
19
|
+
interface SublimeNativeWindow {
|
|
20
|
+
invoke(mod: string, method: string, args: unknown[]): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Envelope shape returned by the main router when a native call fails. */
|
|
24
|
+
interface NativeErrorEnvelope {
|
|
25
|
+
__nativeError: SerializedError;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isNativeErrorEnvelope(value: unknown): value is NativeErrorEnvelope {
|
|
29
|
+
return (
|
|
30
|
+
typeof value === 'object' &&
|
|
31
|
+
value !== null &&
|
|
32
|
+
'__nativeError' in value
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Access a native service by name from the renderer.
|
|
38
|
+
*
|
|
39
|
+
* @typeParam M the service's method map (from the `defineNative` author).
|
|
40
|
+
* @param name the registry key of the native service (e.g. `'fs'`).
|
|
41
|
+
* @returns a typed proxy, or `null` when running outside the Electron shell.
|
|
42
|
+
*/
|
|
43
|
+
export function useNative<M extends NativeMethods>(name: string): M | null {
|
|
44
|
+
const bridge = (
|
|
45
|
+
globalThis as { sublimeNative?: SublimeNativeWindow }
|
|
46
|
+
).sublimeNative;
|
|
47
|
+
if (bridge === undefined) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return createProxy<M>(name, async (mod, method, args) => {
|
|
51
|
+
const result = await bridge.invoke(mod, method, args);
|
|
52
|
+
if (isNativeErrorEnvelope(result)) {
|
|
53
|
+
throw deserializeError(result.__nativeError);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
});
|
|
57
|
+
}
|