@deckbuilderkit/platform 0.0.0-stage → 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/package.json +19 -4
- package/src/browser.ts +55 -0
- package/src/dialogs.ts +296 -0
- package/src/editors.ts +107 -0
- package/src/errors.ts +15 -0
- package/src/exe.ts +42 -0
- package/src/files.ts +49 -0
- package/src/home.ts +7 -0
- package/src/index.ts +107 -0
- package/src/json-file.ts +73 -0
- package/src/launch.ts +4 -0
- package/src/rename-retry.ts +32 -0
- package/src/run.ts +84 -0
- package/src/secrets.ts +209 -0
- package/src/theme.ts +25 -0
- package/src/trash.ts +90 -0
- package/src/win/autostart.ts +71 -0
- package/src/win/placement.ts +73 -0
- package/src/win/powershell.ts +39 -0
- package/src/win/protocol.ts +79 -0
- package/src/win/toast.ts +168 -0
- package/src/win/tray.ts +223 -0
- package/README.md +0 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isCompiledExe } from '../exe.ts';
|
|
2
|
+
import { type Runner, runWithTimeout } from '../run.ts';
|
|
3
|
+
|
|
4
|
+
const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
5
|
+
const TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export interface AutostartOptions {
|
|
8
|
+
/** Registry value name, usually the app's display name. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Flags the exe gets at login. */
|
|
11
|
+
args?: string[];
|
|
12
|
+
run?: Runner;
|
|
13
|
+
execPath?: string;
|
|
14
|
+
/** False when running from source or off Windows: then nothing touches the registry. */
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
onWarn?: (msg: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Autostart {
|
|
20
|
+
/** Registers or removes the Run value; a no-op when not enabled. */
|
|
21
|
+
set(on: boolean): Promise<void>;
|
|
22
|
+
/** The registered command, or null when absent. */
|
|
23
|
+
registered(): Promise<string | null>;
|
|
24
|
+
/** Registers the Run value when absent or when the exe moved since it was registered. */
|
|
25
|
+
fixUp(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const runCommand = (execPath: string, args: string[] = []): string => [`"${execPath}"`, ...args].join(' ');
|
|
29
|
+
|
|
30
|
+
export function autostartArgv(name: string, on: boolean, command: string): string[] {
|
|
31
|
+
return on
|
|
32
|
+
? ['reg.exe', 'add', RUN_KEY, '/v', name, '/t', 'REG_SZ', '/d', command, '/f']
|
|
33
|
+
: ['reg.exe', 'delete', RUN_KEY, '/v', name, '/f'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The data of value `name` in `reg.exe query` output. */
|
|
37
|
+
export function parseRegQuery(stdout: string, name: string): string | null {
|
|
38
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
|
+
const m = stdout.match(new RegExp(`^\\s*${escaped}\\s+REG_SZ\\s+(.+?)\\s*$`, 'm'));
|
|
40
|
+
return m?.[1] ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Starts the exe at Windows login through the HKCU Run key. */
|
|
44
|
+
export function createAutostart(opts: AutostartOptions): Autostart {
|
|
45
|
+
const run = opts.run ?? runWithTimeout;
|
|
46
|
+
const execPath = opts.execPath ?? process.execPath;
|
|
47
|
+
const enabled = opts.enabled ?? (process.platform === 'win32' && isCompiledExe(execPath));
|
|
48
|
+
const command = runCommand(execPath, opts.args);
|
|
49
|
+
const warn = opts.onWarn ?? ((msg) => console.warn(`autostart: ${msg}`));
|
|
50
|
+
|
|
51
|
+
const registered = async (): Promise<string | null> => {
|
|
52
|
+
const r = await run(['reg.exe', 'query', RUN_KEY, '/v', opts.name], TIMEOUT_MS);
|
|
53
|
+
return r.kind === 'done' && r.code === 0 ? parseRegQuery(r.stdout, opts.name) : null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const set = async (on: boolean): Promise<void> => {
|
|
57
|
+
if (!enabled) return;
|
|
58
|
+
if (!on && (await registered()) === null) return;
|
|
59
|
+
const r = await run(autostartArgv(opts.name, on, command), TIMEOUT_MS);
|
|
60
|
+
if (r.kind === 'timeout' || r.code !== 0) warn(`reg.exe failed: ${r.kind === 'done' ? r.stderr.trim() : 'timeout'}`);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
set,
|
|
65
|
+
registered,
|
|
66
|
+
async fixUp() {
|
|
67
|
+
if (!enabled) return;
|
|
68
|
+
if ((await registered()) !== command) await set(true);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Pointer } from 'bun:ffi';
|
|
2
|
+
|
|
3
|
+
/** Where a window sits when not maximized, plus whether it is maximized. Pixels in virtual-screen coordinates. */
|
|
4
|
+
export interface WindowPlacement {
|
|
5
|
+
left: number;
|
|
6
|
+
top: number;
|
|
7
|
+
right: number;
|
|
8
|
+
bottom: number;
|
|
9
|
+
maximized: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// WINDOWPLACEMENT: length, flags, showCmd, ptMinPosition(2), ptMaxPosition(2), rcNormalPosition(4), all 32-bit.
|
|
13
|
+
const STRUCT_INTS = 11;
|
|
14
|
+
const SW_SHOWNORMAL = 1;
|
|
15
|
+
const SW_SHOWMAXIMIZED = 3;
|
|
16
|
+
const WPF_RESTORETOMAXIMIZED = 2;
|
|
17
|
+
|
|
18
|
+
function user32() {
|
|
19
|
+
const { dlopen, FFIType } = require('bun:ffi') as typeof import('bun:ffi');
|
|
20
|
+
return dlopen('user32.dll', {
|
|
21
|
+
GetWindowPlacement: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
22
|
+
SetWindowPlacement: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reads a window's placement on Windows; undefined elsewhere or when the call fails. */
|
|
27
|
+
export function getWindowPlacement(hwnd: Pointer | null): WindowPlacement | undefined {
|
|
28
|
+
if (process.platform !== 'win32' || !hwnd) return undefined;
|
|
29
|
+
try {
|
|
30
|
+
const { ptr } = require('bun:ffi') as typeof import('bun:ffi');
|
|
31
|
+
const lib = user32();
|
|
32
|
+
const buf = new Int32Array(STRUCT_INTS);
|
|
33
|
+
buf[0] = STRUCT_INTS * 4;
|
|
34
|
+
const ok = lib.symbols.GetWindowPlacement(hwnd, ptr(buf));
|
|
35
|
+
lib.close();
|
|
36
|
+
if (!ok) return undefined;
|
|
37
|
+
const maximized = buf[2] === SW_SHOWMAXIMIZED || (buf[1] & WPF_RESTORETOMAXIMIZED) !== 0;
|
|
38
|
+
return { left: buf[7], top: buf[8], right: buf[9], bottom: buf[10], maximized };
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Moves and sizes a window (maximizing it if asked). Windows pulls a rectangle on a missing monitor back onto a visible one. */
|
|
45
|
+
export function setWindowPlacement(hwnd: Pointer | null, p: WindowPlacement): void {
|
|
46
|
+
if (process.platform !== 'win32' || !hwnd) return;
|
|
47
|
+
try {
|
|
48
|
+
const { ptr } = require('bun:ffi') as typeof import('bun:ffi');
|
|
49
|
+
const lib = user32();
|
|
50
|
+
const buf = new Int32Array(STRUCT_INTS);
|
|
51
|
+
buf[0] = STRUCT_INTS * 4;
|
|
52
|
+
buf[2] = p.maximized ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL;
|
|
53
|
+
buf[7] = p.left;
|
|
54
|
+
buf[8] = p.top;
|
|
55
|
+
buf[9] = p.right;
|
|
56
|
+
buf[10] = p.bottom;
|
|
57
|
+
lib.symbols.SetWindowPlacement(hwnd, ptr(buf));
|
|
58
|
+
lib.close();
|
|
59
|
+
} catch {
|
|
60
|
+
// the window stays where the library put it
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parses a saved placement; undefined when it isn't a sane rectangle. */
|
|
65
|
+
export function parsePlacement(value: unknown): WindowPlacement | undefined {
|
|
66
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
67
|
+
const v = value as Record<string, unknown>;
|
|
68
|
+
const nums = [v.left, v.top, v.right, v.bottom];
|
|
69
|
+
if (!nums.every((n) => Number.isInteger(n)) || typeof v.maximized !== 'boolean') return undefined;
|
|
70
|
+
const [left, top, right, bottom] = nums as number[];
|
|
71
|
+
if (right - left < 200 || bottom - top < 150) return undefined;
|
|
72
|
+
return { left, top, right, bottom, maximized: v.maximized };
|
|
73
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
type Stream = 'pipe' | 'ignore';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Starts Windows PowerShell 5.1 (never pwsh: PowerShell 7 dropped WinRT projection) on `script`,
|
|
5
|
+
* hidden. The script goes in -EncodedCommand, so multi-line scripts and C# here-strings need no
|
|
6
|
+
* quoting and stdin stays free for data (callers write JSON lines to it). `sta` adds -STA
|
|
7
|
+
* (WinForms). stderr is always piped. No timeout: callers that need one use `killTree`.
|
|
8
|
+
*/
|
|
9
|
+
export function spawnPowerShell<In extends Stream = 'ignore', Out extends Stream = 'pipe'>(
|
|
10
|
+
script: string,
|
|
11
|
+
opts: { sta?: boolean; stdin?: In; stdout?: Out } = {},
|
|
12
|
+
): Bun.Subprocess<In, Out, 'pipe'> {
|
|
13
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
14
|
+
const argv = ['powershell.exe', '-NoProfile', '-NonInteractive', ...(opts.sta ? ['-STA'] : []), '-EncodedCommand', encoded];
|
|
15
|
+
return Bun.spawn(argv, {
|
|
16
|
+
stdin: opts.stdin ?? 'ignore',
|
|
17
|
+
stdout: opts.stdout ?? 'pipe',
|
|
18
|
+
stderr: 'pipe',
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
}) as Bun.Subprocess<In, Out, 'pipe'>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** JSON with non-ASCII \u-escaped, so the console code page of a host can't mangle it. */
|
|
24
|
+
export function asciiJson(value: unknown): string {
|
|
25
|
+
return JSON.stringify(value).replace(/[\u007f-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Calls `onLine` with each trimmed, non-empty line of a host's output stream. */
|
|
29
|
+
export async function forEachLine(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> {
|
|
30
|
+
const decoder = new TextDecoder();
|
|
31
|
+
let buffered = '';
|
|
32
|
+
for await (const chunk of stream) {
|
|
33
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
34
|
+
const lines = buffered.split(/\r?\n/);
|
|
35
|
+
buffered = lines.pop() ?? '';
|
|
36
|
+
for (const line of lines) if (line.trim() !== '') onLine(line.trim());
|
|
37
|
+
}
|
|
38
|
+
if (buffered.trim() !== '') onLine(buffered.trim());
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isCompiledExe } from '../exe.ts';
|
|
2
|
+
import { type Runner, runWithTimeout } from '../run.ts';
|
|
3
|
+
|
|
4
|
+
const TIMEOUT_MS = 10_000;
|
|
5
|
+
|
|
6
|
+
export interface UrlProtocolOptions {
|
|
7
|
+
/** URL scheme, e.g. `deckbuilder` for `deckbuilder://…`. */
|
|
8
|
+
scheme: string;
|
|
9
|
+
/** Display name; Windows shows `URL:<name>`. */
|
|
10
|
+
name: string;
|
|
11
|
+
execPath?: string;
|
|
12
|
+
run?: Runner;
|
|
13
|
+
/** False when running from source or off Windows: then nothing touches the registry. */
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
onWarn?: (msg: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UrlProtocol {
|
|
19
|
+
/** Writes the protocol keys; a no-op when not enabled. */
|
|
20
|
+
register(): Promise<void>;
|
|
21
|
+
/** The registered open command, or null when absent. */
|
|
22
|
+
registered(): Promise<string | null>;
|
|
23
|
+
/** Registers the protocol when absent or when the exe moved since it was registered. */
|
|
24
|
+
fixUp(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const protocolKey = (scheme: string): string => `HKCU\\Software\\Classes\\${scheme}`;
|
|
28
|
+
|
|
29
|
+
const openCommand = (execPath: string): string => `"${execPath}" --open-url "%1"`;
|
|
30
|
+
|
|
31
|
+
/** One `reg.exe add` per value the protocol needs. */
|
|
32
|
+
export function protocolArgv(scheme: string, name: string, execPath: string): string[][] {
|
|
33
|
+
const key = protocolKey(scheme);
|
|
34
|
+
return [
|
|
35
|
+
['reg.exe', 'add', key, '/ve', '/d', `URL:${name}`, '/f'],
|
|
36
|
+
['reg.exe', 'add', key, '/v', 'URL Protocol', '/d', '', '/f'],
|
|
37
|
+
['reg.exe', 'add', `${key}\\DefaultIcon`, '/ve', '/d', `"${execPath}",0`, '/f'],
|
|
38
|
+
['reg.exe', 'add', `${key}\\shell\\open\\command`, '/ve', '/d', openCommand(execPath), '/f'],
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The data of the first REG_SZ value in `reg.exe query` output; the default value's name is localized. */
|
|
43
|
+
export function parseFirstRegSz(stdout: string): string | null {
|
|
44
|
+
const m = stdout.match(/^\s*\S.*?\s+REG_SZ\s+(.+?)\s*$/m);
|
|
45
|
+
return m?.[1] ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Registers `<scheme>://` under HKCU so Windows opens such links with the exe (`--open-url <uri>`). */
|
|
49
|
+
export function createUrlProtocol(opts: UrlProtocolOptions): UrlProtocol {
|
|
50
|
+
const run = opts.run ?? runWithTimeout;
|
|
51
|
+
const execPath = opts.execPath ?? process.execPath;
|
|
52
|
+
const enabled = opts.enabled ?? (process.platform === 'win32' && isCompiledExe(execPath));
|
|
53
|
+
const warn = opts.onWarn ?? ((msg) => console.warn(`url protocol: ${msg}`));
|
|
54
|
+
|
|
55
|
+
const registered = async (): Promise<string | null> => {
|
|
56
|
+
const r = await run(['reg.exe', 'query', `${protocolKey(opts.scheme)}\\shell\\open\\command`, '/ve'], TIMEOUT_MS);
|
|
57
|
+
return r.kind === 'done' && r.code === 0 ? parseFirstRegSz(r.stdout) : null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const register = async (): Promise<void> => {
|
|
61
|
+
if (!enabled) return;
|
|
62
|
+
for (const cmd of protocolArgv(opts.scheme, opts.name, execPath)) {
|
|
63
|
+
const r = await run(cmd, TIMEOUT_MS);
|
|
64
|
+
if (r.kind === 'timeout' || r.code !== 0) {
|
|
65
|
+
warn(`reg.exe failed: ${r.kind === 'done' ? r.stderr.trim() : 'timeout'}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
register,
|
|
73
|
+
registered,
|
|
74
|
+
async fixUp() {
|
|
75
|
+
if (!enabled) return;
|
|
76
|
+
if ((await registered()) !== openCommand(execPath)) await register();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/win/toast.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { killTree } from '../run.ts';
|
|
2
|
+
import { asciiJson, forEachLine, spawnPowerShell } from './powershell.ts';
|
|
3
|
+
|
|
4
|
+
export interface Toast {
|
|
5
|
+
title: string;
|
|
6
|
+
body: string;
|
|
7
|
+
/** URI Windows opens when the toast is clicked (http(s) opens the default browser). */
|
|
8
|
+
launch: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Toaster {
|
|
12
|
+
show(toast: Toast): void;
|
|
13
|
+
/** Ends the host process; resolves with its exit code (null if none was running). */
|
|
14
|
+
close(): Promise<number | null>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const noopToaster: Toaster = { show() {}, close: async () => null };
|
|
18
|
+
|
|
19
|
+
// Windows PowerShell's own AppUserModelID: toasts need a registered app id, and a custom one
|
|
20
|
+
// would need a Start-menu shortcut.
|
|
21
|
+
const APP_ID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
22
|
+
|
|
23
|
+
function xmlEscape(text: string): string {
|
|
24
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// activationType="protocol": Windows opens `launch` itself, so no COM activator is registered.
|
|
28
|
+
export function toastXml(toast: Toast): string {
|
|
29
|
+
const body = toast.body === '' ? '' : `<text>${xmlEscape(toast.body)}</text>`;
|
|
30
|
+
return (
|
|
31
|
+
`<toast activationType="protocol" launch="${xmlEscape(toast.launch)}">` +
|
|
32
|
+
`<visual><binding template="ToastGeneric"><text>${xmlEscape(toast.title)}</text>${body}` +
|
|
33
|
+
'</binding></visual></toast>'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One stdin line for the host. */
|
|
38
|
+
export function toastLine(toast: Toast): string {
|
|
39
|
+
return `${asciiJson({ xml: toastXml(toast) })}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Reads one JSON line per toast until stdin closes. A bad line is reported on stderr and skipped.
|
|
43
|
+
// $ProgressPreference: module-loading progress would otherwise reach stderr as CLIXML.
|
|
44
|
+
const HOST_SCRIPT = `
|
|
45
|
+
$ErrorActionPreference = 'Stop'
|
|
46
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
47
|
+
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
48
|
+
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
|
|
49
|
+
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${APP_ID}')
|
|
50
|
+
while ($true) {
|
|
51
|
+
$line = [Console]::In.ReadLine()
|
|
52
|
+
if ($null -eq $line) { break }
|
|
53
|
+
if ($line.Trim() -eq '') { continue }
|
|
54
|
+
try {
|
|
55
|
+
$doc = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
56
|
+
$doc.LoadXml(($line | ConvertFrom-Json).xml)
|
|
57
|
+
$notifier.Show([Windows.UI.Notifications.ToastNotification]::new($doc))
|
|
58
|
+
} catch {
|
|
59
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
export interface ToastProcess {
|
|
65
|
+
pid: number;
|
|
66
|
+
write(line: string): void;
|
|
67
|
+
end(): void;
|
|
68
|
+
stderr: ReadableStream<Uint8Array>;
|
|
69
|
+
exited: Promise<number>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ToastSpawn = () => ToastProcess;
|
|
73
|
+
|
|
74
|
+
// stdin carries the toast lines.
|
|
75
|
+
const spawnHost: ToastSpawn = () => {
|
|
76
|
+
const proc = spawnPowerShell(HOST_SCRIPT, { stdin: 'pipe', stdout: 'ignore' });
|
|
77
|
+
return {
|
|
78
|
+
pid: proc.pid,
|
|
79
|
+
write(line) {
|
|
80
|
+
proc.stdin.write(line);
|
|
81
|
+
proc.stdin.flush();
|
|
82
|
+
},
|
|
83
|
+
end: () => proc.stdin.end(),
|
|
84
|
+
stderr: proc.stderr,
|
|
85
|
+
exited: proc.exited,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export interface ToasterOptions {
|
|
90
|
+
spawn?: ToastSpawn;
|
|
91
|
+
onError?: (msg: string) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Running {
|
|
95
|
+
proc: ToastProcess;
|
|
96
|
+
done: Promise<number>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Windows toast notifications through a long-lived PowerShell host, started on the first toast
|
|
101
|
+
* and again after it dies. Off Windows (without `spawn`) it returns a toaster that does nothing.
|
|
102
|
+
*/
|
|
103
|
+
export function createToaster(opts: ToasterOptions = {}): Toaster {
|
|
104
|
+
if (!opts.spawn && process.platform !== 'win32') return noopToaster;
|
|
105
|
+
const spawn = opts.spawn ?? spawnHost;
|
|
106
|
+
const onError = opts.onError ?? ((msg) => console.warn(`toast: ${msg}`));
|
|
107
|
+
let current: Running | null = null;
|
|
108
|
+
|
|
109
|
+
const start = (): Running => {
|
|
110
|
+
const proc = spawn();
|
|
111
|
+
const errors = forEachLine(proc.stderr, onError).catch(() => {});
|
|
112
|
+
const entry: Running = { proc, done: Promise.resolve(0) };
|
|
113
|
+
entry.done = Promise.all([proc.exited, errors]).then(([code]) => {
|
|
114
|
+
if (current === entry) current = null;
|
|
115
|
+
if (code !== 0) onError(`toast host exited with code ${code}`);
|
|
116
|
+
return code;
|
|
117
|
+
});
|
|
118
|
+
current = entry;
|
|
119
|
+
return entry;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
show(toast) {
|
|
124
|
+
const line = toastLine(toast);
|
|
125
|
+
try {
|
|
126
|
+
(current ?? start()).proc.write(line);
|
|
127
|
+
} catch {
|
|
128
|
+
// The host died between toasts and its exit isn't observed yet: start a fresh one.
|
|
129
|
+
current = null;
|
|
130
|
+
try {
|
|
131
|
+
start().proc.write(line);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
current = null;
|
|
134
|
+
onError(`toast host unavailable: ${(err as Error).message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
async close() {
|
|
139
|
+
const entry = current;
|
|
140
|
+
if (!entry) return null;
|
|
141
|
+
current = null;
|
|
142
|
+
entry.proc.end();
|
|
143
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
144
|
+
const timeout = new Promise<null>((resolve) => {
|
|
145
|
+
timer = setTimeout(resolve, 5000, null);
|
|
146
|
+
});
|
|
147
|
+
const code = await Promise.race([entry.done, timeout]);
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
if (code === null) killTree(entry.proc.pid);
|
|
150
|
+
return code;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// `bun packages/platform/src/win/toast.ts --selftest`: shows one real toast; exit code 0 means
|
|
156
|
+
// Windows accepted it.
|
|
157
|
+
if (import.meta.main && process.argv.includes('--selftest')) {
|
|
158
|
+
let failed = false;
|
|
159
|
+
const toaster = createToaster({
|
|
160
|
+
onError: (msg) => {
|
|
161
|
+
failed = true;
|
|
162
|
+
console.error(msg);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
toaster.show({ title: 'DeckBuilder self-test', body: 'Toasts work <&> "ção"', launch: 'https://example.com/' });
|
|
166
|
+
const code = await toaster.close();
|
|
167
|
+
process.exit(failed || code !== 0 ? 1 : 0);
|
|
168
|
+
}
|
package/src/win/tray.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { isCompiledExe } from '../exe.ts';
|
|
2
|
+
import { killTree } from '../run.ts';
|
|
3
|
+
import { asciiJson, forEachLine, spawnPowerShell } from './powershell.ts';
|
|
4
|
+
|
|
5
|
+
export interface TrayItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Tray {
|
|
11
|
+
/** False off Windows, where nothing shows. */
|
|
12
|
+
supported: boolean;
|
|
13
|
+
/** Resolves once the icon shows; rejects when the host ends or hangs before that. */
|
|
14
|
+
ready: Promise<void>;
|
|
15
|
+
/** Removes the icon and ends the host. */
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const noopTray: Tray = { supported: false, ready: Promise.resolve(), close: async () => {} };
|
|
20
|
+
|
|
21
|
+
export interface TrayProcess {
|
|
22
|
+
pid: number;
|
|
23
|
+
end(): void;
|
|
24
|
+
stdout: ReadableStream<Uint8Array>;
|
|
25
|
+
stderr: ReadableStream<Uint8Array>;
|
|
26
|
+
exited: Promise<number>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type TraySpawn = (script: string) => TrayProcess;
|
|
30
|
+
|
|
31
|
+
export interface TrayOptions {
|
|
32
|
+
/** Hover text; Windows shows at most 63 characters. */
|
|
33
|
+
tooltip: string;
|
|
34
|
+
/** Menu entries in order; double-clicking the icon selects the first. */
|
|
35
|
+
items: TrayItem[];
|
|
36
|
+
/** File whose icon shows; default: the exe when compiled, else a generic application icon. */
|
|
37
|
+
iconPath?: string;
|
|
38
|
+
spawn?: TraySpawn;
|
|
39
|
+
onSelect: (id: string) => void;
|
|
40
|
+
onError?: (msg: string) => void;
|
|
41
|
+
/** How long `ready` waits for the icon before the host is killed; default 15 s. */
|
|
42
|
+
readyTimeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TraySpec {
|
|
46
|
+
tooltip: string;
|
|
47
|
+
iconPath: string | null;
|
|
48
|
+
/** `line` is what the host prints when the entry is clicked. */
|
|
49
|
+
items: { label: string; line: string }[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function traySpec(opts: Pick<TrayOptions, 'tooltip' | 'items' | 'iconPath'>): TraySpec {
|
|
53
|
+
return {
|
|
54
|
+
tooltip: opts.tooltip.slice(0, 63),
|
|
55
|
+
iconPath: opts.iconPath ?? null,
|
|
56
|
+
// A single & marks a menu mnemonic.
|
|
57
|
+
items: opts.items.map((item) => ({ label: item.label.replace(/&/g, '&&'), line: asciiJson({ select: item.id }) })),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The spec comes as base64 JSON, so labels need no escaping. [Console]::In.ReadLineAsync() blocks
|
|
62
|
+
// in .NET Framework, so stdin is read through its own StreamReader, polled by a timer next to the
|
|
63
|
+
// message loop; EOF (the parent quit or crashed) removes the icon and ends the host. A handler
|
|
64
|
+
// error must not reach WinForms, which would show its exception dialog.
|
|
65
|
+
const HOST_SCRIPT = `
|
|
66
|
+
$ErrorActionPreference = 'Stop'
|
|
67
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
68
|
+
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
|
69
|
+
$spec = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__SPEC__')) | ConvertFrom-Json
|
|
70
|
+
function Send($line) { try { [Console]::Out.WriteLine($line); [Console]::Out.Flush() } catch {} }
|
|
71
|
+
[System.Windows.Forms.Application]::add_ThreadException({ param($s, $e) try { [Console]::Error.WriteLine($e.Exception.Message) } catch {} })
|
|
72
|
+
$icon = New-Object System.Windows.Forms.NotifyIcon
|
|
73
|
+
$icon.Icon = [System.Drawing.SystemIcons]::Application
|
|
74
|
+
if ($spec.iconPath) {
|
|
75
|
+
try { $icon.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($spec.iconPath) } catch { [Console]::Error.WriteLine($_.Exception.Message) }
|
|
76
|
+
}
|
|
77
|
+
$icon.Text = $spec.tooltip
|
|
78
|
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
79
|
+
foreach ($item in $spec.items) {
|
|
80
|
+
$entry = $menu.Items.Add($item.label)
|
|
81
|
+
$entry.Tag = $item.line
|
|
82
|
+
$entry.add_Click({ param($s) Send $s.Tag })
|
|
83
|
+
}
|
|
84
|
+
$icon.ContextMenuStrip = $menu
|
|
85
|
+
$first = @($spec.items)[0]
|
|
86
|
+
$icon.add_MouseDoubleClick({ param($s, $e) if ($first -and $e.Button -eq 'Left') { Send $first.line } })
|
|
87
|
+
$stdin = New-Object System.IO.StreamReader([Console]::OpenStandardInput())
|
|
88
|
+
$state = @{ read = $stdin.ReadLineAsync() }
|
|
89
|
+
$timer = New-Object System.Windows.Forms.Timer
|
|
90
|
+
$timer.Interval = 250
|
|
91
|
+
$timer.add_Tick({
|
|
92
|
+
if (-not $state.read.IsCompleted) { return }
|
|
93
|
+
if (-not $state.read.IsFaulted -and $null -ne $state.read.Result) { $state.read = $stdin.ReadLineAsync(); return }
|
|
94
|
+
$timer.Stop()
|
|
95
|
+
[System.Windows.Forms.Application]::ExitThread()
|
|
96
|
+
})
|
|
97
|
+
$icon.Visible = $true
|
|
98
|
+
Send '{"ready":true}'
|
|
99
|
+
$timer.Start()
|
|
100
|
+
[System.Windows.Forms.Application]::Run()
|
|
101
|
+
$icon.Visible = $false
|
|
102
|
+
$icon.Dispose()
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
function trayScript(spec: TraySpec): string {
|
|
106
|
+
return HOST_SCRIPT.replace('__SPEC__', Buffer.from(JSON.stringify(spec), 'utf8').toString('base64'));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -STA for WinForms; stdin is the parent's lifeline.
|
|
110
|
+
const spawnHost: TraySpawn = (script) => {
|
|
111
|
+
const proc = spawnPowerShell(script, { sta: true, stdin: 'pipe', stdout: 'pipe' });
|
|
112
|
+
return { pid: proc.pid, end: () => proc.stdin.end(), stdout: proc.stdout, stderr: proc.stderr, exited: proc.exited };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A notification-area icon with a context menu, hosted by a PowerShell process. Off Windows
|
|
117
|
+
* (without `spawn`) it returns a tray that does nothing.
|
|
118
|
+
*/
|
|
119
|
+
export function createTray(opts: TrayOptions): Tray {
|
|
120
|
+
if (!opts.spawn && process.platform !== 'win32') return noopTray;
|
|
121
|
+
const spawn = opts.spawn ?? spawnHost;
|
|
122
|
+
const onError = opts.onError ?? ((msg) => console.warn(`tray: ${msg}`));
|
|
123
|
+
const iconPath = opts.iconPath ?? (isCompiledExe() ? process.execPath : undefined);
|
|
124
|
+
|
|
125
|
+
let resolveReady: () => void = () => {};
|
|
126
|
+
let rejectReady: (err: Error) => void = () => {};
|
|
127
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
128
|
+
resolveReady = resolve;
|
|
129
|
+
rejectReady = reject;
|
|
130
|
+
});
|
|
131
|
+
// The caller may not wait for it; a rejection must not end the process.
|
|
132
|
+
ready.catch(() => {});
|
|
133
|
+
|
|
134
|
+
let proc: TrayProcess;
|
|
135
|
+
try {
|
|
136
|
+
proc = spawn(trayScript(traySpec({ ...opts, iconPath })));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
rejectReady(new Error(`tray host unavailable: ${(err as Error).message}`));
|
|
139
|
+
return { supported: true, ready, close: async () => {} };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let shown = false;
|
|
143
|
+
let closing: Promise<void> | null = null;
|
|
144
|
+
const output = forEachLine(proc.stdout, (line) => {
|
|
145
|
+
let msg: { ready?: unknown; select?: unknown } | null = null;
|
|
146
|
+
try {
|
|
147
|
+
msg = JSON.parse(line);
|
|
148
|
+
} catch {
|
|
149
|
+
// reported below
|
|
150
|
+
}
|
|
151
|
+
if (msg?.ready === true) {
|
|
152
|
+
shown = true;
|
|
153
|
+
resolveReady();
|
|
154
|
+
} else if (typeof msg?.select === 'string') {
|
|
155
|
+
try {
|
|
156
|
+
opts.onSelect(msg.select);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
onError(`menu entry ${msg.select} failed: ${(err as Error).message}`);
|
|
159
|
+
}
|
|
160
|
+
} else onError(`unexpected output: ${line}`);
|
|
161
|
+
}).catch(() => {});
|
|
162
|
+
const errors = forEachLine(proc.stderr, onError).catch(() => {});
|
|
163
|
+
const done = Promise.all([proc.exited, output, errors]).then(([code]) => {
|
|
164
|
+
if (!shown) rejectReady(new Error(`tray host exited with code ${code} before showing the icon`));
|
|
165
|
+
else if (!closing) onError(`tray host exited with code ${code}`);
|
|
166
|
+
return code;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
|
|
170
|
+
const readyTimer = setTimeout(() => {
|
|
171
|
+
rejectReady(new Error(`tray icon didn't show within ${readyTimeoutMs / 1000} s`));
|
|
172
|
+
killTree(proc.pid);
|
|
173
|
+
}, readyTimeoutMs);
|
|
174
|
+
const stopReadyTimer = () => clearTimeout(readyTimer);
|
|
175
|
+
ready.then(stopReadyTimer, stopReadyTimer);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
supported: true,
|
|
179
|
+
ready,
|
|
180
|
+
close() {
|
|
181
|
+
closing ??= (async () => {
|
|
182
|
+
stopReadyTimer();
|
|
183
|
+
try {
|
|
184
|
+
proc.end();
|
|
185
|
+
} catch {
|
|
186
|
+
// already gone
|
|
187
|
+
}
|
|
188
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
189
|
+
const timeout = new Promise<null>((resolve) => {
|
|
190
|
+
timer = setTimeout(resolve, 5000, null);
|
|
191
|
+
});
|
|
192
|
+
const code = await Promise.race([done, timeout]);
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
if (code === null) killTree(proc.pid);
|
|
195
|
+
})();
|
|
196
|
+
return closing;
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `bun packages/platform/src/win/tray.ts --selftest`: shows the icon, then removes it; exit code 0
|
|
202
|
+
// means it showed within 15 s.
|
|
203
|
+
if (import.meta.main && process.argv.includes('--selftest')) {
|
|
204
|
+
const tray = createTray({
|
|
205
|
+
tooltip: 'DeckBuilder self-test',
|
|
206
|
+
items: [{ id: 'hello', label: 'Self-test <&> "ção"' }],
|
|
207
|
+
onSelect: (id) => console.log(`selected ${id}`),
|
|
208
|
+
onError: (msg) => console.error(msg),
|
|
209
|
+
});
|
|
210
|
+
if (!tray.supported) {
|
|
211
|
+
console.error('No tray on this platform.');
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
let code = 0;
|
|
215
|
+
try {
|
|
216
|
+
await tray.ready;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error((err as Error).message);
|
|
219
|
+
code = 1;
|
|
220
|
+
}
|
|
221
|
+
await tray.close();
|
|
222
|
+
process.exit(code);
|
|
223
|
+
}
|
package/README.md
DELETED