@flighthq/shell 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.
@@ -0,0 +1,2 @@
1
+ export * from './shell';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './shell';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC"}
@@ -0,0 +1,16 @@
1
+ import type { ShellBackend, ShellOpenExternalOptions, ShellOpenPathOptions, ShellShortcutLink, ShellShortcutWriteOperation } from '@flighthq/types';
2
+ export declare function createWebShellBackend(): ShellBackend;
3
+ export declare function getShellBackend(): ShellBackend;
4
+ export declare function isShellUrlAllowed(url: string): boolean;
5
+ export declare function moveItemsToTrash(paths: readonly string[]): Promise<readonly boolean[]>;
6
+ export declare function moveItemToTrash(path: string): Promise<boolean>;
7
+ export declare function openShellExternalUrl(url: string, options?: Readonly<ShellOpenExternalOptions>): Promise<boolean>;
8
+ export declare function openShellPath(path: string, options?: Readonly<ShellOpenPathOptions>): Promise<boolean>;
9
+ export declare function openShellPathResult(path: string, options?: Readonly<ShellOpenPathOptions>): Promise<string>;
10
+ export declare function readShellShortcutLink(shortcutPath: string): Promise<ShellShortcutLink | null>;
11
+ export declare function setShellBackend(backend: ShellBackend | null): void;
12
+ export declare function setShellUrlSchemeAllowlist(schemes: readonly string[] | null): void;
13
+ export declare function shellBeep(): void;
14
+ export declare function showItemInFolder(path: string): Promise<boolean>;
15
+ export declare function writeShellShortcutLink(shortcutPath: string, link: Readonly<ShellShortcutLink>, operation?: ShellShortcutWriteOperation): Promise<boolean>;
16
+ //# sourceMappingURL=shell.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EACZ,wBAAwB,EACxB,oBAAoB,EACpB,iBAAiB,EACjB,2BAA2B,EAC5B,MAAM,iBAAiB,CAAC;AAKzB,wBAAgB,qBAAqB,IAAI,YAAY,CA4CpD;AAGD,wBAAgB,eAAe,IAAI,YAAY,CAG9C;AAID,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAQtD;AAID,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CAEtF;AAGD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE9D;AAQD,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,wBAAwB,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAGhH;AAGD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAEtG;AAKD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,oBAAoB,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAE3G;AAID,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAE7F;AAGD,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,IAAI,CAElE;AAMD,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,GAAG,IAAI,CAElF;AAGD,wBAAgB,SAAS,IAAI,IAAI,CAEhC;AAGD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE/D;AAMD,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,QAAQ,CAAC,iBAAiB,CAAC,EACjC,SAAS,CAAC,EAAE,2BAA2B,GACtC,OAAO,CAAC,OAAO,CAAC,CAElB"}
package/dist/shell.js ADDED
@@ -0,0 +1,133 @@
1
+ // Builds the default web backend. Only openExternal is achievable on the web (window.open); revealing
2
+ // items, opening arbitrary local paths, trashing, shortcut links, and batch operations require a native
3
+ // host, so they return false / null / error-string sentinels here.
4
+ export function createWebShellBackend() {
5
+ return {
6
+ beep() {
7
+ // No portable web equivalent of a system beep; no-op until a native host provides one.
8
+ },
9
+ async moveItemsToTrash() {
10
+ // The web cannot move local paths to the OS trash; native-host only.
11
+ return [];
12
+ },
13
+ async moveToTrash() {
14
+ // The web cannot move a local path to the OS trash; native-host only.
15
+ return false;
16
+ },
17
+ async openExternal(url) {
18
+ // window.open returns a Window handle on success, or null when blocked (popup blocker, no window).
19
+ // The activate option (macOS foreground raise) has no web equivalent and is silently ignored.
20
+ if (typeof window === 'undefined' || typeof window.open !== 'function')
21
+ return false;
22
+ try {
23
+ return window.open(url, '_blank', 'noopener') !== null;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ },
29
+ async openPath() {
30
+ // The web sandbox cannot open arbitrary local paths; only a native host can.
31
+ return false;
32
+ },
33
+ async openPathResult() {
34
+ // The web cannot open arbitrary local paths; native-host only.
35
+ return 'unavailable on web';
36
+ },
37
+ async readShortcutLink() {
38
+ // Windows .lnk shortcut links are not accessible on the web.
39
+ return null;
40
+ },
41
+ async showItemInFolder() {
42
+ // The web has no OS file manager to reveal an item in; native-host only.
43
+ return false;
44
+ },
45
+ async writeShortcutLink() {
46
+ // Windows .lnk shortcut links cannot be written on the web.
47
+ return false;
48
+ },
49
+ };
50
+ }
51
+ // The active shell backend, or a lazily-created web default. There is always a backend.
52
+ export function getShellBackend() {
53
+ if (_backend === null)
54
+ _backend = createWebShellBackend();
55
+ return _backend;
56
+ }
57
+ // True when url is allowed by the active URL-scheme allowlist. When no allowlist is set, all URLs
58
+ // are allowed. Used internally by openShellExternalUrl; also exported for callers that need the check.
59
+ export function isShellUrlAllowed(url) {
60
+ if (_urlSchemeAllowlist === null)
61
+ return true;
62
+ try {
63
+ const scheme = new URL(url).protocol.replace(/:$/, '');
64
+ return _urlSchemeAllowlist.includes(scheme);
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ // Moves a batch of local paths to the OS trash. Returns a per-path boolean array. Empty array on the
71
+ // web (native-host only). Single-path convenience: use moveItemToTrash.
72
+ export function moveItemsToTrash(paths) {
73
+ return getShellBackend().moveItemsToTrash(paths);
74
+ }
75
+ // Moves a local path to the OS trash. Returns false on the web; native-host capability.
76
+ export function moveItemToTrash(path) {
77
+ return getShellBackend().moveToTrash(path);
78
+ }
79
+ // Opens a URL in the user's default browser / external handler. Returns false when blocked, popup-
80
+ // blocked, or the URL scheme is not in the active allowlist (see setShellUrlSchemeAllowlist).
81
+ // SECURITY: handing an attacker-controlled URL to the OS default handler is the classic openExternal
82
+ // footgun — a non-http(s) scheme can launch a local application or protocol handler. When the url may
83
+ // be untrusted, constrain the accepted schemes with setShellUrlSchemeAllowlist (e.g. ['https',
84
+ // 'mailto']); isShellUrlAllowed exposes the same check for a caller that wants to gate the URL itself.
85
+ export function openShellExternalUrl(url, options) {
86
+ if (!isShellUrlAllowed(url))
87
+ return Promise.resolve(false);
88
+ return getShellBackend().openExternal(url, options);
89
+ }
90
+ // Opens a local path with its default OS application. Returns false on the web; native-host capability.
91
+ export function openShellPath(path, options) {
92
+ return getShellBackend().openPath(path, options);
93
+ }
94
+ // Opens a local path and returns the OS error message, or '' on success. On the web returns
95
+ // 'unavailable on web'. Use this when you need the reason a path could not be opened rather than
96
+ // just a boolean; openShellPath is the boolean convenience wrapper over this.
97
+ export function openShellPathResult(path, options) {
98
+ return getShellBackend().openPathResult(path, options);
99
+ }
100
+ // Reads a Windows .lnk shell shortcut. Returns null on non-Windows platforms, on the web, or when
101
+ // the shortcut does not exist.
102
+ export function readShellShortcutLink(shortcutPath) {
103
+ return getShellBackend().readShortcutLink(shortcutPath);
104
+ }
105
+ // Installs a native host shell backend; pass null to fall back to the web default.
106
+ export function setShellBackend(backend) {
107
+ _backend = backend;
108
+ }
109
+ // Sets the URL-scheme allowlist consulted by openShellExternalUrl. Pass null to allow all schemes
110
+ // (default behavior). When a non-null list is set, openShellExternalUrl returns false for any URL whose
111
+ // scheme is not in the list. Example: setShellUrlSchemeAllowlist(['https', 'mailto']).
112
+ // This closes the classic openExternal security footgun with attacker-controlled URLs.
113
+ export function setShellUrlSchemeAllowlist(schemes) {
114
+ _urlSchemeAllowlist = schemes;
115
+ }
116
+ // Emits a system beep. No-op on the web until a native host provides one.
117
+ export function shellBeep() {
118
+ getShellBackend().beep();
119
+ }
120
+ // Reveals a local path in the OS file manager. Returns false on the web; native-host capability.
121
+ export function showItemInFolder(path) {
122
+ return getShellBackend().showItemInFolder(path);
123
+ }
124
+ // Creates a Windows .lnk shell shortcut at shortcutPath pointing to link. Returns false on
125
+ // non-Windows platforms and on the web. operation defaults to 'create'.
126
+ // IMPORTANT: URL safety — only use openShellExternalUrl for user-facing link opening; do not pass
127
+ // attacker-controlled paths to writeShellShortcutLink without validation.
128
+ export function writeShellShortcutLink(shortcutPath, link, operation) {
129
+ return getShellBackend().writeShortcutLink(shortcutPath, link, operation);
130
+ }
131
+ let _backend = null;
132
+ let _urlSchemeAllowlist = null;
133
+ //# sourceMappingURL=shell.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../src/shell.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@flighthq/shell",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src/**/*.test.ts",
16
+ "!dist/**/*.test.js",
17
+ "!dist/**/*.test.d.ts",
18
+ "!dist/**/*.test.js.map",
19
+ "!dist/**/*.test.d.ts.map"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -b",
23
+ "clean": "tsc -b --clean",
24
+ "test": "vitest run --config vitest.config.ts",
25
+ "test:watch": "vitest --watch --config vitest.config.ts",
26
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
27
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
28
+ },
29
+ "dependencies": {
30
+ "@flighthq/types": "0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.3.0"
34
+ },
35
+ "description": "OS shell integration over a swappable web/native backend: open external URLs and paths, reveal items in the file manager, move to trash, beep",
36
+ "sideEffects": false
37
+ }
@@ -0,0 +1,313 @@
1
+ import type { ShellBackend, ShellOpenExternalOptions, ShellOpenPathOptions } from '@flighthq/types';
2
+
3
+ import {
4
+ createWebShellBackend,
5
+ getShellBackend,
6
+ isShellUrlAllowed,
7
+ moveItemsToTrash,
8
+ moveItemToTrash,
9
+ openShellExternalUrl,
10
+ openShellPath,
11
+ openShellPathResult,
12
+ readShellShortcutLink,
13
+ setShellBackend,
14
+ setShellUrlSchemeAllowlist,
15
+ shellBeep,
16
+ showItemInFolder,
17
+ writeShellShortcutLink,
18
+ } from './shell';
19
+
20
+ function fakeBackend(): ShellBackend & {
21
+ beeped: number;
22
+ lastOptions: ShellOpenExternalOptions | ShellOpenPathOptions | undefined;
23
+ opened: string;
24
+ pathResult: string;
25
+ trashed: string;
26
+ trashedBatch: readonly string[];
27
+ } {
28
+ return {
29
+ beeped: 0,
30
+ lastOptions: undefined,
31
+ opened: '',
32
+ pathResult: '',
33
+ trashed: '',
34
+ trashedBatch: [],
35
+ beep() {
36
+ this.beeped += 1;
37
+ },
38
+ async moveItemsToTrash(paths) {
39
+ this.trashedBatch = paths;
40
+ return paths.map(() => true);
41
+ },
42
+ async moveToTrash(path) {
43
+ this.trashed = path;
44
+ return true;
45
+ },
46
+ async openExternal(url, options) {
47
+ this.opened = url;
48
+ this.lastOptions = options;
49
+ return true;
50
+ },
51
+ async openPath(path, options) {
52
+ this.opened = path;
53
+ this.lastOptions = options;
54
+ return true;
55
+ },
56
+ async openPathResult(path, options) {
57
+ this.opened = path;
58
+ this.lastOptions = options;
59
+ return this.pathResult;
60
+ },
61
+ async readShortcutLink() {
62
+ return { target: '/path/to/target' };
63
+ },
64
+ async showItemInFolder(path) {
65
+ this.opened = path;
66
+ return true;
67
+ },
68
+ async writeShortcutLink() {
69
+ return true;
70
+ },
71
+ };
72
+ }
73
+
74
+ afterEach(() => {
75
+ setShellBackend(null);
76
+ setShellUrlSchemeAllowlist(null);
77
+ });
78
+
79
+ describe('createWebShellBackend', () => {
80
+ it('returns a backend whose native-only operations resolve to sentinels without throwing', async () => {
81
+ const backend = createWebShellBackend();
82
+ expect(await backend.openPath('/tmp/x')).toBe(false);
83
+ expect(await backend.showItemInFolder('/tmp/x')).toBe(false);
84
+ expect(await backend.moveToTrash('/tmp/x')).toBe(false);
85
+ expect(await backend.writeShortcutLink('/tmp/x.lnk', { target: '/tmp/x' })).toBe(false);
86
+ expect(await backend.readShortcutLink('/tmp/x.lnk')).toBeNull();
87
+ expect(() => backend.beep()).not.toThrow();
88
+ });
89
+
90
+ it('batch trash resolves to an empty array on the web', async () => {
91
+ const backend = createWebShellBackend();
92
+ expect(await backend.moveItemsToTrash(['/tmp/a', '/tmp/b'])).toEqual([]);
93
+ });
94
+
95
+ it('openPathResult returns the unavailable-on-web sentinel string', async () => {
96
+ const backend = createWebShellBackend();
97
+ expect(await backend.openPathResult('/tmp/x')).toBe('unavailable on web');
98
+ });
99
+
100
+ it('openExternal type-checks to boolean regardless of options', async () => {
101
+ const backend = createWebShellBackend();
102
+ expect(typeof (await backend.openExternal('https://example.com'))).toBe('boolean');
103
+ expect(typeof (await backend.openExternal('https://example.com', { activate: true }))).toBe('boolean');
104
+ });
105
+ });
106
+
107
+ describe('getShellBackend', () => {
108
+ it('falls back to a web backend', () => {
109
+ expect(getShellBackend()).not.toBeNull();
110
+ });
111
+
112
+ it('returns the registered backend', () => {
113
+ const backend = fakeBackend();
114
+ setShellBackend(backend);
115
+ expect(getShellBackend()).toBe(backend);
116
+ });
117
+ });
118
+
119
+ describe('isShellUrlAllowed', () => {
120
+ it('returns true when no allowlist is set', () => {
121
+ expect(isShellUrlAllowed('https://example.com')).toBe(true);
122
+ expect(isShellUrlAllowed('file:///tmp/x')).toBe(true);
123
+ });
124
+
125
+ it('returns true for a scheme in the allowlist', () => {
126
+ setShellUrlSchemeAllowlist(['https', 'mailto']);
127
+ expect(isShellUrlAllowed('https://example.com')).toBe(true);
128
+ expect(isShellUrlAllowed('mailto:user@example.com')).toBe(true);
129
+ });
130
+
131
+ it('returns false for a scheme not in the allowlist', () => {
132
+ setShellUrlSchemeAllowlist(['https']);
133
+ expect(isShellUrlAllowed('file:///tmp/x')).toBe(false);
134
+ expect(isShellUrlAllowed('ftp://example.com')).toBe(false);
135
+ });
136
+
137
+ it('returns false for a URL that cannot be parsed', () => {
138
+ setShellUrlSchemeAllowlist(['https']);
139
+ expect(isShellUrlAllowed('not-a-url')).toBe(false);
140
+ });
141
+ });
142
+
143
+ describe('moveItemsToTrash', () => {
144
+ it('passes the path array to the backend and returns per-path results', async () => {
145
+ const backend = fakeBackend();
146
+ setShellBackend(backend);
147
+ const results = await moveItemsToTrash(['/tmp/a', '/tmp/b']);
148
+ expect(results).toEqual([true, true]);
149
+ expect(backend.trashedBatch).toEqual(['/tmp/a', '/tmp/b']);
150
+ });
151
+ });
152
+
153
+ describe('moveItemToTrash', () => {
154
+ it('trashes via the active backend', async () => {
155
+ const backend = fakeBackend();
156
+ setShellBackend(backend);
157
+ expect(await moveItemToTrash('/tmp/x')).toBe(true);
158
+ expect(backend.trashed).toBe('/tmp/x');
159
+ });
160
+ });
161
+
162
+ describe('openShellExternalUrl', () => {
163
+ it('opens via the active backend', async () => {
164
+ const backend = fakeBackend();
165
+ setShellBackend(backend);
166
+ expect(await openShellExternalUrl('https://example.com')).toBe(true);
167
+ expect(backend.opened).toBe('https://example.com');
168
+ });
169
+
170
+ it('forwards the activate option to the backend', async () => {
171
+ const backend = fakeBackend();
172
+ setShellBackend(backend);
173
+ await openShellExternalUrl('https://example.com', { activate: true });
174
+ expect(backend.lastOptions).toEqual({ activate: true });
175
+ });
176
+
177
+ it('returns false and does not call the backend when the URL scheme is blocked', async () => {
178
+ const backend = fakeBackend();
179
+ setShellBackend(backend);
180
+ setShellUrlSchemeAllowlist(['https']);
181
+ expect(await openShellExternalUrl('file:///etc/passwd')).toBe(false);
182
+ expect(backend.opened).toBe('');
183
+ });
184
+
185
+ it('allows the URL when its scheme is in the allowlist', async () => {
186
+ const backend = fakeBackend();
187
+ setShellBackend(backend);
188
+ setShellUrlSchemeAllowlist(['https', 'mailto']);
189
+ expect(await openShellExternalUrl('https://example.com')).toBe(true);
190
+ expect(backend.opened).toBe('https://example.com');
191
+ });
192
+ });
193
+
194
+ describe('openShellPath', () => {
195
+ it('opens via the active backend', async () => {
196
+ const backend = fakeBackend();
197
+ setShellBackend(backend);
198
+ expect(await openShellPath('/tmp/x')).toBe(true);
199
+ expect(backend.opened).toBe('/tmp/x');
200
+ });
201
+
202
+ it('forwards the workingDirectory option to the backend', async () => {
203
+ const backend = fakeBackend();
204
+ setShellBackend(backend);
205
+ await openShellPath('/tmp/x', { workingDirectory: '/home/user' });
206
+ expect(backend.lastOptions).toEqual({ workingDirectory: '/home/user' });
207
+ });
208
+
209
+ it('forwards the application option to the backend', async () => {
210
+ const backend = fakeBackend();
211
+ setShellBackend(backend);
212
+ await openShellPath('/tmp/x', { application: 'TextEdit' });
213
+ expect(backend.lastOptions).toEqual({ application: 'TextEdit' });
214
+ });
215
+
216
+ it('omits options when none provided', async () => {
217
+ const backend = fakeBackend();
218
+ setShellBackend(backend);
219
+ await openShellPath('/tmp/x');
220
+ expect(backend.lastOptions).toBeUndefined();
221
+ });
222
+ });
223
+
224
+ describe('openShellPathResult', () => {
225
+ it('returns an empty string on success', async () => {
226
+ const backend = fakeBackend();
227
+ backend.pathResult = '';
228
+ setShellBackend(backend);
229
+ expect(await openShellPathResult('/tmp/x')).toBe('');
230
+ expect(backend.opened).toBe('/tmp/x');
231
+ });
232
+
233
+ it('returns the OS error string on failure', async () => {
234
+ const backend = fakeBackend();
235
+ backend.pathResult = 'No such file or directory';
236
+ setShellBackend(backend);
237
+ expect(await openShellPathResult('/nonexistent')).toBe('No such file or directory');
238
+ });
239
+
240
+ it('forwards options to the backend', async () => {
241
+ const backend = fakeBackend();
242
+ setShellBackend(backend);
243
+ await openShellPathResult('/tmp/x', { workingDirectory: '/home/user' });
244
+ expect(backend.lastOptions).toEqual({ workingDirectory: '/home/user' });
245
+ });
246
+ });
247
+
248
+ describe('readShellShortcutLink', () => {
249
+ it('returns null on the web (no backend set)', async () => {
250
+ const result = await readShellShortcutLink('/tmp/x.lnk');
251
+ expect(result).toBeNull();
252
+ });
253
+
254
+ it('returns the shortcut link from the active backend', async () => {
255
+ const backend = fakeBackend();
256
+ setShellBackend(backend);
257
+ const result = await readShellShortcutLink('/tmp/x.lnk');
258
+ expect(result).toEqual({ target: '/path/to/target' });
259
+ });
260
+ });
261
+
262
+ describe('setShellBackend', () => {
263
+ it('clears back to the web fallback when passed null', () => {
264
+ setShellBackend(fakeBackend());
265
+ setShellBackend(null);
266
+ expect(getShellBackend()).not.toBeNull();
267
+ });
268
+ });
269
+
270
+ describe('setShellUrlSchemeAllowlist', () => {
271
+ it('allows all URLs when set to null', () => {
272
+ setShellUrlSchemeAllowlist(['https']);
273
+ setShellUrlSchemeAllowlist(null);
274
+ expect(isShellUrlAllowed('file:///tmp/x')).toBe(true);
275
+ });
276
+
277
+ it('restricts URLs to the listed schemes', () => {
278
+ setShellUrlSchemeAllowlist(['https', 'mailto']);
279
+ expect(isShellUrlAllowed('ftp://example.com')).toBe(false);
280
+ });
281
+ });
282
+
283
+ describe('shellBeep', () => {
284
+ it('forwards to the active backend', () => {
285
+ const backend = fakeBackend();
286
+ setShellBackend(backend);
287
+ shellBeep();
288
+ expect(backend.beeped).toBe(1);
289
+ });
290
+ });
291
+
292
+ describe('showItemInFolder', () => {
293
+ it('reveals via the active backend', async () => {
294
+ const backend = fakeBackend();
295
+ setShellBackend(backend);
296
+ expect(await showItemInFolder('/tmp/x')).toBe(true);
297
+ expect(backend.opened).toBe('/tmp/x');
298
+ });
299
+ });
300
+
301
+ describe('writeShellShortcutLink', () => {
302
+ it('returns false on the web (no backend set)', async () => {
303
+ const result = await writeShellShortcutLink('/tmp/x.lnk', { target: '/tmp/target' });
304
+ expect(result).toBe(false);
305
+ });
306
+
307
+ it('writes via the active backend', async () => {
308
+ const backend = fakeBackend();
309
+ setShellBackend(backend);
310
+ const result = await writeShellShortcutLink('/tmp/x.lnk', { target: '/tmp/target' }, 'create');
311
+ expect(result).toBe(true);
312
+ });
313
+ });