@deckbuilderkit/platform 0.0.0-stage → 0.1.1

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,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
+ }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
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
+ }
@@ -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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.