@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,499 @@
|
|
|
1
|
+
// Overlay on Win32: restyle the HWND in place (WS_POPUP, WS_EX_LAYERED with a
|
|
2
|
+
// colour key, WS_EX_TOOLWINDOW, WS_EX_NOACTIVATE, HWND_TOPMOST). The webview
|
|
3
|
+
// keeps its own window procedure; we only change styles and placement.
|
|
4
|
+
//
|
|
5
|
+
// Click-through by hit regions: the rectangles the page sent are tested
|
|
6
|
+
// against the cursor on a 10 ms SetTimer (and on every update) and the
|
|
7
|
+
// answer flips WS_EX_TRANSPARENT | WS_EX_LAYERED, which the window manager
|
|
8
|
+
// honours across processes. No window region: SetWindowRgn forces a repaint
|
|
9
|
+
// of the window and the browser in it every time it changes, which a scene
|
|
10
|
+
// that changes every frame cannot afford.
|
|
11
|
+
|
|
12
|
+
import { FFIType, JSCallback, ptr } from "bun:ffi";
|
|
13
|
+
import { hitRegions, packRegions } from "../hit-regions";
|
|
14
|
+
import type { HitRefiner, Overlay, OverlayOptions, OverlayWindowOptions, Point, Rect, ResizeEdge } from "../overlay";
|
|
15
|
+
import type { WindowHandle } from "../window";
|
|
16
|
+
import {
|
|
17
|
+
COINIT_APARTMENTTHREADED,
|
|
18
|
+
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
|
|
19
|
+
DWM_BB_BLURREGION,
|
|
20
|
+
DWM_BB_ENABLE,
|
|
21
|
+
GW_CHILD,
|
|
22
|
+
HTCAPTION,
|
|
23
|
+
RPC_E_CHANGED_MODE,
|
|
24
|
+
GWL_EXSTYLE,
|
|
25
|
+
GWL_STYLE,
|
|
26
|
+
HWND_NOTOPMOST,
|
|
27
|
+
HWND_TOPMOST,
|
|
28
|
+
LWA_ALPHA,
|
|
29
|
+
LWA_COLORKEY,
|
|
30
|
+
MONITORINFO_SIZE,
|
|
31
|
+
MONITOR_DEFAULTTONEAREST,
|
|
32
|
+
SM_CXSCREEN,
|
|
33
|
+
SM_CXVIRTUALSCREEN,
|
|
34
|
+
SM_CYSCREEN,
|
|
35
|
+
SM_CYVIRTUALSCREEN,
|
|
36
|
+
SM_XVIRTUALSCREEN,
|
|
37
|
+
SM_YVIRTUALSCREEN,
|
|
38
|
+
SPI_GETWORKAREA,
|
|
39
|
+
SWP_FRAMECHANGED,
|
|
40
|
+
SWP_NOACTIVATE,
|
|
41
|
+
SWP_NOMOVE,
|
|
42
|
+
SWP_NOSIZE,
|
|
43
|
+
SWP_NOZORDER,
|
|
44
|
+
SW_HIDE,
|
|
45
|
+
SW_MINIMIZE,
|
|
46
|
+
SW_SHOW,
|
|
47
|
+
SW_SHOWNOACTIVATE,
|
|
48
|
+
WM_CLOSE,
|
|
49
|
+
WM_NCLBUTTONDOWN,
|
|
50
|
+
WM_DESTROY,
|
|
51
|
+
WM_SIZE,
|
|
52
|
+
WS_EX_APPWINDOW,
|
|
53
|
+
WS_EX_LAYERED,
|
|
54
|
+
WS_EX_NOACTIVATE,
|
|
55
|
+
WS_EX_TOOLWINDOW,
|
|
56
|
+
WS_EX_TRANSPARENT,
|
|
57
|
+
WS_CLIPCHILDREN,
|
|
58
|
+
WS_OVERLAPPEDWINDOW,
|
|
59
|
+
WS_POPUP,
|
|
60
|
+
dwmapi,
|
|
61
|
+
gdi32,
|
|
62
|
+
hInstance,
|
|
63
|
+
lastError,
|
|
64
|
+
ole32,
|
|
65
|
+
user32,
|
|
66
|
+
wstr,
|
|
67
|
+
} from "./api";
|
|
68
|
+
import { setWindowIcon } from "./icon";
|
|
69
|
+
import { createMessageWindow, type MessageWindow } from "./message-window";
|
|
70
|
+
import { asHwnd } from "./window";
|
|
71
|
+
|
|
72
|
+
/** `#rrggbb` -> COLORREF (0x00bbggrr). */
|
|
73
|
+
export const colorRef = (hex: string): number => {
|
|
74
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex);
|
|
75
|
+
if (!m) throw new Error(`sweetnative: colour key must be #rrggbb, got ${hex}`);
|
|
76
|
+
const n = Number.parseInt(m[1]!, 16);
|
|
77
|
+
return ((n & 0xff) << 16) | (n & 0xff00) | ((n >> 16) & 0xff);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const rectFrom = (buf: Int32Array): Rect => ({ x: buf[0]!, y: buf[1]!, width: buf[2]! - buf[0]!, height: buf[3]! - buf[1]! });
|
|
81
|
+
|
|
82
|
+
// ---- resize gestures for frameless windows ---------------------------------------
|
|
83
|
+
|
|
84
|
+
const RESIZE_TIMER_ID = 2n;
|
|
85
|
+
const RESIZE_TIMER_MS = 10;
|
|
86
|
+
const MIN_RESIZE = { width: 160, height: 120 };
|
|
87
|
+
const resizers = new Map<bigint, () => void>(); // hwnd -> one tick of its resize gesture
|
|
88
|
+
let resizeProc: JSCallback | undefined; // retained for the process
|
|
89
|
+
|
|
90
|
+
function ensureResizeProc(): JSCallback {
|
|
91
|
+
resizeProc ??= new JSCallback(
|
|
92
|
+
(hwnd: bigint) => {
|
|
93
|
+
try {
|
|
94
|
+
resizers.get(hwnd)?.();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error("sweetnative: resize gesture threw:", err); // never unwind into user32
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{ args: [FFIType.u64, FFIType.u32, FFIType.u64, FFIType.u32], returns: FFIType.void },
|
|
100
|
+
);
|
|
101
|
+
if (resizeProc.ptr === null) throw new Error("sweetnative: could not allocate the resize timer procedure");
|
|
102
|
+
return resizeProc;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- hit regions: the click-through poll ----------------------------------------
|
|
106
|
+
|
|
107
|
+
const HIT_TIMER_ID = 1n;
|
|
108
|
+
const HIT_TIMER_MS = 10; // USER_TIMER_MINIMUM
|
|
109
|
+
const VK_LBUTTON = 0x01;
|
|
110
|
+
const VK_RBUTTON = 0x02;
|
|
111
|
+
const VK_MBUTTON = 0x04;
|
|
112
|
+
const mouseButtonsDown = (): boolean => [VK_LBUTTON, VK_RBUTTON, VK_MBUTTON].some((vk) => user32.GetAsyncKeyState(vk) < 0);
|
|
113
|
+
|
|
114
|
+
type HitState = {
|
|
115
|
+
packed: Int32Array;
|
|
116
|
+
passThrough: (on: boolean) => void;
|
|
117
|
+
refine?: HitRefiner;
|
|
118
|
+
/** Whether the cursor was inside a polygon at the last tick; entering is when the window turns solid until the refiner answers. */
|
|
119
|
+
inPolygon: boolean;
|
|
120
|
+
/** The last point the refiner was asked about, or undefined once the cursor left the polygons or the polygons changed. */
|
|
121
|
+
asked?: Point;
|
|
122
|
+
askSeq: number;
|
|
123
|
+
};
|
|
124
|
+
const hits = new Map<bigint, HitState>(); // hwnd -> regions, while set
|
|
125
|
+
let timerProc: JSCallback | undefined; // retained for the process: SetTimer calls it until KillTimer
|
|
126
|
+
const hitRect = new Int32Array(4);
|
|
127
|
+
const hitPoint = new Int32Array(2);
|
|
128
|
+
|
|
129
|
+
// TIMERPROC: runs inside the message loop webview.run() spins, on this thread.
|
|
130
|
+
function pollHit(hwnd: bigint) {
|
|
131
|
+
const state = hits.get(hwnd);
|
|
132
|
+
if (!state) return;
|
|
133
|
+
if (mouseButtonsDown()) return; // never flip mid-click or mid-drag
|
|
134
|
+
if (!user32.GetCursorPos(hitPoint) || !user32.GetWindowRect(hwnd, hitRect)) return;
|
|
135
|
+
const x = hitPoint[0]! - hitRect[0]!;
|
|
136
|
+
const y = hitPoint[1]! - hitRect[1]!;
|
|
137
|
+
const inside = x >= 0 && y >= 0 && x < hitRect[2]! - hitRect[0]! && y < hitRect[3]! - hitRect[1]!;
|
|
138
|
+
refineHit(hwnd, state, inside && hitRegions(state.packed, x, y), x, y);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The polygon answer, then the refiner's if there is one and the cursor is inside a polygon. */
|
|
142
|
+
function refineHit(hwnd: bigint, state: HitState, inPolygon: boolean, x: number, y: number) {
|
|
143
|
+
const entered = inPolygon && !state.inPolygon;
|
|
144
|
+
state.inPolygon = inPolygon;
|
|
145
|
+
if (!inPolygon) {
|
|
146
|
+
state.passThrough(true);
|
|
147
|
+
state.asked = undefined;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!state.refine) {
|
|
151
|
+
state.passThrough(false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (entered) state.passThrough(false); // take clicks until the exact answer arrives; a polygon update alone keeps the last answer
|
|
155
|
+
if (state.asked?.x === x && state.asked?.y === y) return; // same spot, same polygons: the answer stands
|
|
156
|
+
state.asked = { x, y };
|
|
157
|
+
const seq = ++state.askSeq;
|
|
158
|
+
state.refine({ x, y }, (solid) => {
|
|
159
|
+
if (hits.get(hwnd) !== state || seq !== state.askSeq || state.asked === undefined) return; // moved on, or the regions were replaced or cleared
|
|
160
|
+
if (mouseButtonsDown()) return; // never flip mid-click or mid-drag; the next tick applies the answer
|
|
161
|
+
state.passThrough(!solid);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ensureTimerProc(): JSCallback {
|
|
166
|
+
timerProc ??= new JSCallback(
|
|
167
|
+
(hwnd: bigint) => {
|
|
168
|
+
try {
|
|
169
|
+
pollHit(hwnd);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error("sweetnative: hit region poll threw:", err); // never unwind into user32
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{ args: [FFIType.u64, FFIType.u32, FFIType.u64, FFIType.u32], returns: FFIType.void },
|
|
175
|
+
);
|
|
176
|
+
if (timerProc.ptr === null) throw new Error("sweetnative: could not allocate the hit region timer procedure");
|
|
177
|
+
return timerProc;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* DwmEnableBlurBehindWindow with an empty region: no blur, but DWM now
|
|
182
|
+
* composes the window's client area with its alpha, so a transparent page
|
|
183
|
+
* background shows the desktop (the same trick tao/Tauri use on Windows).
|
|
184
|
+
*/
|
|
185
|
+
function enableAlphaComposition(hwnd: bigint) {
|
|
186
|
+
const region = gdi32.CreateRectRgn(0, 0, -1, -1);
|
|
187
|
+
// DWM_BLURBEHIND { DWORD dwFlags; BOOL fEnable; HRGN hRgnBlur; BOOL fTransitionOnMaximized; } (x64: 4 + 4 + 8 + 4, padded to 24)
|
|
188
|
+
const bb = new ArrayBuffer(24);
|
|
189
|
+
const view = new DataView(bb);
|
|
190
|
+
view.setUint32(0, DWM_BB_ENABLE | DWM_BB_BLURREGION, true);
|
|
191
|
+
view.setInt32(4, 1, true);
|
|
192
|
+
view.setBigUint64(8, region, true);
|
|
193
|
+
view.setInt32(16, 0, true);
|
|
194
|
+
const hr = dwmapi.DwmEnableBlurBehindWindow(hwnd, new Uint8Array(bb));
|
|
195
|
+
gdi32.DeleteObject(region);
|
|
196
|
+
if (hr < 0) throw new Error(`sweetnative: DwmEnableBlurBehindWindow failed (0x${(hr >>> 0).toString(16)})`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- our own overlay window ---------------------------------------------------
|
|
200
|
+
|
|
201
|
+
const CLASS_NAME = "SweetnativeOverlay";
|
|
202
|
+
const WNDCLASSEXW_SIZE = 80;
|
|
203
|
+
let classRegistered = false;
|
|
204
|
+
let wndProc: JSCallback | undefined; // retained for the process: the class calls it until exit
|
|
205
|
+
const rectScratch = new Int32Array(4);
|
|
206
|
+
|
|
207
|
+
/** The webview's widget child fills our client area, like the library's own window does. */
|
|
208
|
+
function layoutChild(hwnd: bigint) {
|
|
209
|
+
const child = user32.GetWindow(hwnd, GW_CHILD);
|
|
210
|
+
if (child !== 0n && user32.GetClientRect(hwnd, rectScratch)) {
|
|
211
|
+
user32.MoveWindow(child, 0, 0, rectScratch[2]!, rectScratch[3]!, 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function registerClass() {
|
|
216
|
+
if (classRegistered) return;
|
|
217
|
+
wndProc = new JSCallback(
|
|
218
|
+
(hwnd: bigint, message: number, wParam: bigint, lParam: bigint): bigint => {
|
|
219
|
+
if (message === WM_SIZE) {
|
|
220
|
+
layoutChild(hwnd);
|
|
221
|
+
return 0n;
|
|
222
|
+
}
|
|
223
|
+
if (message === WM_CLOSE) {
|
|
224
|
+
user32.ShowWindow(hwnd, SW_HIDE); // a framed window's close button hides it; the app ends on quit
|
|
225
|
+
return 0n;
|
|
226
|
+
}
|
|
227
|
+
if (message === WM_DESTROY) {
|
|
228
|
+
user32.PostQuitMessage(0); // ends webview_run
|
|
229
|
+
return 0n;
|
|
230
|
+
}
|
|
231
|
+
return user32.DefWindowProcW(hwnd, message, wParam, lParam);
|
|
232
|
+
},
|
|
233
|
+
{ args: [FFIType.u64, FFIType.u32, FFIType.u64, FFIType.i64], returns: FFIType.i64 },
|
|
234
|
+
);
|
|
235
|
+
if (wndProc.ptr === null) throw new Error("sweetnative: could not allocate the overlay window procedure");
|
|
236
|
+
const wc = new Uint8Array(WNDCLASSEXW_SIZE);
|
|
237
|
+
const dv = new DataView(wc.buffer);
|
|
238
|
+
const className = wstr(CLASS_NAME);
|
|
239
|
+
dv.setUint32(0, WNDCLASSEXW_SIZE, true); // cbSize
|
|
240
|
+
dv.setBigUint64(8, BigInt(wndProc.ptr), true); // lpfnWndProc
|
|
241
|
+
dv.setBigUint64(24, hInstance(), true); // hInstance
|
|
242
|
+
dv.setBigUint64(64, BigInt(ptr(className)), true); // lpszClassName; hbrBackground stays NULL: nothing paints behind the page
|
|
243
|
+
if (user32.RegisterClassExW(ptr(wc)) === 0) throw new Error(`sweetnative: RegisterClassExW failed (${lastError()})`);
|
|
244
|
+
classRegistered = true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The first top-level window a process creates can never be made topmost:
|
|
248
|
+
// SetWindowPos(HWND_TOPMOST) returns success from any process and leaves
|
|
249
|
+
// WS_EX_TOPMOST off, for that window only, for its whole life (seen on
|
|
250
|
+
// Windows 11 25H2 with a bare overlay, a transparent one, and a framed one;
|
|
251
|
+
// every later window is fine, and destroying the first one does not free the
|
|
252
|
+
// next). So the first window is a hidden one nobody needs, kept for the
|
|
253
|
+
// process. Created after the DPI awareness context is set, which only works
|
|
254
|
+
// before any window exists.
|
|
255
|
+
let anchor: MessageWindow | undefined;
|
|
256
|
+
function ensureFirstWindowIsHidden() {
|
|
257
|
+
anchor ??= createMessageWindow(() => {});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createOverlayWindow(options: OverlayWindowOptions): Overlay {
|
|
261
|
+
// The webview library does these two only for windows it creates itself.
|
|
262
|
+
// WebView2 needs a single-threaded apartment on this thread; without it
|
|
263
|
+
// webview_create fails and returns NULL.
|
|
264
|
+
const hr = ole32.CoInitializeEx(null, COINIT_APARTMENTTHREADED);
|
|
265
|
+
if (hr === RPC_E_CHANGED_MODE) throw new Error("sweetnative: this thread already initialised COM as multi-threaded; WebView2 needs an STA");
|
|
266
|
+
user32.SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); // fails harmlessly if already set
|
|
267
|
+
ensureFirstWindowIsHidden();
|
|
268
|
+
registerClass();
|
|
269
|
+
const { title = "", bounds, frame = false, dark = false } = options;
|
|
270
|
+
const b = bounds ?? defaultBounds();
|
|
271
|
+
const className = wstr(CLASS_NAME);
|
|
272
|
+
const titleW = wstr(title);
|
|
273
|
+
// Created hidden and bare; restyle sets the rest of the styles and the DWM composition
|
|
274
|
+
// before the caller hands it to the webview, which is the order WebView2 needs.
|
|
275
|
+
const style = (frame ? WS_OVERLAPPEDWINDOW : WS_POPUP) | WS_CLIPCHILDREN;
|
|
276
|
+
const hwnd = user32.CreateWindowExW(0, className, titleW, style, b.x, b.y, b.width, b.height, 0n, 0n, hInstance(), null);
|
|
277
|
+
if (hwnd === 0n) throw new Error(`sweetnative: CreateWindowExW failed (${lastError()})`);
|
|
278
|
+
if (frame && dark) {
|
|
279
|
+
// DWMWA_USE_IMMERSIVE_DARK_MODE (20, Windows 11 and 10 2004+); ignored where unsupported.
|
|
280
|
+
dwmapi.DwmSetWindowAttribute(hwnd, 20, new Int32Array([1]), 4);
|
|
281
|
+
}
|
|
282
|
+
if (options.icon?.windows) setWindowIcon(hwnd, options.icon.windows.ico);
|
|
283
|
+
return restyle(hwnd, options, frame);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function defaultBounds(): Rect {
|
|
287
|
+
user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, rectScratch, 0);
|
|
288
|
+
const area = rectFrom(rectScratch);
|
|
289
|
+
const width = 320;
|
|
290
|
+
const height = 240;
|
|
291
|
+
return { x: area.x + Math.round((area.width - width) / 2), y: area.y + Math.round((area.height - height) / 2), width, height };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- restyling ---------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
export function makeOverlay(handle: WindowHandle, options: OverlayOptions): Overlay {
|
|
297
|
+
return restyle(asHwnd(handle), options, false);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The Overlay object for a window; `framed` leaves a normal window's styles alone (see OverlayWindowOptions.frame). */
|
|
301
|
+
function restyle(hwnd: bigint, options: OverlayOptions, framed: boolean): Overlay {
|
|
302
|
+
const { transparent = false, colorKey, opacity, topmost: topmostAtStart = !framed, hideFromTaskbar = !framed, noActivate = !framed } = options;
|
|
303
|
+
let topmost = topmostAtStart; // kept current by setTopmost so moves re-assert the right z-order
|
|
304
|
+
const layered = colorKey !== undefined || opacity !== undefined; // WS_EX_LAYERED is part of this window's look, not just click-through
|
|
305
|
+
|
|
306
|
+
// Styles. Keep WS_VISIBLE and friends; swap the frame for a bare popup.
|
|
307
|
+
if (!framed) {
|
|
308
|
+
const style = user32.GetWindowLongPtrW(hwnd, GWL_STYLE);
|
|
309
|
+
user32.SetWindowLongPtrW(hwnd, GWL_STYLE, (style & ~BigInt(WS_OVERLAPPEDWINDOW)) | BigInt(WS_POPUP) | BigInt(WS_CLIPCHILDREN));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let ex = user32.GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
|
|
313
|
+
if (colorKey !== undefined || opacity !== undefined) ex |= BigInt(WS_EX_LAYERED);
|
|
314
|
+
if (hideFromTaskbar) ex = (ex | BigInt(WS_EX_TOOLWINDOW)) & ~BigInt(WS_EX_APPWINDOW);
|
|
315
|
+
if (noActivate) ex |= BigInt(WS_EX_NOACTIVATE);
|
|
316
|
+
user32.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
|
|
317
|
+
|
|
318
|
+
if (colorKey !== undefined || opacity !== undefined) {
|
|
319
|
+
const flags = (colorKey !== undefined ? LWA_COLORKEY : 0) | (opacity !== undefined ? LWA_ALPHA : 0);
|
|
320
|
+
const alpha = Math.round(Math.min(1, Math.max(0, opacity ?? 1)) * 255);
|
|
321
|
+
if (!user32.SetLayeredWindowAttributes(hwnd, colorKey !== undefined ? colorRef(colorKey) : 0, alpha, flags)) {
|
|
322
|
+
throw new Error("sweetnative: SetLayeredWindowAttributes failed");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (transparent) enableAlphaComposition(hwnd);
|
|
327
|
+
|
|
328
|
+
// Apply the frame change and the z-order in one go.
|
|
329
|
+
user32.SetWindowPos(hwnd, topmost ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED);
|
|
330
|
+
|
|
331
|
+
const rectBuf = new Int32Array(4);
|
|
332
|
+
const pointBuf = new Int32Array(2);
|
|
333
|
+
let restoreBounds: Rect | undefined; // set while maximised by toggleMaximize
|
|
334
|
+
let resizing: { edge: ResizeEdge; from: Rect; cursor: Point } | undefined; // while a startResize gesture is in progress
|
|
335
|
+
resizers.set(hwnd, () => {
|
|
336
|
+
if (!resizing) return;
|
|
337
|
+
if (user32.GetAsyncKeyState(VK_LBUTTON) >= 0) {
|
|
338
|
+
resizing = undefined;
|
|
339
|
+
user32.KillTimer(hwnd, RESIZE_TIMER_ID);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
user32.GetCursorPos(pointBuf);
|
|
343
|
+
const dx = pointBuf[0]! - resizing.cursor.x;
|
|
344
|
+
const dy = pointBuf[1]! - resizing.cursor.y;
|
|
345
|
+
const { edge, from } = resizing;
|
|
346
|
+
let { x, y, width, height } = from;
|
|
347
|
+
if (edge.includes("left")) {
|
|
348
|
+
width = Math.max(MIN_RESIZE.width, from.width - dx);
|
|
349
|
+
x = from.x + from.width - width;
|
|
350
|
+
}
|
|
351
|
+
if (edge.includes("right")) width = Math.max(MIN_RESIZE.width, from.width + dx);
|
|
352
|
+
if (edge.includes("top")) {
|
|
353
|
+
height = Math.max(MIN_RESIZE.height, from.height - dy);
|
|
354
|
+
y = from.y + from.height - height;
|
|
355
|
+
}
|
|
356
|
+
if (edge.includes("bottom")) height = Math.max(MIN_RESIZE.height, from.height + dy);
|
|
357
|
+
if (x !== self.bounds().x || y !== self.bounds().y || width !== self.bounds().width || height !== self.bounds().height) self.setBounds({ x, y, width, height });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// WS_EX_TRANSPARENT alone only hides the window from its own thread's hit
|
|
361
|
+
// testing; with WS_EX_LAYERED the window manager passes input through to
|
|
362
|
+
// other processes' windows (what tao and Electron set). A window that is
|
|
363
|
+
// layered for its colour key or opacity keeps that bit either way. Setting
|
|
364
|
+
// the same bits again is skipped: SetWindowLongPtr is not free.
|
|
365
|
+
const passBits = BigInt(WS_EX_TRANSPARENT) | (layered ? 0n : BigInt(WS_EX_LAYERED));
|
|
366
|
+
let passing: boolean | undefined;
|
|
367
|
+
const passThrough = (on: boolean) => {
|
|
368
|
+
if (passing === on) return;
|
|
369
|
+
passing = on;
|
|
370
|
+
const cur = user32.GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
|
|
371
|
+
const next = on ? cur | passBits : cur & ~passBits;
|
|
372
|
+
if (next !== cur) user32.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, next); // leaves WS_EX_TOPMOST as it is
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const self: Overlay = {
|
|
376
|
+
handle: hwnd,
|
|
377
|
+
setIcon(icon) {
|
|
378
|
+
setWindowIcon(hwnd, icon?.windows?.ico);
|
|
379
|
+
},
|
|
380
|
+
show() {
|
|
381
|
+
// The webview's child is created 0x0 and only follows WM_SIZE; the first show lays it out.
|
|
382
|
+
layoutChild(hwnd);
|
|
383
|
+
user32.ShowWindow(hwnd, noActivate ? SW_SHOWNOACTIVATE : SW_SHOW);
|
|
384
|
+
if (!noActivate && !topmost) {
|
|
385
|
+
// A process without the foreground right (a GUI exe nobody has clicked yet)
|
|
386
|
+
// gets its window shown behind the active one; a topmost round trip raises it
|
|
387
|
+
// regardless, and SetForegroundWindow then succeeds whenever it is allowed to.
|
|
388
|
+
user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
|
389
|
+
user32.SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
|
390
|
+
user32.SetForegroundWindow(hwnd);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
hide() {
|
|
394
|
+
user32.ShowWindow(hwnd, SW_HIDE);
|
|
395
|
+
},
|
|
396
|
+
bounds() {
|
|
397
|
+
user32.GetWindowRect(hwnd, rectBuf);
|
|
398
|
+
return rectFrom(rectBuf);
|
|
399
|
+
},
|
|
400
|
+
moveTo(x, y) {
|
|
401
|
+
// Re-assert the z-order with every move: the taskbar is topmost too and wins ties otherwise.
|
|
402
|
+
user32.SetWindowPos(hwnd, topmost ? HWND_TOPMOST : 0n, Math.round(x), Math.round(y), 0, 0, SWP_NOSIZE | (topmost ? 0 : SWP_NOZORDER) | SWP_NOACTIVATE);
|
|
403
|
+
},
|
|
404
|
+
resize(width, height) {
|
|
405
|
+
user32.SetWindowPos(hwnd, 0n, 0, 0, Math.round(width), Math.round(height), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
|
|
406
|
+
},
|
|
407
|
+
setBounds(r) {
|
|
408
|
+
user32.SetWindowPos(hwnd, topmost ? HWND_TOPMOST : 0n, Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height), (topmost ? 0 : SWP_NOZORDER) | SWP_NOACTIVATE);
|
|
409
|
+
},
|
|
410
|
+
setClickThrough(on) {
|
|
411
|
+
hits.delete(hwnd); // whole-window mode wins over the regions
|
|
412
|
+
user32.KillTimer(hwnd, HIT_TIMER_ID);
|
|
413
|
+
passThrough(on);
|
|
414
|
+
},
|
|
415
|
+
setHitRegions(polygons, refine) {
|
|
416
|
+
if (polygons === null) {
|
|
417
|
+
hits.delete(hwnd);
|
|
418
|
+
user32.KillTimer(hwnd, HIT_TIMER_ID);
|
|
419
|
+
passThrough(false);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const previous = hits.get(hwnd);
|
|
423
|
+
hits.set(hwnd, { packed: packRegions(polygons), passThrough, refine, inPolygon: previous?.inPolygon ?? false, askSeq: previous?.askSeq ?? 0 });
|
|
424
|
+
if (!previous && user32.SetTimer(hwnd, HIT_TIMER_ID, HIT_TIMER_MS, ensureTimerProc().ptr) === 0n) {
|
|
425
|
+
throw new Error(`sweetnative: SetTimer failed (${lastError()})`);
|
|
426
|
+
}
|
|
427
|
+
pollHit(hwnd); // things moved under a still cursor: the polygons are new, so the refiner is asked again
|
|
428
|
+
},
|
|
429
|
+
setTopmost(on) {
|
|
430
|
+
topmost = on;
|
|
431
|
+
user32.SetWindowPos(hwnd, on ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
|
432
|
+
},
|
|
433
|
+
startDrag() {
|
|
434
|
+
// Give the gesture to the window manager: it moves the window until the button comes up.
|
|
435
|
+
user32.ReleaseCapture();
|
|
436
|
+
user32.SendMessageW(hwnd, WM_NCLBUTTONDOWN, BigInt(HTCAPTION), 0n);
|
|
437
|
+
},
|
|
438
|
+
startResize(edge) {
|
|
439
|
+
// Done here rather than by the OS: the size loop needs WS_THICKFRAME, which brings a
|
|
440
|
+
// visible border and the library's frame arithmetic with it. The cursor's travel
|
|
441
|
+
// since the press becomes the window's new bounds on every tick while the button is down.
|
|
442
|
+
user32.GetCursorPos(pointBuf);
|
|
443
|
+
resizing = { edge, from: self.bounds(), cursor: { x: pointBuf[0]!, y: pointBuf[1]! } };
|
|
444
|
+
if (user32.SetTimer(hwnd, RESIZE_TIMER_ID, RESIZE_TIMER_MS, ensureResizeProc().ptr) === 0n) throw new Error(`sweetnative: SetTimer failed (${lastError()})`);
|
|
445
|
+
},
|
|
446
|
+
minimize() {
|
|
447
|
+
user32.ShowWindow(hwnd, SW_MINIMIZE);
|
|
448
|
+
},
|
|
449
|
+
toggleMaximize() {
|
|
450
|
+
// Done by hand: a popup without a caption maximises to the whole monitor, taskbar and all.
|
|
451
|
+
if (restoreBounds) {
|
|
452
|
+
self.setBounds(restoreBounds);
|
|
453
|
+
restoreBounds = undefined;
|
|
454
|
+
} else {
|
|
455
|
+
restoreBounds = self.bounds();
|
|
456
|
+
self.setBounds(self.workArea());
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
maximized() {
|
|
460
|
+
return restoreBounds !== undefined;
|
|
461
|
+
},
|
|
462
|
+
cursor(): Point {
|
|
463
|
+
user32.GetCursorPos(pointBuf);
|
|
464
|
+
return { x: pointBuf[0]!, y: pointBuf[1]! };
|
|
465
|
+
},
|
|
466
|
+
screen() {
|
|
467
|
+
return { x: 0, y: 0, width: user32.GetSystemMetrics(SM_CXSCREEN), height: user32.GetSystemMetrics(SM_CYSCREEN) };
|
|
468
|
+
},
|
|
469
|
+
screenAt(point) {
|
|
470
|
+
// A 1x1 RECT at the point: MonitorFromRect takes a pointer, MonitorFromPoint a POINT by value, which bun:ffi cannot pass.
|
|
471
|
+
rectBuf[0] = Math.round(point.x);
|
|
472
|
+
rectBuf[1] = Math.round(point.y);
|
|
473
|
+
rectBuf[2] = rectBuf[0]! + 1;
|
|
474
|
+
rectBuf[3] = rectBuf[1]! + 1;
|
|
475
|
+
const monitor = user32.MonitorFromRect(rectBuf, MONITOR_DEFAULTTONEAREST);
|
|
476
|
+
const info = new Int32Array(MONITORINFO_SIZE / 4);
|
|
477
|
+
info[0] = MONITORINFO_SIZE;
|
|
478
|
+
if (monitor === 0n || user32.GetMonitorInfoW(monitor, info) === 0) return self.screen();
|
|
479
|
+
return rectFrom(info.subarray(1, 5)); // rcMonitor
|
|
480
|
+
},
|
|
481
|
+
workArea() {
|
|
482
|
+
user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, rectBuf, 0);
|
|
483
|
+
return rectFrom(rectBuf);
|
|
484
|
+
},
|
|
485
|
+
scale() {
|
|
486
|
+
const dpi = user32.GetDpiForWindow(hwnd);
|
|
487
|
+
return dpi === 0 ? 1 : dpi / 96;
|
|
488
|
+
},
|
|
489
|
+
virtualScreen() {
|
|
490
|
+
return {
|
|
491
|
+
x: user32.GetSystemMetrics(SM_XVIRTUALSCREEN),
|
|
492
|
+
y: user32.GetSystemMetrics(SM_YVIRTUALSCREEN),
|
|
493
|
+
width: user32.GetSystemMetrics(SM_CXVIRTUALSCREEN),
|
|
494
|
+
height: user32.GetSystemMetrics(SM_CYVIRTUALSCREEN),
|
|
495
|
+
};
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
return self;
|
|
499
|
+
}
|