@n1h/sweetview 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.
- package/LICENSE +21 -0
- package/LICENSE-0BSD +12 -0
- package/README.md +268 -3
- package/docs/README.md +26 -0
- package/docs/app.md +523 -0
- package/docs/build.md +169 -0
- package/docs/ipc-ws.md +202 -0
- package/docs/ipc.md +323 -0
- package/docs/native.md +1066 -0
- package/docs/page.md +71 -0
- package/docs/settings-store.md +125 -0
- package/docs/webview-library.md +104 -0
- package/docs/window-chrome.md +221 -0
- package/docs/worker.md +72 -0
- package/file-imports.d.ts +19 -0
- package/package.json +66 -4
- package/src/app.ts +350 -0
- package/src/build.ts +118 -0
- package/src/html.ts +71 -0
- package/src/index.ts +8 -0
- package/src/ipc/bridge.ts +41 -0
- package/src/ipc/frame.ts +16 -0
- package/src/ipc/index.ts +16 -0
- package/src/ipc/memory.ts +33 -0
- package/src/ipc/service.ts +75 -0
- package/src/ipc-ws/client.ts +111 -0
- package/src/ipc-ws/index.ts +5 -0
- package/src/ipc-ws/server.ts +133 -0
- package/src/make-app.ts +93 -0
- package/src/messages.ts +16 -0
- package/src/native/app-icon.ts +23 -0
- package/src/native/app-menu.ts +18 -0
- package/src/native/darwin/app-icon.ts +10 -0
- package/src/native/darwin/app-menu.ts +88 -0
- package/src/native/darwin/folder-picker.ts +29 -0
- package/src/native/darwin/image.ts +47 -0
- package/src/native/darwin/menu.ts +89 -0
- package/src/native/darwin/notifier.ts +95 -0
- package/src/native/darwin/objc.ts +246 -0
- package/src/native/darwin/overlay.ts +352 -0
- package/src/native/darwin/tray.ts +129 -0
- package/src/native/darwin/window.ts +64 -0
- package/src/native/folder-picker.ts +15 -0
- package/src/native/hit-regions.ts +162 -0
- package/src/native/icon.ts +530 -0
- package/src/native/index.ts +38 -0
- package/src/native/menu.ts +43 -0
- package/src/native/notifications.ts +40 -0
- package/src/native/overlay.ts +182 -0
- package/src/native/platform.ts +25 -0
- package/src/native/tray.ts +41 -0
- package/src/native/win32/api.ts +234 -0
- package/src/native/win32/app-icon.ts +5 -0
- package/src/native/win32/app-menu.ts +4 -0
- package/src/native/win32/folder-picker.ts +121 -0
- package/src/native/win32/icon.ts +85 -0
- package/src/native/win32/menu.ts +107 -0
- package/src/native/win32/message-window.ts +72 -0
- package/src/native/win32/notifier.ts +28 -0
- package/src/native/win32/overlay.ts +499 -0
- package/src/native/win32/tray.ts +228 -0
- package/src/native/win32/window.ts +77 -0
- package/src/native/window.ts +40 -0
- package/src/page.ts +48 -0
- package/src/ring.ts +110 -0
- package/src/settings-store.ts +101 -0
- package/src/webview-lib.ts +60 -0
- package/src/window-chrome.ts +118 -0
- package/src/worker.ts +72 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Native Windows folder picker: IFileOpenDialog (the modern Vista+ dialog)
|
|
2
|
+
// driven directly over COM vtables with bun:ffi. No child process.
|
|
3
|
+
//
|
|
4
|
+
// Must be called on the thread that owns the window (the webview UI thread),
|
|
5
|
+
// which is already COM-initialised (apartment threaded) by WebView2.
|
|
6
|
+
|
|
7
|
+
import { CFunction, FFIType, dlopen, ptr, read, toArrayBuffer, type Pointer } from "bun:ffi";
|
|
8
|
+
import type { WindowHandle } from "../window";
|
|
9
|
+
import { asPointer, toPointer, wstr } from "./api";
|
|
10
|
+
|
|
11
|
+
const ole32 = dlopen("ole32.dll", {
|
|
12
|
+
CoInitializeEx: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
|
13
|
+
CoCreateInstance: { args: [FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
14
|
+
CoTaskMemFree: { args: [FFIType.ptr], returns: FFIType.void },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const COINIT_APARTMENTTHREADED = 0x2;
|
|
18
|
+
const CLSCTX_INPROC_SERVER = 0x1;
|
|
19
|
+
const CLSID_FileOpenDialog = guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7");
|
|
20
|
+
const IID_IFileOpenDialog = guid("D57C7288-D4AD-4768-BE02-9D969532D960");
|
|
21
|
+
|
|
22
|
+
const FOS_NOCHANGEDIR = 0x8;
|
|
23
|
+
const FOS_PICKFOLDERS = 0x20;
|
|
24
|
+
const FOS_FORCEFILESYSTEM = 0x40;
|
|
25
|
+
const FOS_PATHMUSTEXIST = 0x800;
|
|
26
|
+
const SIGDN_FILESYSPATH = 0x80058000;
|
|
27
|
+
const HRESULT_CANCELLED = 0x800704c7 | 0; // HRESULT_FROM_WIN32(ERROR_CANCELLED)
|
|
28
|
+
|
|
29
|
+
// vtable slots (shobjidl_core.h)
|
|
30
|
+
const IUnknown_Release = 2;
|
|
31
|
+
const IModalWindow_Show = 3;
|
|
32
|
+
const IFileDialog_SetOptions = 9;
|
|
33
|
+
const IFileDialog_GetOptions = 10;
|
|
34
|
+
const IFileDialog_SetTitle = 17;
|
|
35
|
+
const IFileDialog_GetResult = 20;
|
|
36
|
+
const IShellItem_GetDisplayName = 5;
|
|
37
|
+
|
|
38
|
+
type Fn = (...args: unknown[]) => number;
|
|
39
|
+
|
|
40
|
+
function guid(s: string): Uint8Array {
|
|
41
|
+
const hex = s.replace(/-/g, "");
|
|
42
|
+
const b = new Uint8Array(16);
|
|
43
|
+
const view = new DataView(b.buffer);
|
|
44
|
+
view.setUint32(0, parseInt(hex.slice(0, 8), 16), true);
|
|
45
|
+
view.setUint16(4, parseInt(hex.slice(8, 12), 16), true);
|
|
46
|
+
view.setUint16(6, parseInt(hex.slice(12, 16), 16), true);
|
|
47
|
+
for (let i = 0; i < 8; i++) b[8 + i] = parseInt(hex.slice(16 + i * 2, 18 + i * 2), 16);
|
|
48
|
+
return b;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function method(obj: Pointer, slot: number, args: FFIType[], returns: FFIType): Fn {
|
|
52
|
+
const vtable = asPointer(read.ptr(obj, 0));
|
|
53
|
+
const fn = asPointer(read.ptr(vtable, slot * 8));
|
|
54
|
+
return CFunction({ ptr: fn, args, returns }) as unknown as Fn;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function release(obj: Pointer) {
|
|
58
|
+
method(obj, IUnknown_Release, [FFIType.ptr], FFIType.u32)(obj);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readWide(p: Pointer): string {
|
|
62
|
+
let n = 0;
|
|
63
|
+
while (read.u16(p, n * 2) !== 0) n++;
|
|
64
|
+
return new TextDecoder("utf-16le").decode(toArrayBuffer(p, 0, n * 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function check(hr: number, what: string) {
|
|
68
|
+
if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Opens the native folder dialog owned by `owner`. Returns the path, or null if cancelled. */
|
|
72
|
+
export function pickFolder(owner: WindowHandle, title: string): string | null {
|
|
73
|
+
const hwnd = toPointer(owner);
|
|
74
|
+
// S_OK or S_FALSE (already initialised on this thread by WebView2).
|
|
75
|
+
check(ole32.symbols.CoInitializeEx(null, COINIT_APARTMENTTHREADED), "CoInitializeEx");
|
|
76
|
+
|
|
77
|
+
const out = new BigUint64Array(1);
|
|
78
|
+
check(
|
|
79
|
+
ole32.symbols.CoCreateInstance(ptr(CLSID_FileOpenDialog), null, CLSCTX_INPROC_SERVER, ptr(IID_IFileOpenDialog), ptr(out)),
|
|
80
|
+
"CoCreateInstance(FileOpenDialog)",
|
|
81
|
+
);
|
|
82
|
+
const dialog = asPointer(out[0]!);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const opts = new Uint32Array(1);
|
|
86
|
+
check(method(dialog, IFileDialog_GetOptions, [FFIType.ptr, FFIType.ptr], FFIType.i32)(dialog, ptr(opts)), "GetOptions");
|
|
87
|
+
check(
|
|
88
|
+
method(dialog, IFileDialog_SetOptions, [FFIType.ptr, FFIType.u32], FFIType.i32)(
|
|
89
|
+
dialog,
|
|
90
|
+
(opts[0]! | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_NOCHANGEDIR) >>> 0,
|
|
91
|
+
),
|
|
92
|
+
"SetOptions",
|
|
93
|
+
);
|
|
94
|
+
check(method(dialog, IFileDialog_SetTitle, [FFIType.ptr, FFIType.ptr], FFIType.i32)(dialog, wstr(title)), "SetTitle");
|
|
95
|
+
|
|
96
|
+
const hr = method(dialog, IModalWindow_Show, [FFIType.ptr, FFIType.ptr], FFIType.i32)(dialog, hwnd);
|
|
97
|
+
if (hr === HRESULT_CANCELLED) return null;
|
|
98
|
+
check(hr, "Show");
|
|
99
|
+
|
|
100
|
+
out[0] = 0n;
|
|
101
|
+
check(method(dialog, IFileDialog_GetResult, [FFIType.ptr, FFIType.ptr], FFIType.i32)(dialog, ptr(out)), "GetResult");
|
|
102
|
+
const item = asPointer(out[0]!);
|
|
103
|
+
try {
|
|
104
|
+
out[0] = 0n;
|
|
105
|
+
check(
|
|
106
|
+
method(item, IShellItem_GetDisplayName, [FFIType.ptr, FFIType.u32, FFIType.ptr], FFIType.i32)(item, SIGDN_FILESYSPATH, ptr(out)),
|
|
107
|
+
"GetDisplayName",
|
|
108
|
+
);
|
|
109
|
+
const pwsz = asPointer(out[0]!);
|
|
110
|
+
try {
|
|
111
|
+
return readWide(pwsz);
|
|
112
|
+
} finally {
|
|
113
|
+
ole32.symbols.CoTaskMemFree(pwsz);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
release(item);
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
release(dialog);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Icon bytes to GDI objects: an HICON for the tray, toasts and window
|
|
2
|
+
// captions; an HBITMAP for menu items (menus take bitmaps, not icons, and
|
|
3
|
+
// want 32bpp premultiplied alpha). The .ico is decoded in JS so both paths
|
|
4
|
+
// scale to the DPI's size instead of letting the menu stretch a 16px image.
|
|
5
|
+
|
|
6
|
+
import { ptr, toArrayBuffer } from "bun:ffi";
|
|
7
|
+
import { icoRgba, parseIco, pickIcoEntry } from "../icon";
|
|
8
|
+
import { ICON_BIG, ICON_SMALL, SM_CXICON, SM_CXSMICON, WM_SETICON, gdi32, lastError, user32 } from "./api";
|
|
9
|
+
|
|
10
|
+
const IDI_APPLICATION = 32512n;
|
|
11
|
+
const LR_DEFAULTCOLOR = 0;
|
|
12
|
+
const DIB_RGB_COLORS = 0;
|
|
13
|
+
|
|
14
|
+
export type LoadedIcon = { handle: bigint; owned: boolean };
|
|
15
|
+
|
|
16
|
+
/** HICON from .ico bytes at the small-icon size (or `metric`); the stock application icon when there are none. */
|
|
17
|
+
export function loadIcon(ico: Uint8Array | undefined, metric: number = SM_CXSMICON): LoadedIcon {
|
|
18
|
+
if (ico) {
|
|
19
|
+
const want = user32.GetSystemMetrics(metric) || (metric === SM_CXICON ? 32 : 16);
|
|
20
|
+
const entry = pickIcoEntry(parseIco(ico), want);
|
|
21
|
+
const bits = ico.subarray(entry.offset, entry.offset + entry.size);
|
|
22
|
+
const handle = user32.CreateIconFromResourceEx(bits, bits.length, 1, 0x00030000, want, want, LR_DEFAULTCOLOR);
|
|
23
|
+
if (handle !== 0n) return { handle, owned: true };
|
|
24
|
+
console.error(`sweetnative: CreateIconFromResourceEx failed (${lastError()}); using the default icon`);
|
|
25
|
+
}
|
|
26
|
+
return { handle: user32.LoadIconW(0n, IDI_APPLICATION), owned: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const releaseIcon = (icon: LoadedIcon) => {
|
|
30
|
+
if (icon.owned) user32.DestroyIcon(icon.handle);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A 32bpp premultiplied-alpha HBITMAP of the .ico at `size` px (default the
|
|
35
|
+
* small-icon metric), for MENUITEMINFO.hbmpItem. 0n, with the reason logged,
|
|
36
|
+
* when the .ico cannot be decoded. The caller owns it: DeleteObject when done.
|
|
37
|
+
*/
|
|
38
|
+
export function iconBitmap(ico: Uint8Array, size: number = user32.GetSystemMetrics(SM_CXSMICON) || 16): bigint {
|
|
39
|
+
let rgba: Uint8Array;
|
|
40
|
+
try {
|
|
41
|
+
rgba = icoRgba(ico, size);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(`sweetnative: menu icon skipped: ${err instanceof Error ? err.message : err}`);
|
|
44
|
+
return 0n;
|
|
45
|
+
}
|
|
46
|
+
const info = new Uint8Array(40); // BITMAPINFOHEADER; no colour table for BI_RGB 32bpp
|
|
47
|
+
const dv = new DataView(info.buffer);
|
|
48
|
+
dv.setUint32(0, 40, true);
|
|
49
|
+
dv.setInt32(4, size, true);
|
|
50
|
+
dv.setInt32(8, -size, true); // negative: top-down, so the RGBA rows copy straight in
|
|
51
|
+
dv.setUint16(12, 1, true);
|
|
52
|
+
dv.setUint16(14, 32, true);
|
|
53
|
+
const bitsOut = new BigUint64Array(1);
|
|
54
|
+
const hbm = gdi32.CreateDIBSection(0n, info, DIB_RGB_COLORS, ptr(bitsOut), 0n, 0);
|
|
55
|
+
if (hbm === 0n || bitsOut[0] === 0n) {
|
|
56
|
+
console.error(`sweetnative: CreateDIBSection failed (${lastError()})`);
|
|
57
|
+
return 0n;
|
|
58
|
+
}
|
|
59
|
+
const bits = new Uint8Array(toArrayBuffer(Number(bitsOut[0]) as never, 0, size * size * 4));
|
|
60
|
+
for (let i = 0; i < rgba.length; i += 4) {
|
|
61
|
+
const a = rgba[i + 3]!;
|
|
62
|
+
bits[i] = Math.round((rgba[i + 2]! * a) / 255); // B
|
|
63
|
+
bits[i + 1] = Math.round((rgba[i + 1]! * a) / 255); // G
|
|
64
|
+
bits[i + 2] = Math.round((rgba[i]! * a) / 255); // R
|
|
65
|
+
bits[i + 3] = a;
|
|
66
|
+
}
|
|
67
|
+
return hbm;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const windowIcons = new Map<bigint, LoadedIcon[]>();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sets a window's caption and taskbar icons (WM_SETICON small + big) from .ico
|
|
74
|
+
* bytes, or back to the stock icon when undefined. The HICONs live until the
|
|
75
|
+
* next call for the same window; Windows draws from them, so they cannot be
|
|
76
|
+
* destroyed right away.
|
|
77
|
+
*/
|
|
78
|
+
export function setWindowIcon(hwnd: bigint, ico: Uint8Array | undefined): void {
|
|
79
|
+
const small = loadIcon(ico, SM_CXSMICON);
|
|
80
|
+
const big = loadIcon(ico, SM_CXICON);
|
|
81
|
+
user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, BigInt.asIntN(64, small.handle));
|
|
82
|
+
user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, BigInt.asIntN(64, big.handle));
|
|
83
|
+
windowIcons.get(hwnd)?.forEach(releaseIcon);
|
|
84
|
+
windowIcons.set(hwnd, [small, big]);
|
|
85
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Popup menus: a MenuTemplate becomes an HMENU tree, shown modally with
|
|
2
|
+
// TrackPopupMenu, then destroyed. Built fresh for every showing, so dynamic
|
|
3
|
+
// labels and check marks cost nothing. Item icons are 32bpp bitmaps
|
|
4
|
+
// (MENUITEMINFO.hbmpItem), which DestroyMenu does not free, so the build
|
|
5
|
+
// keeps a list to delete afterwards.
|
|
6
|
+
|
|
7
|
+
import { isSeparator, isSubmenu, type MenuTemplate } from "../menu";
|
|
8
|
+
import { WM_NULL, gdi32, lastError, user32, wstr } from "./api";
|
|
9
|
+
import { iconBitmap } from "./icon";
|
|
10
|
+
|
|
11
|
+
const MF_STRING = 0x0;
|
|
12
|
+
const MF_GRAYED = 0x1;
|
|
13
|
+
const MF_CHECKED = 0x8;
|
|
14
|
+
const MF_POPUP = 0x10;
|
|
15
|
+
const MF_SEPARATOR = 0x800;
|
|
16
|
+
|
|
17
|
+
const TPM_RIGHTBUTTON = 0x0002;
|
|
18
|
+
const TPM_BOTTOMALIGN = 0x0020;
|
|
19
|
+
const TPM_RETURNCMD = 0x0100;
|
|
20
|
+
|
|
21
|
+
// MENUITEMINFOW, x64 (sizeof = 80).
|
|
22
|
+
export const MII_SIZE = 80;
|
|
23
|
+
export const MII_MASK = 4;
|
|
24
|
+
export const MII_HBMPITEM = 72;
|
|
25
|
+
const MIIM_BITMAP = 0x80;
|
|
26
|
+
|
|
27
|
+
/** A literal ampersand in a menu label is an accelerator prefix unless doubled. */
|
|
28
|
+
export const escapeLabel = (label: string) => label.replaceAll("&", "&&");
|
|
29
|
+
|
|
30
|
+
/** The MENUITEMINFOW that sets only hbmpItem. */
|
|
31
|
+
export function bitmapItemInfo(hbm: bigint): Uint8Array {
|
|
32
|
+
const mii = new Uint8Array(MII_SIZE);
|
|
33
|
+
const dv = new DataView(mii.buffer);
|
|
34
|
+
dv.setUint32(0, MII_SIZE, true);
|
|
35
|
+
dv.setUint32(MII_MASK, MIIM_BITMAP, true);
|
|
36
|
+
dv.setBigUint64(MII_HBMPITEM, hbm, true);
|
|
37
|
+
return mii;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Built = { hmenu: bigint; commands: Map<number, () => void>; bitmaps: bigint[] };
|
|
41
|
+
|
|
42
|
+
function build(template: MenuTemplate): Built {
|
|
43
|
+
const built: Built = { hmenu: user32.CreatePopupMenu(), commands: new Map(), bitmaps: [] };
|
|
44
|
+
if (built.hmenu === 0n) throw new Error("sweetnative: CreatePopupMenu failed");
|
|
45
|
+
try {
|
|
46
|
+
fill(built.hmenu, template, built);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
destroy(built);
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
return built;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function destroy(built: Built): void {
|
|
55
|
+
user32.DestroyMenu(built.hmenu); // takes the submenus with it
|
|
56
|
+
for (const hbm of built.bitmaps) gdi32.DeleteObject(hbm);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fill(hmenu: bigint, template: MenuTemplate, built: Built): void {
|
|
60
|
+
template.forEach((item, position) => {
|
|
61
|
+
if (isSeparator(item)) {
|
|
62
|
+
user32.AppendMenuW(hmenu, MF_SEPARATOR, 0n, null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const grayed = item.enabled === false ? MF_GRAYED : 0;
|
|
66
|
+
const label = wstr(escapeLabel(item.label)); // AppendMenuW copies the text
|
|
67
|
+
if (isSubmenu(item)) {
|
|
68
|
+
const sub = user32.CreatePopupMenu();
|
|
69
|
+
if (sub === 0n) throw new Error("sweetnative: CreatePopupMenu failed");
|
|
70
|
+
fill(sub, item.submenu, built);
|
|
71
|
+
user32.AppendMenuW(hmenu, MF_POPUP | MF_STRING | grayed, sub, label);
|
|
72
|
+
} else {
|
|
73
|
+
const id = built.commands.size + 1; // command ids are 1-based; 0 means "nothing chosen"
|
|
74
|
+
built.commands.set(id, item.click ?? (() => {}));
|
|
75
|
+
const checked = item.checked ? MF_CHECKED : 0;
|
|
76
|
+
user32.AppendMenuW(hmenu, MF_STRING | grayed | checked, BigInt(id), label);
|
|
77
|
+
}
|
|
78
|
+
const ico = item.icon?.windows?.ico;
|
|
79
|
+
if (ico !== undefined) {
|
|
80
|
+
const hbm = iconBitmap(ico);
|
|
81
|
+
if (hbm !== 0n) {
|
|
82
|
+
built.bitmaps.push(hbm);
|
|
83
|
+
if (!user32.SetMenuItemInfoW(hmenu, position, 1, bitmapItemInfo(hbm))) console.error(`sweetnative: SetMenuItemInfoW(hbmpItem) failed (${lastError()})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Shows `template` at the cursor, owned by `owner`, and runs the chosen item's
|
|
91
|
+
* click handler. Blocks until the menu closes. Returns true when an item was chosen.
|
|
92
|
+
*/
|
|
93
|
+
export function popupMenuAtCursor(owner: bigint, template: MenuTemplate): boolean {
|
|
94
|
+
const pt = new Int32Array(2);
|
|
95
|
+
user32.GetCursorPos(pt);
|
|
96
|
+
const built = build(template);
|
|
97
|
+
// The owner must be the foreground window or the menu will not dismiss when
|
|
98
|
+
// the user clicks elsewhere; the WM_NULL afterwards is the documented nudge
|
|
99
|
+
// that makes the menu close correctly next time (KB 135788).
|
|
100
|
+
user32.SetForegroundWindow(owner);
|
|
101
|
+
const chosen = user32.TrackPopupMenu(built.hmenu, TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RETURNCMD, pt[0]!, pt[1]!, 0, owner, null);
|
|
102
|
+
user32.PostMessageW(owner, WM_NULL, 0n, 0n);
|
|
103
|
+
destroy(built);
|
|
104
|
+
const handler = built.commands.get(chosen);
|
|
105
|
+
handler?.();
|
|
106
|
+
return handler !== undefined;
|
|
107
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A never-shown top-level window whose window procedure is a JSCallback.
|
|
2
|
+
// Shell_NotifyIcon posts tray clicks to it, SetTimer posts WM_TIMER to it, and
|
|
3
|
+
// TrackPopupMenu needs an owner window that can take the foreground. It is a
|
|
4
|
+
// real (not message-only) window so it also receives the TaskbarCreated
|
|
5
|
+
// broadcast when Explorer restarts.
|
|
6
|
+
|
|
7
|
+
import { FFIType, JSCallback, ptr } from "bun:ffi";
|
|
8
|
+
import { WM_CLOSE, hInstance, lastError, user32, wstr } from "./api";
|
|
9
|
+
|
|
10
|
+
const CLASS_NAME = "NativeShellMessageWindow";
|
|
11
|
+
const WS_EX_TOOLWINDOW = 0x00000080;
|
|
12
|
+
const WS_OVERLAPPED = 0x00000000;
|
|
13
|
+
const WNDCLASSEXW_SIZE = 80;
|
|
14
|
+
|
|
15
|
+
export type MessageHandler = (message: number, wParam: bigint, lParam: bigint) => void;
|
|
16
|
+
|
|
17
|
+
export type MessageWindow = {
|
|
18
|
+
readonly hwnd: bigint;
|
|
19
|
+
readonly destroy: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const handlers = new Map<bigint, MessageHandler>();
|
|
23
|
+
|
|
24
|
+
// Retained for the process: the registered class calls it until exit.
|
|
25
|
+
let wndProc: JSCallback | undefined;
|
|
26
|
+
|
|
27
|
+
function ensureClass(): void {
|
|
28
|
+
if (wndProc !== undefined) return;
|
|
29
|
+
wndProc = new JSCallback(
|
|
30
|
+
(hwnd: bigint, message: number, wParam: bigint, lParam: bigint): bigint => {
|
|
31
|
+
try {
|
|
32
|
+
handlers.get(hwnd)?.(message, wParam, lParam);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
// Never let an exception unwind into user32.
|
|
35
|
+
console.error("sweetnative: tray handler threw:", err);
|
|
36
|
+
}
|
|
37
|
+
// Only destroy() tears this window down; an external WM_CLOSE is ignored.
|
|
38
|
+
return message === WM_CLOSE ? 0n : user32.DefWindowProcW(hwnd, message, wParam, lParam);
|
|
39
|
+
},
|
|
40
|
+
{ args: [FFIType.u64, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.i64 },
|
|
41
|
+
);
|
|
42
|
+
if (wndProc.ptr === null) throw new Error("sweetnative: could not allocate the window procedure");
|
|
43
|
+
|
|
44
|
+
const wc = new Uint8Array(WNDCLASSEXW_SIZE);
|
|
45
|
+
const dv = new DataView(wc.buffer);
|
|
46
|
+
const className = wstr(CLASS_NAME);
|
|
47
|
+
dv.setUint32(0, WNDCLASSEXW_SIZE, true); // cbSize
|
|
48
|
+
dv.setBigUint64(8, BigInt(wndProc.ptr), true); // lpfnWndProc
|
|
49
|
+
dv.setBigUint64(24, hInstance(), true); // hInstance
|
|
50
|
+
dv.setBigUint64(64, BigInt(ptr(className)), true); // lpszClassName
|
|
51
|
+
if (user32.RegisterClassExW(ptr(wc)) === 0) {
|
|
52
|
+
throw new Error(`sweetnative: RegisterClassExW failed (${lastError()})`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createMessageWindow(handler: MessageHandler): MessageWindow {
|
|
57
|
+
ensureClass();
|
|
58
|
+
const className = wstr(CLASS_NAME);
|
|
59
|
+
const hwnd = user32.CreateWindowExW(WS_EX_TOOLWINDOW, className, null, WS_OVERLAPPED, 0, 0, 0, 0, 0n, 0n, hInstance(), null);
|
|
60
|
+
if (hwnd === 0n) throw new Error(`sweetnative: CreateWindowExW failed (${lastError()})`);
|
|
61
|
+
handlers.set(hwnd, handler);
|
|
62
|
+
let destroyed = false;
|
|
63
|
+
return {
|
|
64
|
+
hwnd,
|
|
65
|
+
destroy() {
|
|
66
|
+
if (destroyed) return;
|
|
67
|
+
destroyed = true;
|
|
68
|
+
handlers.delete(hwnd);
|
|
69
|
+
user32.DestroyWindow(hwnd);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Windows notifications: the tray icon's balloon, which Windows 10/11 renders
|
|
2
|
+
// as a regular toast in the notification centre, with our own icon. Flat C,
|
|
3
|
+
// no WinRT, no AppUserModelID registration. The toast belongs to the tray
|
|
4
|
+
// icon, so the tray must exist first.
|
|
5
|
+
|
|
6
|
+
import type { Notifier, NotifierOptions } from "../notifications";
|
|
7
|
+
import { currentTray } from "./tray";
|
|
8
|
+
|
|
9
|
+
const activeNotifier = new WeakMap<object, symbol>(); // each tray has one balloon click handler; its newest notifier owns it
|
|
10
|
+
|
|
11
|
+
export function createNotifier(options: NotifierOptions = {}): Notifier {
|
|
12
|
+
const tray = currentTray();
|
|
13
|
+
if (!tray) throw new Error("sweetnative: on Windows, create the tray before the notifier (toasts hang off the tray icon)");
|
|
14
|
+
tray.balloon.onClick(options.onClick);
|
|
15
|
+
const me = Symbol("notifier");
|
|
16
|
+
activeNotifier.set(tray, me);
|
|
17
|
+
let disposed = false;
|
|
18
|
+
return {
|
|
19
|
+
notify(n) {
|
|
20
|
+
return !disposed && tray.balloon.show(n);
|
|
21
|
+
},
|
|
22
|
+
dispose() {
|
|
23
|
+
if (disposed) return;
|
|
24
|
+
disposed = true;
|
|
25
|
+
if (activeNotifier.get(tray) === me) tray.balloon.onClick(undefined); // a newer notifier owns the handler otherwise
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|