@flighthq/host-electron 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/dist/electronApp.d.ts +4 -0
- package/dist/electronApp.d.ts.map +1 -0
- package/dist/electronApp.js +184 -0
- package/dist/electronApp.js.map +1 -0
- package/dist/electronClipboard.d.ts +4 -0
- package/dist/electronClipboard.d.ts.map +1 -0
- package/dist/electronClipboard.js +211 -0
- package/dist/electronClipboard.js.map +1 -0
- package/dist/electronDialog.d.ts +4 -0
- package/dist/electronDialog.d.ts.map +1 -0
- package/dist/electronDialog.js +85 -0
- package/dist/electronDialog.js.map +1 -0
- package/dist/electronIpc.d.ts +4 -0
- package/dist/electronIpc.d.ts.map +1 -0
- package/dist/electronIpc.js +21 -0
- package/dist/electronIpc.js.map +1 -0
- package/dist/electronMenu.d.ts +4 -0
- package/dist/electronMenu.d.ts.map +1 -0
- package/dist/electronMenu.js +59 -0
- package/dist/electronMenu.js.map +1 -0
- package/dist/electronModule.d.ts +368 -0
- package/dist/electronModule.d.ts.map +1 -0
- package/dist/electronModule.js +12 -0
- package/dist/electronModule.js.map +1 -0
- package/dist/electronNotification.d.ts +4 -0
- package/dist/electronNotification.d.ts.map +1 -0
- package/dist/electronNotification.js +134 -0
- package/dist/electronNotification.js.map +1 -0
- package/dist/electronPlatform.d.ts +4 -0
- package/dist/electronPlatform.d.ts.map +1 -0
- package/dist/electronPlatform.js +30 -0
- package/dist/electronPlatform.js.map +1 -0
- package/dist/electronPower.d.ts +4 -0
- package/dist/electronPower.d.ts.map +1 -0
- package/dist/electronPower.js +92 -0
- package/dist/electronPower.js.map +1 -0
- package/dist/electronProtocol.d.ts +4 -0
- package/dist/electronProtocol.d.ts.map +1 -0
- package/dist/electronProtocol.js +55 -0
- package/dist/electronProtocol.js.map +1 -0
- package/dist/electronRegister.d.ts +6 -0
- package/dist/electronRegister.d.ts.map +1 -0
- package/dist/electronRegister.js +62 -0
- package/dist/electronRegister.js.map +1 -0
- package/dist/electronScreen.d.ts +4 -0
- package/dist/electronScreen.d.ts.map +1 -0
- package/dist/electronScreen.js +68 -0
- package/dist/electronScreen.js.map +1 -0
- package/dist/electronShell.d.ts +4 -0
- package/dist/electronShell.d.ts.map +1 -0
- package/dist/electronShell.js +105 -0
- package/dist/electronShell.js.map +1 -0
- package/dist/electronShortcut.d.ts +4 -0
- package/dist/electronShortcut.d.ts.map +1 -0
- package/dist/electronShortcut.js +40 -0
- package/dist/electronShortcut.js.map +1 -0
- package/dist/electronStorage.d.ts +4 -0
- package/dist/electronStorage.d.ts.map +1 -0
- package/dist/electronStorage.js +75 -0
- package/dist/electronStorage.js.map +1 -0
- package/dist/electronTray.d.ts +4 -0
- package/dist/electronTray.d.ts.map +1 -0
- package/dist/electronTray.js +164 -0
- package/dist/electronTray.js.map +1 -0
- package/dist/electronUpdater.d.ts +4 -0
- package/dist/electronUpdater.d.ts.map +1 -0
- package/dist/electronUpdater.js +113 -0
- package/dist/electronUpdater.js.map +1 -0
- package/dist/electronWindow.d.ts +7 -0
- package/dist/electronWindow.d.ts.map +1 -0
- package/dist/electronWindow.js +417 -0
- package/dist/electronWindow.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/electronApp.test.ts +141 -0
- package/src/electronClipboard.test.ts +86 -0
- package/src/electronDialog.test.ts +95 -0
- package/src/electronIpc.test.ts +47 -0
- package/src/electronMenu.test.ts +85 -0
- package/src/electronNotification.test.ts +97 -0
- package/src/electronPlatform.test.ts +28 -0
- package/src/electronPower.test.ts +110 -0
- package/src/electronProtocol.test.ts +63 -0
- package/src/electronRegister.test.ts +57 -0
- package/src/electronScreen.test.ts +92 -0
- package/src/electronShell.test.ts +67 -0
- package/src/electronShortcut.test.ts +56 -0
- package/src/electronStorage.test.ts +70 -0
- package/src/electronTray.test.ts +107 -0
- package/src/electronUpdater.test.ts +117 -0
- package/src/electronWindow.test.ts +217 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createElectronClipboardBackend } from './electronClipboard';
|
|
2
|
+
import type { ElectronApi, ElectronNativeImage } from './electronModule';
|
|
3
|
+
|
|
4
|
+
function fakeElectron(): ElectronApi {
|
|
5
|
+
const store = { text: '', html: '', rtf: '', imageDataUrl: '', bookmarkTitle: '', bookmarkUrl: '' };
|
|
6
|
+
const image = (dataUrl: string): ElectronNativeImage => ({
|
|
7
|
+
toDataUrl: () => dataUrl,
|
|
8
|
+
isEmpty: () => dataUrl === '',
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
clipboard: {
|
|
12
|
+
readText: () => store.text,
|
|
13
|
+
writeText: (t: string) => {
|
|
14
|
+
store.text = t;
|
|
15
|
+
},
|
|
16
|
+
readHtml: () => store.html,
|
|
17
|
+
writeHtml: (h: string) => {
|
|
18
|
+
store.html = h;
|
|
19
|
+
},
|
|
20
|
+
readRTF: () => store.rtf,
|
|
21
|
+
writeRTF: (r: string) => {
|
|
22
|
+
store.rtf = r;
|
|
23
|
+
},
|
|
24
|
+
readBookmark: () => ({ title: store.bookmarkTitle, url: store.bookmarkUrl }),
|
|
25
|
+
writeBookmark: (title: string, url: string) => {
|
|
26
|
+
store.bookmarkTitle = title;
|
|
27
|
+
store.bookmarkUrl = url;
|
|
28
|
+
},
|
|
29
|
+
readImage: () => image(store.imageDataUrl),
|
|
30
|
+
writeImage: (img: ElectronNativeImage) => {
|
|
31
|
+
store.imageDataUrl = img.toDataUrl();
|
|
32
|
+
},
|
|
33
|
+
clear: () => {
|
|
34
|
+
store.text = '';
|
|
35
|
+
store.html = '';
|
|
36
|
+
store.rtf = '';
|
|
37
|
+
store.imageDataUrl = '';
|
|
38
|
+
store.bookmarkTitle = '';
|
|
39
|
+
store.bookmarkUrl = '';
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
nativeImage: {
|
|
43
|
+
createFromDataUrl: (dataUrl: string) => image(dataUrl),
|
|
44
|
+
createFromPath: () => image(''),
|
|
45
|
+
},
|
|
46
|
+
} as unknown as ElectronApi;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('createElectronClipboardBackend', () => {
|
|
50
|
+
it('round-trips text through the Electron clipboard', async () => {
|
|
51
|
+
const backend = createElectronClipboardBackend(fakeElectron());
|
|
52
|
+
expect(await backend.writeText('hi')).toBe(true);
|
|
53
|
+
expect(await backend.readText()).toBe('hi');
|
|
54
|
+
expect(await backend.hasText()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('round-trips HTML and RTF', async () => {
|
|
58
|
+
const backend = createElectronClipboardBackend(fakeElectron());
|
|
59
|
+
await backend.writeHtml('<b>x</b>');
|
|
60
|
+
await backend.writeRTF('{\\rtf1 x}');
|
|
61
|
+
expect(await backend.readHtml()).toBe('<b>x</b>');
|
|
62
|
+
expect(await backend.readRTF()).toBe('{\\rtf1 x}');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('round-trips an image as a data URL', async () => {
|
|
66
|
+
const backend = createElectronClipboardBackend(fakeElectron());
|
|
67
|
+
expect(await backend.hasImage()).toBe(false);
|
|
68
|
+
await backend.writeImage('data:image/png;base64,AAAA');
|
|
69
|
+
expect(await backend.readImage()).toBe('data:image/png;base64,AAAA');
|
|
70
|
+
expect(await backend.hasImage()).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns null for an empty bookmark and round-trips a set one', async () => {
|
|
74
|
+
const backend = createElectronClipboardBackend(fakeElectron());
|
|
75
|
+
expect(await backend.readBookmark()).toBeNull();
|
|
76
|
+
await backend.writeBookmark('Flight', 'https://example.test');
|
|
77
|
+
expect(await backend.readBookmark()).toEqual({ title: 'Flight', url: 'https://example.test' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('clears all formats', async () => {
|
|
81
|
+
const backend = createElectronClipboardBackend(fakeElectron());
|
|
82
|
+
await backend.writeText('x');
|
|
83
|
+
expect(await backend.clear()).toBe(true);
|
|
84
|
+
expect(await backend.readText()).toBe('');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createElectronDialogBackend } from './electronDialog';
|
|
2
|
+
import type {
|
|
3
|
+
ElectronApi,
|
|
4
|
+
ElectronMessageBoxOptions,
|
|
5
|
+
ElectronOpenDialogOptions,
|
|
6
|
+
ElectronSaveDialogOptions,
|
|
7
|
+
} from './electronModule';
|
|
8
|
+
|
|
9
|
+
function fakeElectron(overrides: {
|
|
10
|
+
open?: { canceled: boolean; filePaths: string[] };
|
|
11
|
+
save?: { canceled: boolean; filePath?: string };
|
|
12
|
+
message?: { response: number; checkboxChecked: boolean };
|
|
13
|
+
}): {
|
|
14
|
+
electron: ElectronApi;
|
|
15
|
+
calls: {
|
|
16
|
+
openOptions?: ElectronOpenDialogOptions;
|
|
17
|
+
saveOptions?: ElectronSaveDialogOptions;
|
|
18
|
+
messageOptions?: ElectronMessageBoxOptions;
|
|
19
|
+
};
|
|
20
|
+
} {
|
|
21
|
+
const calls: {
|
|
22
|
+
openOptions?: ElectronOpenDialogOptions;
|
|
23
|
+
saveOptions?: ElectronSaveDialogOptions;
|
|
24
|
+
messageOptions?: ElectronMessageBoxOptions;
|
|
25
|
+
} = {};
|
|
26
|
+
const electron = {
|
|
27
|
+
dialog: {
|
|
28
|
+
showOpenDialog: async (_window: unknown, options: ElectronOpenDialogOptions) => {
|
|
29
|
+
calls.openOptions = options;
|
|
30
|
+
return overrides.open ?? { canceled: false, filePaths: [] };
|
|
31
|
+
},
|
|
32
|
+
showSaveDialog: async (_window: unknown, options: ElectronSaveDialogOptions) => {
|
|
33
|
+
calls.saveOptions = options;
|
|
34
|
+
return overrides.save ?? { canceled: false, filePath: undefined };
|
|
35
|
+
},
|
|
36
|
+
showMessageBox: async (_window: unknown, options: ElectronMessageBoxOptions) => {
|
|
37
|
+
calls.messageOptions = options;
|
|
38
|
+
return overrides.message ?? { response: 0, checkboxChecked: false };
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
} as unknown as ElectronApi;
|
|
42
|
+
return { electron, calls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('createElectronDialogBackend', () => {
|
|
46
|
+
it('builds open-dialog properties from options and returns file handles', async () => {
|
|
47
|
+
const { electron, calls } = fakeElectron({ open: { canceled: false, filePaths: ['/a', '/b'] } });
|
|
48
|
+
const backend = createElectronDialogBackend(electron);
|
|
49
|
+
const handles = await backend.openFile({ multiple: true, directory: true });
|
|
50
|
+
expect(handles).toEqual([
|
|
51
|
+
{ kind: 'Directory', name: 'a', path: '/a' },
|
|
52
|
+
{ kind: 'Directory', name: 'b', path: '/b' },
|
|
53
|
+
]);
|
|
54
|
+
expect(calls.openOptions?.properties).toEqual(['openFile', 'multiSelections', 'openDirectory']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns an empty array when the open dialog is canceled', async () => {
|
|
58
|
+
const { electron } = fakeElectron({ open: { canceled: true, filePaths: ['/a'] } });
|
|
59
|
+
const backend = createElectronDialogBackend(electron);
|
|
60
|
+
expect(await backend.openFile({})).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns the save handle or null when canceled or empty', async () => {
|
|
64
|
+
const ok = fakeElectron({ save: { canceled: false, filePath: '/out.txt' } });
|
|
65
|
+
expect(await createElectronDialogBackend(ok.electron).saveFile({})).toEqual({
|
|
66
|
+
kind: 'File',
|
|
67
|
+
name: 'out.txt',
|
|
68
|
+
path: '/out.txt',
|
|
69
|
+
});
|
|
70
|
+
const canceled = fakeElectron({ save: { canceled: true } });
|
|
71
|
+
expect(await createElectronDialogBackend(canceled.electron).saveFile({})).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('maps message results to button index, cancelled, and checkbox state', async () => {
|
|
75
|
+
const { electron, calls } = fakeElectron({ message: { response: 2, checkboxChecked: true } });
|
|
76
|
+
const backend = createElectronDialogBackend(electron);
|
|
77
|
+
const result = await backend.message({ message: 'hi', kind: 'warning' });
|
|
78
|
+
expect(result).toEqual({ buttonIndex: 2, cancelled: false, checkboxChecked: true });
|
|
79
|
+
expect(calls.messageOptions?.type).toBe('warning');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('confirm returns true only when the OK button is chosen', async () => {
|
|
83
|
+
const yes = fakeElectron({ message: { response: 0, checkboxChecked: false } });
|
|
84
|
+
expect(await createElectronDialogBackend(yes.electron).confirm({ message: 'ok?' })).toBe(true);
|
|
85
|
+
const no = fakeElectron({ message: { response: 1, checkboxChecked: false } });
|
|
86
|
+
expect(await createElectronDialogBackend(no.electron).confirm({ message: 'ok?' })).toBe(false);
|
|
87
|
+
expect(no.calls.messageOptions?.buttons).toEqual(['OK', 'Cancel']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('prompt resolves to null since Electron has no native text dialog', async () => {
|
|
91
|
+
const { electron } = fakeElectron({});
|
|
92
|
+
const backend = createElectronDialogBackend(electron);
|
|
93
|
+
expect(await backend.prompt({ message: 'name?', defaultValue: 'default' })).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createElectronIpcBackend } from './electronIpc';
|
|
2
|
+
import type { ElectronApi } from './electronModule';
|
|
3
|
+
|
|
4
|
+
function fakeElectron(): {
|
|
5
|
+
electron: ElectronApi;
|
|
6
|
+
channels: Map<string, Set<(event: unknown, ...args: unknown[]) => void>>;
|
|
7
|
+
} {
|
|
8
|
+
const channels = new Map<string, Set<(event: unknown, ...args: unknown[]) => void>>();
|
|
9
|
+
const electron = {
|
|
10
|
+
ipcMain: {
|
|
11
|
+
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => {
|
|
12
|
+
if (!channels.has(channel)) channels.set(channel, new Set());
|
|
13
|
+
channels.get(channel)?.add(listener);
|
|
14
|
+
},
|
|
15
|
+
removeListener: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => {
|
|
16
|
+
channels.get(channel)?.delete(listener);
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
} as unknown as ElectronApi;
|
|
20
|
+
return { electron, channels };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('createElectronIpcBackend', () => {
|
|
24
|
+
it('send no-ops without a webContents target', () => {
|
|
25
|
+
const { electron } = fakeElectron();
|
|
26
|
+
const backend = createElectronIpcBackend(electron);
|
|
27
|
+
expect(() => backend.send('channel', [1, 2])).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('invoke resolves to undefined on the main side', async () => {
|
|
31
|
+
const { electron } = fakeElectron();
|
|
32
|
+
const backend = createElectronIpcBackend(electron);
|
|
33
|
+
expect(await backend.invoke('channel', [])).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('subscribe receives renderer args and unsubscribes the same handler', () => {
|
|
37
|
+
const { electron, channels } = fakeElectron();
|
|
38
|
+
const backend = createElectronIpcBackend(electron);
|
|
39
|
+
let received: readonly unknown[] | undefined;
|
|
40
|
+
const unsubscribe = backend.subscribe('chat', (args) => (received = args));
|
|
41
|
+
expect(channels.get('chat')?.size).toBe(1);
|
|
42
|
+
for (const listener of channels.get('chat') ?? []) listener({ sender: 1 }, 'hello', 42);
|
|
43
|
+
expect(received).toEqual(['hello', 42]);
|
|
44
|
+
unsubscribe();
|
|
45
|
+
expect(channels.get('chat')?.size).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createElectronMenuBackend } from './electronMenu';
|
|
2
|
+
import type { ElectronApi, ElectronMenu, ElectronMenuItemOptions } from './electronModule';
|
|
3
|
+
|
|
4
|
+
function fakeElectron(): {
|
|
5
|
+
electron: ElectronApi;
|
|
6
|
+
built: ElectronMenuItemOptions[][];
|
|
7
|
+
applied: (ElectronMenu | null)[];
|
|
8
|
+
popups: { x?: number; y?: number }[];
|
|
9
|
+
} {
|
|
10
|
+
const built: ElectronMenuItemOptions[][] = [];
|
|
11
|
+
const applied: (ElectronMenu | null)[] = [];
|
|
12
|
+
const popups: { x?: number; y?: number }[] = [];
|
|
13
|
+
const electron = {
|
|
14
|
+
Menu: {
|
|
15
|
+
buildFromTemplate: (template: ElectronMenuItemOptions[]) => {
|
|
16
|
+
built.push(template);
|
|
17
|
+
return {
|
|
18
|
+
template,
|
|
19
|
+
popup: (options?: { x?: number; y?: number }) => {
|
|
20
|
+
popups.push(options ?? {});
|
|
21
|
+
},
|
|
22
|
+
} as unknown as ElectronMenu;
|
|
23
|
+
},
|
|
24
|
+
setApplicationMenu: (menu: ElectronMenu | null) => {
|
|
25
|
+
applied.push(menu);
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
} as unknown as ElectronApi;
|
|
29
|
+
return { electron, built, applied, popups };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Clicks the menu item with the given id from the most recently built template.
|
|
33
|
+
function clickItem(built: ElectronMenuItemOptions[][], id: string): void {
|
|
34
|
+
const findIn = (items: ElectronMenuItemOptions[]): ElectronMenuItemOptions | undefined => {
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
if (item.id === id) return item;
|
|
37
|
+
if (item.submenu) {
|
|
38
|
+
const found = findIn(item.submenu);
|
|
39
|
+
if (found) return found;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
};
|
|
44
|
+
const item = findIn(built[built.length - 1]);
|
|
45
|
+
item?.click?.();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('createElectronMenuBackend', () => {
|
|
49
|
+
it('builds and applies the application menu and reports clicks via subscribeSelect', () => {
|
|
50
|
+
const { electron, built, applied } = fakeElectron();
|
|
51
|
+
const backend = createElectronMenuBackend(electron);
|
|
52
|
+
const seen: string[] = [];
|
|
53
|
+
backend.subscribeSelect((id) => seen.push(id));
|
|
54
|
+
expect(
|
|
55
|
+
backend.setApplicationMenu([
|
|
56
|
+
{ id: 'open', label: 'Open' },
|
|
57
|
+
{ label: 'Edit', submenu: [{ id: 'copy', label: 'Copy' }] },
|
|
58
|
+
]),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
expect(applied.length).toBe(1);
|
|
61
|
+
clickItem(built, 'open');
|
|
62
|
+
clickItem(built, 'copy');
|
|
63
|
+
expect(seen).toEqual(['open', 'copy']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('stops delivering selects after unsubscribe', () => {
|
|
67
|
+
const { electron, built } = fakeElectron();
|
|
68
|
+
const backend = createElectronMenuBackend(electron);
|
|
69
|
+
const seen: string[] = [];
|
|
70
|
+
const unsubscribe = backend.subscribeSelect((id) => seen.push(id));
|
|
71
|
+
backend.setApplicationMenu([{ id: 'a', label: 'A' }]);
|
|
72
|
+
unsubscribe();
|
|
73
|
+
clickItem(built, 'a');
|
|
74
|
+
expect(seen).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('resolves the context menu promise with the clicked id', async () => {
|
|
78
|
+
const { electron, built, popups } = fakeElectron();
|
|
79
|
+
const backend = createElectronMenuBackend(electron);
|
|
80
|
+
const pending = backend.popupContextMenu([{ id: 'paste', label: 'Paste' }], 10, 20);
|
|
81
|
+
expect(popups).toEqual([{ x: 10, y: 20 }]);
|
|
82
|
+
clickItem(built, 'paste');
|
|
83
|
+
expect(await pending).toBe('paste');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ElectronApi, ElectronNotificationOptions } from './electronModule';
|
|
2
|
+
import { createElectronNotificationBackend } from './electronNotification';
|
|
3
|
+
|
|
4
|
+
interface FakeNotification {
|
|
5
|
+
options: ElectronNotificationOptions;
|
|
6
|
+
shown: boolean;
|
|
7
|
+
handlers: Record<string, (...args: unknown[]) => void>;
|
|
8
|
+
show(): void;
|
|
9
|
+
close(): void;
|
|
10
|
+
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fakeElectron(supported = true): {
|
|
14
|
+
electron: ElectronApi;
|
|
15
|
+
notifications: FakeNotification[];
|
|
16
|
+
} {
|
|
17
|
+
const notifications: FakeNotification[] = [];
|
|
18
|
+
const electron = {
|
|
19
|
+
Notification: Object.assign(
|
|
20
|
+
function (this: FakeNotification, options: ElectronNotificationOptions) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.shown = false;
|
|
23
|
+
this.handlers = {};
|
|
24
|
+
this.show = () => {
|
|
25
|
+
this.shown = true;
|
|
26
|
+
};
|
|
27
|
+
this.close = () => {};
|
|
28
|
+
this.on = (event: string, listener: (...args: unknown[]) => void) => {
|
|
29
|
+
this.handlers[event] = listener;
|
|
30
|
+
};
|
|
31
|
+
notifications.push(this);
|
|
32
|
+
},
|
|
33
|
+
{ isSupported: () => supported },
|
|
34
|
+
),
|
|
35
|
+
} as unknown as ElectronApi;
|
|
36
|
+
return { electron, notifications };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createElectronNotificationBackend', () => {
|
|
40
|
+
it('shows a notification and resolves to its id', async () => {
|
|
41
|
+
const { electron, notifications } = fakeElectron();
|
|
42
|
+
const backend = createElectronNotificationBackend(electron);
|
|
43
|
+
expect(backend.isSupported()).toBe(true);
|
|
44
|
+
expect(backend.getPermission()).toBe('granted');
|
|
45
|
+
expect(await backend.requestPermission()).toBe('granted');
|
|
46
|
+
const id = await backend.notify({ id: 'n1', title: 'Hi', body: 'there', actions: [{ id: 'ok', title: 'OK' }] });
|
|
47
|
+
expect(id).toBe('n1');
|
|
48
|
+
expect(notifications[0].options.title).toBe('Hi');
|
|
49
|
+
expect(notifications[0].options.actions).toEqual([{ type: 'button', text: 'OK' }]);
|
|
50
|
+
expect(notifications[0].shown).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns an empty id when notifications are unsupported', async () => {
|
|
54
|
+
const { electron } = fakeElectron(false);
|
|
55
|
+
const backend = createElectronNotificationBackend(electron);
|
|
56
|
+
expect(backend.isSupported()).toBe(false);
|
|
57
|
+
expect(backend.getPermission()).toBe('denied');
|
|
58
|
+
expect(await backend.requestPermission()).toBe('denied');
|
|
59
|
+
expect(await backend.notify({ title: 'x' })).toBe('');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('forwards click events with the notification id and stops after unsubscribe', async () => {
|
|
63
|
+
const { electron, notifications } = fakeElectron();
|
|
64
|
+
const backend = createElectronNotificationBackend(electron);
|
|
65
|
+
const ids: string[] = [];
|
|
66
|
+
const unsubscribe = backend.subscribeClick((id) => ids.push(id));
|
|
67
|
+
await backend.notify({ id: 'msg-1', title: 'x' });
|
|
68
|
+
notifications[0].handlers['click']();
|
|
69
|
+
unsubscribe();
|
|
70
|
+
notifications[0].handlers['click']();
|
|
71
|
+
expect(ids).toEqual(['msg-1']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('forwards action events mapping the electron index back to the action id', async () => {
|
|
75
|
+
const { electron, notifications } = fakeElectron();
|
|
76
|
+
const backend = createElectronNotificationBackend(electron);
|
|
77
|
+
const seen: [string, string][] = [];
|
|
78
|
+
backend.subscribeAction((id, actionId) => seen.push([id, actionId]));
|
|
79
|
+
await backend.notify({
|
|
80
|
+
id: 'msg-2',
|
|
81
|
+
title: 'x',
|
|
82
|
+
actions: [
|
|
83
|
+
{ id: 'yes', title: 'Yes' },
|
|
84
|
+
{ id: 'no', title: 'No' },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
notifications[0].handlers['action']({}, 1);
|
|
88
|
+
expect(seen).toEqual([['msg-2', 'no']]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('updateNotification returns false (no in-place update support)', async () => {
|
|
92
|
+
const { electron } = fakeElectron();
|
|
93
|
+
const backend = createElectronNotificationBackend(electron);
|
|
94
|
+
await backend.notify({ title: 'Hi', tag: 'u1' });
|
|
95
|
+
expect(await backend.updateNotification('u1', { title: 'Updated' })).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PlatformInfo } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import type { ElectronApi } from './electronModule';
|
|
4
|
+
import { createElectronPlatformBackend } from './electronPlatform';
|
|
5
|
+
|
|
6
|
+
function fakeElectron(): ElectronApi {
|
|
7
|
+
return {
|
|
8
|
+
app: {
|
|
9
|
+
getLocale: () => 'en-US',
|
|
10
|
+
},
|
|
11
|
+
} as unknown as ElectronApi;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('createElectronPlatformBackend', () => {
|
|
15
|
+
it('fills platform info from process and electron locale', () => {
|
|
16
|
+
const backend = createElectronPlatformBackend(fakeElectron());
|
|
17
|
+
const out = {} as PlatformInfo;
|
|
18
|
+
const result = backend.getInfo(out);
|
|
19
|
+
expect(result).toBe(out);
|
|
20
|
+
expect(out.kind).toBe('desktop');
|
|
21
|
+
expect(out.isTouch).toBe(false);
|
|
22
|
+
expect(out.locale).toBe('en-US');
|
|
23
|
+
// Running under Node, so name maps to a desktop OS and arch is reported.
|
|
24
|
+
expect(['windows', 'macos', 'linux', 'unknown']).toContain(out.name);
|
|
25
|
+
expect(typeof out.arch).toBe('string');
|
|
26
|
+
expect(typeof out.version).toBe('string');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { PowerStatus } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import type { ElectronApi } from './electronModule';
|
|
4
|
+
import { createElectronPowerBackend } from './electronPower';
|
|
5
|
+
|
|
6
|
+
function emptyStatus(): PowerStatus {
|
|
7
|
+
return {
|
|
8
|
+
batteryLevel: 0,
|
|
9
|
+
chargingTime: 0,
|
|
10
|
+
dischargingTime: 0,
|
|
11
|
+
isBatteryLow: false,
|
|
12
|
+
isCharging: false,
|
|
13
|
+
isLowPower: false,
|
|
14
|
+
isOnBattery: false,
|
|
15
|
+
thermalState: 'Unknown',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fakeElectron(options: { onBatteryPower?: boolean; startId?: number }): {
|
|
20
|
+
electron: ElectronApi;
|
|
21
|
+
monitorListeners: Map<string, Set<() => void>>;
|
|
22
|
+
blocker: { started: number[]; stopped: number[] };
|
|
23
|
+
} {
|
|
24
|
+
const monitorListeners = new Map<string, Set<() => void>>();
|
|
25
|
+
const blocker = { started: [] as number[], stopped: [] as number[] };
|
|
26
|
+
let nextId = options.startId ?? 7;
|
|
27
|
+
const electron = {
|
|
28
|
+
powerMonitor: {
|
|
29
|
+
onBatteryPower: options.onBatteryPower,
|
|
30
|
+
on: (event: string, listener: () => void) => {
|
|
31
|
+
if (!monitorListeners.has(event)) monitorListeners.set(event, new Set());
|
|
32
|
+
monitorListeners.get(event)?.add(listener);
|
|
33
|
+
},
|
|
34
|
+
removeListener: (event: string, listener: () => void) => {
|
|
35
|
+
monitorListeners.get(event)?.delete(listener);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
powerSaveBlocker: {
|
|
39
|
+
start: () => {
|
|
40
|
+
const id = nextId++;
|
|
41
|
+
blocker.started.push(id);
|
|
42
|
+
return id;
|
|
43
|
+
},
|
|
44
|
+
stop: (id: number) => {
|
|
45
|
+
blocker.stopped.push(id);
|
|
46
|
+
},
|
|
47
|
+
isStarted: () => true,
|
|
48
|
+
},
|
|
49
|
+
} as unknown as ElectronApi;
|
|
50
|
+
return { electron, monitorListeners, blocker };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('createElectronPowerBackend', () => {
|
|
54
|
+
it('getStatus reports no battery level and infers charging from AC power', () => {
|
|
55
|
+
const onAc = createElectronPowerBackend(fakeElectron({ onBatteryPower: false }).electron);
|
|
56
|
+
const status = onAc.getStatus(emptyStatus());
|
|
57
|
+
expect(status).toEqual({
|
|
58
|
+
batteryLevel: -1,
|
|
59
|
+
chargingTime: -1,
|
|
60
|
+
dischargingTime: -1,
|
|
61
|
+
isBatteryLow: false,
|
|
62
|
+
isCharging: true,
|
|
63
|
+
isLowPower: false,
|
|
64
|
+
isOnBattery: false,
|
|
65
|
+
thermalState: 'Unknown',
|
|
66
|
+
});
|
|
67
|
+
const onBattery = createElectronPowerBackend(fakeElectron({ onBatteryPower: true }).electron);
|
|
68
|
+
const batteryStatus = onBattery.getStatus(emptyStatus());
|
|
69
|
+
expect(batteryStatus.isCharging).toBe(false);
|
|
70
|
+
expect(batteryStatus.isOnBattery).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('subscribe wires both AC and battery events and unsubscribes both', () => {
|
|
74
|
+
const { electron, monitorListeners } = fakeElectron({});
|
|
75
|
+
const backend = createElectronPowerBackend(electron);
|
|
76
|
+
const unsubscribe = backend.subscribe(() => {});
|
|
77
|
+
expect(monitorListeners.get('on-battery')?.size).toBe(1);
|
|
78
|
+
expect(monitorListeners.get('on-ac')?.size).toBe(1);
|
|
79
|
+
unsubscribe();
|
|
80
|
+
expect(monitorListeners.get('on-battery')?.size).toBe(0);
|
|
81
|
+
expect(monitorListeners.get('on-ac')?.size).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('subscribeSuspend and subscribeResume register and remove their listeners', () => {
|
|
85
|
+
const { electron, monitorListeners } = fakeElectron({});
|
|
86
|
+
const backend = createElectronPowerBackend(electron);
|
|
87
|
+
const unsubSuspend = backend.subscribeSuspend(() => {});
|
|
88
|
+
const unsubResume = backend.subscribeResume(() => {});
|
|
89
|
+
expect(monitorListeners.get('suspend')?.size).toBe(1);
|
|
90
|
+
expect(monitorListeners.get('resume')?.size).toBe(1);
|
|
91
|
+
unsubSuspend();
|
|
92
|
+
unsubResume();
|
|
93
|
+
expect(monitorListeners.get('suspend')?.size).toBe(0);
|
|
94
|
+
expect(monitorListeners.get('resume')?.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('setKeepAwake starts a single blocker and stops it when disabled', () => {
|
|
98
|
+
const { electron, blocker } = fakeElectron({ startId: 42 });
|
|
99
|
+
const backend = createElectronPowerBackend(electron);
|
|
100
|
+
expect(backend.setKeepAwake(true)).toBe(true);
|
|
101
|
+
expect(blocker.started).toEqual([42]);
|
|
102
|
+
// Already held — no second blocker, returns the requested state.
|
|
103
|
+
expect(backend.setKeepAwake(true)).toBe(true);
|
|
104
|
+
expect(blocker.started).toEqual([42]);
|
|
105
|
+
expect(backend.setKeepAwake(false)).toBe(true);
|
|
106
|
+
expect(blocker.stopped).toEqual([42]);
|
|
107
|
+
// Already released.
|
|
108
|
+
expect(backend.setKeepAwake(false)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ElectronApi } from './electronModule';
|
|
2
|
+
import { createElectronProtocolBackend } from './electronProtocol';
|
|
3
|
+
|
|
4
|
+
function fakeElectron(): {
|
|
5
|
+
electron: ElectronApi;
|
|
6
|
+
listeners: Map<string, ((...args: unknown[]) => void)[]>;
|
|
7
|
+
registered: Set<string>;
|
|
8
|
+
} {
|
|
9
|
+
const listeners = new Map<string, ((...args: unknown[]) => void)[]>();
|
|
10
|
+
const registered = new Set<string>();
|
|
11
|
+
const electron = {
|
|
12
|
+
app: {
|
|
13
|
+
setAsDefaultProtocolClient: (scheme: string) => {
|
|
14
|
+
registered.add(scheme);
|
|
15
|
+
return true;
|
|
16
|
+
},
|
|
17
|
+
removeAsDefaultProtocolClient: (scheme: string) => {
|
|
18
|
+
registered.delete(scheme);
|
|
19
|
+
return true;
|
|
20
|
+
},
|
|
21
|
+
isDefaultProtocolClient: (scheme: string) => registered.has(scheme),
|
|
22
|
+
on: (event: string, listener: (...args: unknown[]) => void) => {
|
|
23
|
+
const list = listeners.get(event) ?? [];
|
|
24
|
+
list.push(listener);
|
|
25
|
+
listeners.set(event, list);
|
|
26
|
+
},
|
|
27
|
+
removeListener: (event: string, listener: (...args: unknown[]) => void) => {
|
|
28
|
+
const list = listeners.get(event) ?? [];
|
|
29
|
+
listeners.set(
|
|
30
|
+
event,
|
|
31
|
+
list.filter((l) => l !== listener),
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
} as unknown as ElectronApi;
|
|
36
|
+
return { electron, listeners, registered };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createElectronProtocolBackend', () => {
|
|
40
|
+
it('registers, queries, and unregisters a scheme', () => {
|
|
41
|
+
const fake = fakeElectron();
|
|
42
|
+
const backend = createElectronProtocolBackend(fake.electron);
|
|
43
|
+
expect(backend.isRegistered('flight')).toBe(false);
|
|
44
|
+
expect(backend.register('flight')).toBe(true);
|
|
45
|
+
expect(backend.isRegistered('flight')).toBe(true);
|
|
46
|
+
expect(backend.setAsDefault('flight')).toBe(true);
|
|
47
|
+
expect(backend.unregister('flight')).toBe(true);
|
|
48
|
+
expect(backend.isRegistered('flight')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('adapts the open-url event and unsubscribes', () => {
|
|
52
|
+
const fake = fakeElectron();
|
|
53
|
+
const backend = createElectronProtocolBackend(fake.electron);
|
|
54
|
+
let url = '';
|
|
55
|
+
const off = backend.subscribe((next) => {
|
|
56
|
+
url = next;
|
|
57
|
+
});
|
|
58
|
+
for (const l of fake.listeners.get('open-url') ?? []) l({}, 'flight://open');
|
|
59
|
+
expect(url).toBe('flight://open');
|
|
60
|
+
off();
|
|
61
|
+
expect(fake.listeners.get('open-url')).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getAppName, setAppBackend } from '@flighthq/app';
|
|
2
|
+
import { readClipboardText, setClipboardBackend } from '@flighthq/clipboard';
|
|
3
|
+
import { setStorageBackend } from '@flighthq/storage';
|
|
4
|
+
|
|
5
|
+
import type { ElectronApi } from './electronModule';
|
|
6
|
+
import { registerElectronBackends } from './electronRegister';
|
|
7
|
+
|
|
8
|
+
// A fake Electron module broad enough that every createElectron*Backend constructs without touching
|
|
9
|
+
// missing members. Backends close over `electron` and only call into it when their methods run, so a
|
|
10
|
+
// thin fake suffices to prove registration routes the capability seams to the Electron backends.
|
|
11
|
+
function fakeElectron(): ElectronApi {
|
|
12
|
+
const noop = () => {};
|
|
13
|
+
const off = () => () => {};
|
|
14
|
+
return {
|
|
15
|
+
app: {
|
|
16
|
+
getName: () => 'ElectronApp',
|
|
17
|
+
getLocale: () => 'en-US',
|
|
18
|
+
getPath: () => '/userData',
|
|
19
|
+
on: noop,
|
|
20
|
+
removeListener: noop,
|
|
21
|
+
},
|
|
22
|
+
clipboard: {
|
|
23
|
+
readText: () => 'ELECTRON-TEXT',
|
|
24
|
+
},
|
|
25
|
+
fs: {
|
|
26
|
+
existsSync: () => false,
|
|
27
|
+
readFileSync: () => '{}',
|
|
28
|
+
writeFileSync: noop,
|
|
29
|
+
},
|
|
30
|
+
globalShortcut: {},
|
|
31
|
+
screen: { on: noop, removeListener: noop },
|
|
32
|
+
powerMonitor: { on: noop, removeListener: noop },
|
|
33
|
+
powerSaveBlocker: {},
|
|
34
|
+
nativeImage: {},
|
|
35
|
+
ipcMain: { on: noop, removeListener: noop },
|
|
36
|
+
autoUpdater: { on: noop, removeListener: noop },
|
|
37
|
+
shell: {},
|
|
38
|
+
dialog: {},
|
|
39
|
+
Menu: { buildFromTemplate: () => ({ popup: noop }), setApplicationMenu: noop },
|
|
40
|
+
// The remaining members are unused at registration time.
|
|
41
|
+
_off: off,
|
|
42
|
+
} as unknown as ElectronApi;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
setClipboardBackend(null);
|
|
47
|
+
setAppBackend(null);
|
|
48
|
+
setStorageBackend(null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('registerElectronBackends', () => {
|
|
52
|
+
it('routes capability seams to the Electron backends without throwing', async () => {
|
|
53
|
+
registerElectronBackends(fakeElectron());
|
|
54
|
+
expect(getAppName()).toBe('ElectronApp');
|
|
55
|
+
expect(await readClipboardText()).toBe('ELECTRON-TEXT');
|
|
56
|
+
});
|
|
57
|
+
});
|