@n1h/sweetview 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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-0BSD +12 -0
  3. package/README.md +268 -3
  4. package/docs/README.md +26 -0
  5. package/docs/app.md +523 -0
  6. package/docs/build.md +169 -0
  7. package/docs/ipc-ws.md +202 -0
  8. package/docs/ipc.md +323 -0
  9. package/docs/native.md +1066 -0
  10. package/docs/page.md +71 -0
  11. package/docs/settings-store.md +125 -0
  12. package/docs/webview-library.md +104 -0
  13. package/docs/window-chrome.md +221 -0
  14. package/docs/worker.md +72 -0
  15. package/file-imports.d.ts +19 -0
  16. package/package.json +66 -4
  17. package/src/app.ts +350 -0
  18. package/src/build.ts +118 -0
  19. package/src/html.ts +71 -0
  20. package/src/index.ts +8 -0
  21. package/src/ipc/bridge.ts +41 -0
  22. package/src/ipc/frame.ts +16 -0
  23. package/src/ipc/index.ts +16 -0
  24. package/src/ipc/memory.ts +33 -0
  25. package/src/ipc/service.ts +75 -0
  26. package/src/ipc-ws/client.ts +111 -0
  27. package/src/ipc-ws/index.ts +5 -0
  28. package/src/ipc-ws/server.ts +133 -0
  29. package/src/make-app.ts +93 -0
  30. package/src/messages.ts +16 -0
  31. package/src/native/app-icon.ts +23 -0
  32. package/src/native/app-menu.ts +18 -0
  33. package/src/native/darwin/app-icon.ts +10 -0
  34. package/src/native/darwin/app-menu.ts +88 -0
  35. package/src/native/darwin/folder-picker.ts +29 -0
  36. package/src/native/darwin/image.ts +47 -0
  37. package/src/native/darwin/menu.ts +89 -0
  38. package/src/native/darwin/notifier.ts +95 -0
  39. package/src/native/darwin/objc.ts +246 -0
  40. package/src/native/darwin/overlay.ts +352 -0
  41. package/src/native/darwin/tray.ts +129 -0
  42. package/src/native/darwin/window.ts +64 -0
  43. package/src/native/folder-picker.ts +15 -0
  44. package/src/native/hit-regions.ts +162 -0
  45. package/src/native/icon.ts +530 -0
  46. package/src/native/index.ts +38 -0
  47. package/src/native/menu.ts +43 -0
  48. package/src/native/notifications.ts +40 -0
  49. package/src/native/overlay.ts +182 -0
  50. package/src/native/platform.ts +25 -0
  51. package/src/native/tray.ts +41 -0
  52. package/src/native/win32/api.ts +234 -0
  53. package/src/native/win32/app-icon.ts +5 -0
  54. package/src/native/win32/app-menu.ts +4 -0
  55. package/src/native/win32/folder-picker.ts +121 -0
  56. package/src/native/win32/icon.ts +85 -0
  57. package/src/native/win32/menu.ts +107 -0
  58. package/src/native/win32/message-window.ts +72 -0
  59. package/src/native/win32/notifier.ts +28 -0
  60. package/src/native/win32/overlay.ts +499 -0
  61. package/src/native/win32/tray.ts +228 -0
  62. package/src/native/win32/window.ts +77 -0
  63. package/src/native/window.ts +40 -0
  64. package/src/page.ts +48 -0
  65. package/src/ring.ts +110 -0
  66. package/src/settings-store.ts +101 -0
  67. package/src/webview-lib.ts +60 -0
  68. package/src/window-chrome.ts +118 -0
  69. package/src/worker.ts +72 -0
@@ -0,0 +1,352 @@
1
+ // Overlay on macOS: a borderless NSWindow that is not opaque with a clear
2
+ // background, above the Dock (status window level), on every Space. Units are points (what
3
+ // the page sees as CSS pixels), with the origin at the top-left of the
4
+ // primary screen like the Win32 side; Cocoa's bottom-left origin is converted
5
+ // here.
6
+ //
7
+ // Click-through by hit regions. AppKit hit-tests a borderless window by its
8
+ // alpha only as long as ignoresMouseEvents was never set, and macOS 26.3
9
+ // broke even that, so it is done the way Electron, tao and Cap do it: a
10
+ // main-thread NSTimer tests the cursor against the rectangles the page sent
11
+ // and toggles ignoresMouseEvents. While a mouse button is down nothing
12
+ // changes, so a drag survives leaving the rectangles. The cursor comes from
13
+ // +[NSEvent mouseLocation] through an NSInvocation (a struct return, which
14
+ // bun:ffi cannot receive directly); the webview library reads struct returns
15
+ // the same way.
16
+
17
+ import { hitRegions, packRegions } from "../hit-regions";
18
+ import type { HitRefiner, Overlay, OverlayOptions, OverlayWindowOptions, Point, Rect } from "../overlay";
19
+ import type { WindowHandle } from "../window";
20
+ import {
21
+ NIL,
22
+ allocRaw,
23
+ asId,
24
+ cls,
25
+ defineYesClass,
26
+ makeTarget,
27
+ nsString,
28
+ send,
29
+ sendBool,
30
+ sendBytesLen,
31
+ sendF64PtrSelPtrBool,
32
+ sendF64Void,
33
+ sendInt,
34
+ sendIntArg,
35
+ sendPoint,
36
+ sendPtr,
37
+ sendPtrArg,
38
+ sendRectBool,
39
+ sendRectUIntUIntBool,
40
+ sendUIntArg,
41
+ sendUIntRetPtr,
42
+ sel,
43
+ sharedApp,
44
+ type Id,
45
+ } from "./objc";
46
+ import { asNSWindow } from "./window";
47
+
48
+ const NSWindowStyleMaskBorderless = 0n;
49
+ const NSWindowStyleMaskTitled = 1n;
50
+ const NSWindowStyleMaskClosable = 2n;
51
+ const NSWindowStyleMaskMiniaturizable = 4n;
52
+ const NSWindowStyleMaskResizable = 8n;
53
+ const NSBackingStoreBuffered = 2n;
54
+ const NSNormalWindowLevel = 0n;
55
+ // Above the Dock (level 20), so an overlay along the bottom of the screen is not hidden behind it.
56
+ const NSStatusWindowLevel = 25n;
57
+ const NSWindowCollectionBehaviorCanJoinAllSpaces = 1n << 0n;
58
+ const NSWindowCollectionBehaviorStationary = 1n << 4n;
59
+ const NSWindowCollectionBehaviorIgnoresCycle = 1n << 6n;
60
+ const NSWindowCollectionBehaviorFullScreenAuxiliary = 1n << 8n;
61
+ const NSApplicationActivationPolicyAccessory = 1n;
62
+
63
+ // ---- rects out of Cocoa without struct returns -------------------------------
64
+
65
+ type CocoaRect = { x: number; y: number; w: number; h: number };
66
+ const rectBuf = new Float64Array(4);
67
+
68
+ /** `[obj valueForKey:key]` boxes a struct-returning property into an NSValue; `getValue:size:` copies it out. */
69
+ function rectOf(obj: Id, key: string): CocoaRect {
70
+ const value = asId(sendPtr(obj, sel("valueForKey:"), nsString(key)));
71
+ if (value === NIL) throw new Error(`sweetnative: ${key} unavailable`);
72
+ rectBuf.fill(0);
73
+ sendBytesLen(value, sel("getValue:size:"), rectBuf, 32n);
74
+ return { x: rectBuf[0]!, y: rectBuf[1]!, w: rectBuf[2]!, h: rectBuf[3]! };
75
+ }
76
+
77
+ const primaryScreen = (): Id => asId(sendUIntRetPtr(asId(send(cls("NSScreen"), sel("screens"))), sel("objectAtIndex:"), 0n));
78
+ const primaryHeight = (): number => rectOf(primaryScreen(), "frame").h;
79
+
80
+ /**
81
+ * Cursor in our coordinates. +[NSEvent mouseLocation] returns an NSPoint by
82
+ * value, which bun:ffi cannot receive, so the call goes through an
83
+ * NSInvocation and the result is copied out with getReturnValue:.
84
+ */
85
+ const pointBuf = new Float64Array(2);
86
+ let cursorInvocation: Id = NIL;
87
+ let warnedNoCursor = false;
88
+ function cursorPoint(): Point | null {
89
+ if (cursorInvocation === NIL) {
90
+ const eventClass = cls("NSEvent");
91
+ const selector = sel("mouseLocation");
92
+ const signature = asId(sendPtr(eventClass, sel("methodSignatureForSelector:"), selector));
93
+ const invocation = signature === NIL ? NIL : asId(sendPtr(cls("NSInvocation"), sel("invocationWithMethodSignature:"), signature));
94
+ if (invocation === NIL) {
95
+ if (!warnedNoCursor) console.error("sweetnative: cannot build the NSInvocation for +[NSEvent mouseLocation]; hit regions will not work");
96
+ warnedNoCursor = true;
97
+ return null;
98
+ }
99
+ sendPtr(invocation, sel("setSelector:"), selector);
100
+ sendPtr(invocation, sel("setTarget:"), eventClass);
101
+ send(invocation, sel("retain")); // the invocation is autoreleased; keep it for the process
102
+ cursorInvocation = invocation;
103
+ }
104
+ send(cursorInvocation, sel("invoke"));
105
+ pointBuf.fill(0);
106
+ sendPtrArg(cursorInvocation, sel("getReturnValue:"), pointBuf);
107
+ return { x: pointBuf[0]!, y: primaryHeight() - pointBuf[1]! };
108
+ }
109
+
110
+ const mouseButtonsDown = (): boolean => BigInt(sendInt(cls("NSEvent"), sel("pressedMouseButtons")) ?? 0) !== 0n;
111
+
112
+ // ---- hit regions: the click-through poll ----------------------------------------
113
+
114
+ type HitState = { packed: Int32Array; refine?: HitRefiner; inPolygon: boolean; asked?: Point; askSeq: number };
115
+ const hits = new Map<Id, HitState>(); // window -> regions, while set
116
+ const ignoring = new Map<Id, boolean>();
117
+ let timer: Id = NIL;
118
+ let tickTarget: Id = NIL;
119
+
120
+ function setIgnoring(win: Id, on: boolean) {
121
+ if (ignoring.get(win) === on) return;
122
+ ignoring.set(win, on);
123
+ sendBool(win, sel("setIgnoresMouseEvents:"), on);
124
+ }
125
+
126
+ function pollHits() {
127
+ if (hits.size === 0) return;
128
+ if (mouseButtonsDown()) return; // never flip mid-click or mid-drag
129
+ const cursor = cursorPoint();
130
+ if (!cursor) return;
131
+ for (const [win, state] of hits) {
132
+ const r = fromCocoa(rectOf(win, "frame"));
133
+ const x = Math.floor(cursor.x - r.x);
134
+ const y = Math.floor(cursor.y - r.y);
135
+ const inside = x >= 0 && y >= 0 && x < r.width && y < r.height;
136
+ refineHit(win, state, inside && hitRegions(state.packed, x, y), x, y);
137
+ }
138
+ }
139
+
140
+ /** The polygon answer, then the refiner's if there is one and the cursor is inside a polygon. */
141
+ function refineHit(win: Id, state: HitState, inPolygon: boolean, x: number, y: number) {
142
+ const entered = inPolygon && !state.inPolygon;
143
+ state.inPolygon = inPolygon;
144
+ if (!inPolygon) {
145
+ setIgnoring(win, true);
146
+ state.asked = undefined;
147
+ return;
148
+ }
149
+ if (!state.refine) {
150
+ setIgnoring(win, false);
151
+ return;
152
+ }
153
+ if (entered) setIgnoring(win, false); // take clicks until the exact answer arrives; a polygon update alone keeps the last answer
154
+ if (state.asked?.x === x && state.asked?.y === y) return;
155
+ state.asked = { x, y };
156
+ const seq = ++state.askSeq;
157
+ state.refine({ x, y }, (solid) => {
158
+ if (hits.get(win) !== state || seq !== state.askSeq || state.asked === undefined) return; // moved on, or the regions were replaced or cleared
159
+ if (mouseButtonsDown()) return; // never flip mid-click or mid-drag; the next tick applies the answer
160
+ setIgnoring(win, !solid);
161
+ });
162
+ }
163
+
164
+ function ensureTimer() {
165
+ if (timer !== NIL) return;
166
+ tickTarget = makeTarget("SweetnativeOverlayHitTimer", { "tick:": () => pollHits() });
167
+ // Runs on the main run loop, which is the one webview.run() spins.
168
+ timer = asId(sendF64PtrSelPtrBool(cls("NSTimer"), sel("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"), 0.01, tickTarget, sel("tick:"), NIL, true));
169
+ if (timer === NIL) throw new Error("sweetnative: NSTimer for the hit regions failed");
170
+ }
171
+
172
+ /** Cocoa (bottom-left origin) -> ours (top-left origin). */
173
+ const fromCocoa = (r: CocoaRect): Rect => ({ x: r.x, y: primaryHeight() - (r.y + r.h), width: r.w, height: r.h });
174
+ /** Ours -> Cocoa. */
175
+ const toCocoa = (r: Rect): CocoaRect => ({ x: r.x, y: primaryHeight() - (r.y + r.height), w: r.width, h: r.height });
176
+
177
+ // ---- our own overlay window ---------------------------------------------------
178
+
179
+ function defaultBounds(): Rect {
180
+ const area = fromCocoa(rectOf(primaryScreen(), "visibleFrame"));
181
+ const width = 320;
182
+ const height = 240;
183
+ return { x: area.x + Math.round((area.width - width) / 2), y: area.y + Math.round((area.height - height) / 2), width, height };
184
+ }
185
+
186
+ export function createOverlayWindow(options: OverlayWindowOptions): Overlay {
187
+ sharedApp(); // NSApp must exist before any window
188
+ const b = toCocoa(options.bounds ?? defaultBounds());
189
+ const framed = options.frame === true;
190
+ const styleMask = framed ? NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable : NSWindowStyleMaskBorderless;
191
+ // A borderless NSWindow answers NO to canBecomeKeyWindow, so a frameless
192
+ // window that draws its own chrome (noActivate: false) would never take
193
+ // keyboard input: its text fields could be clicked but not typed into. That
194
+ // one gets a subclass that says YES.
195
+ const activatable = !framed && options.noActivate === false;
196
+ if (activatable) defineYesClass("NSWindow", KEY_WINDOW_CLASS, ["canBecomeKeyWindow", "canBecomeMainWindow"]);
197
+ const win = asId(sendRectUIntUIntBool(allocRaw(activatable ? KEY_WINDOW_CLASS : "NSWindow"), sel("initWithContentRect:styleMask:backing:defer:"), b.x, b.y, b.w, b.h, styleMask, NSBackingStoreBuffered, false));
198
+ if (win === NIL) throw new Error("sweetnative: NSWindow init failed");
199
+ sendBool(win, sel("setReleasedWhenClosed:"), false); // the close button only orders the window out
200
+ if (options.title) sendPtr(win, sel("setTitle:"), nsString(options.title));
201
+ if (framed && options.dark) {
202
+ sendPtr(win, sel("setAppearance:"), asId(sendPtr(cls("NSAppearance"), sel("appearanceNamed:"), nsString("NSAppearanceNameDarkAqua"))));
203
+ }
204
+ return restyle(win, options, framed);
205
+ }
206
+
207
+ // ---- restyling ---------------------------------------------------------------
208
+
209
+ export function makeOverlay(handle: WindowHandle, options: OverlayOptions): Overlay {
210
+ return restyle(asNSWindow(handle), options, false);
211
+ }
212
+
213
+ const KEY_WINDOW_CLASS = "SweetnativeKeyWindow";
214
+
215
+ /** The Overlay object for a window; `framed` leaves a normal window's styles alone (see OverlayWindowOptions.frame). */
216
+ function restyle(win: Id, options: OverlayOptions, framed: boolean): Overlay {
217
+ // noActivate (the default) needs nothing here: a borderless NSWindow never becomes key, so clicks do not steal focus.
218
+ // noActivate: false is honoured only by createOverlayWindow, which makes the window of a class that can become key; here it decides how show() brings it up.
219
+ const { transparent = false, colorKey, opacity, topmost = true, hideFromTaskbar = true, noActivate = true } = options;
220
+
221
+ if (!framed) {
222
+ sendUIntArg(win, sel("setStyleMask:"), NSWindowStyleMaskBorderless);
223
+ sendBool(win, sel("setHasShadow:"), false);
224
+ if (transparent) {
225
+ sendBool(win, sel("setOpaque:"), false);
226
+ sendPtr(win, sel("setBackgroundColor:"), asId(send(cls("NSColor"), sel("clearColor"))));
227
+ }
228
+ if (colorKey !== undefined) console.warn("sweetnative: colorKey is Windows-only; use transparent on macOS");
229
+ if (opacity !== undefined) sendF64Void(win, sel("setAlphaValue:"), Math.min(1, Math.max(0, opacity)));
230
+ sendIntArg(win, sel("setLevel:"), topmost ? NSStatusWindowLevel : NSNormalWindowLevel);
231
+ sendUIntArg(
232
+ win,
233
+ sel("setCollectionBehavior:"),
234
+ NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorStationary | NSWindowCollectionBehaviorIgnoresCycle | NSWindowCollectionBehaviorFullScreenAuxiliary,
235
+ );
236
+ // No Dock icon or app switcher entry: an accessory app. The menu bar item (tray) still works.
237
+ if (hideFromTaskbar) sendIntArg(sharedApp(), sel("setActivationPolicy:"), NSApplicationActivationPolicyAccessory);
238
+ }
239
+
240
+ const self: Overlay = {
241
+ handle: win,
242
+ setIcon() {
243
+ // A Cocoa window has no icon of its own; setAppIcon covers the Dock.
244
+ },
245
+ show() {
246
+ if (framed || !noActivate) {
247
+ sendPtr(win, sel("makeKeyAndOrderFront:"), NIL);
248
+ sendBool(sharedApp(), sel("activateIgnoringOtherApps:"), true);
249
+ } else {
250
+ sendPtr(win, sel("orderFrontRegardless"), NIL);
251
+ }
252
+ },
253
+ hide() {
254
+ sendPtr(win, sel("orderOut:"), NIL);
255
+ },
256
+ bounds() {
257
+ return fromCocoa(rectOf(win, "frame"));
258
+ },
259
+ moveTo(x, y) {
260
+ const { height } = self.bounds();
261
+ sendPoint(win, sel("setFrameOrigin:"), x, primaryHeight() - (y + height));
262
+ },
263
+ resize(width, height) {
264
+ const { x, y } = self.bounds();
265
+ self.setBounds({ x, y, width, height });
266
+ },
267
+ setBounds(r) {
268
+ const c = toCocoa(r);
269
+ sendRectBool(win, sel("setFrame:display:"), c.x, c.y, c.w, c.h, true);
270
+ },
271
+ setClickThrough(on) {
272
+ hits.delete(win); // whole-window mode wins over the regions
273
+ setIgnoring(win, on);
274
+ },
275
+ setHitRegions(polygons, refine) {
276
+ if (polygons === null) {
277
+ hits.delete(win);
278
+ setIgnoring(win, false);
279
+ return;
280
+ }
281
+ const previous = hits.get(win);
282
+ hits.set(win, { packed: packRegions(polygons), refine, inPolygon: previous?.inPolygon ?? false, askSeq: previous?.askSeq ?? 0 });
283
+ ensureTimer();
284
+ pollHits(); // things moved under a still cursor: the polygons are new, so the refiner is asked again
285
+ },
286
+ setTopmost(on) {
287
+ sendIntArg(win, sel("setLevel:"), on ? NSStatusWindowLevel : NSNormalWindowLevel);
288
+ },
289
+ startDrag() {
290
+ // The mouse-down that the page is reacting to is still the current event.
291
+ const event = asId(send(sharedApp(), sel("currentEvent")));
292
+ if (event !== NIL) sendPtr(win, sel("performWindowDragWithEvent:"), event);
293
+ },
294
+ startResize() {
295
+ // Nothing to start: a frameless macOS overlay has no resizable style mask, so the user cannot resize it.
296
+ },
297
+ minimize() {
298
+ sendPtr(win, sel("miniaturize:"), NIL);
299
+ },
300
+ toggleMaximize() {
301
+ sendPtr(win, sel("zoom:"), NIL);
302
+ },
303
+ maximized() {
304
+ return BigInt(sendInt(win, sel("isZoomed")) ?? 0) !== 0n;
305
+ },
306
+ cursor(): Point {
307
+ return cursorPoint() ?? { x: 0, y: 0 };
308
+ },
309
+ screen() {
310
+ return fromCocoa(rectOf(primaryScreen(), "frame"));
311
+ },
312
+ screenAt(point) {
313
+ // The screen whose frame holds the point; otherwise the one whose frame is nearest to it.
314
+ const screens = asId(send(cls("NSScreen"), sel("screens")));
315
+ const count = Number(sendInt(screens, sel("count")));
316
+ let nearest: Rect | undefined;
317
+ let nearestDistance = Infinity;
318
+ for (let i = 0; i < count; i++) {
319
+ const r = fromCocoa(rectOf(asId(sendUIntRetPtr(screens, sel("objectAtIndex:"), BigInt(i))), "frame"));
320
+ const dx = Math.max(r.x - point.x, 0, point.x - (r.x + r.width));
321
+ const dy = Math.max(r.y - point.y, 0, point.y - (r.y + r.height));
322
+ const distance = dx * dx + dy * dy;
323
+ if (distance < nearestDistance) {
324
+ nearestDistance = distance;
325
+ nearest = r;
326
+ }
327
+ }
328
+ return nearest ?? self.screen();
329
+ },
330
+ workArea() {
331
+ return fromCocoa(rectOf(primaryScreen(), "visibleFrame"));
332
+ },
333
+ virtualScreen() {
334
+ const screens = asId(send(cls("NSScreen"), sel("screens")));
335
+ const count = Number(sendInt(screens, sel("count")));
336
+ let minX = Infinity;
337
+ let minY = Infinity;
338
+ let maxX = -Infinity;
339
+ let maxY = -Infinity;
340
+ for (let i = 0; i < count; i++) {
341
+ const r = fromCocoa(rectOf(asId(sendUIntRetPtr(screens, sel("objectAtIndex:"), BigInt(i))), "frame"));
342
+ minX = Math.min(minX, r.x);
343
+ minY = Math.min(minY, r.y);
344
+ maxX = Math.max(maxX, r.x + r.width);
345
+ maxY = Math.max(maxY, r.y + r.height);
346
+ }
347
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
348
+ },
349
+ scale: () => 1,
350
+ };
351
+ return self;
352
+ }
@@ -0,0 +1,129 @@
1
+ // Menu bar status item (NSStatusItem). As on every Mac menu bar item, any
2
+ // click (left, right, ctrl+click) opens the menu when there is one. Only when
3
+ // no menu is set do clicks reach the app: left double click fires
4
+ // onDoubleClick; a left click not followed by a second within
5
+ // NSEvent.doubleClickInterval fires onClick. That delay runs on the Cocoa run
6
+ // loop (performSelector:withObject:afterDelay:), not on a JS timer, because
7
+ // Bun's loop is blocked while the webview runs.
8
+ //
9
+ // The menu is attached only for the duration of the click (setMenu:,
10
+ // performClick:, setMenu:nil) so it is rebuilt from current state each time.
11
+
12
+ import { resolveMenu, type MenuSource } from "../menu";
13
+ import type { Tray, TrayIcon, TrayOptions } from "../tray";
14
+ import { applyImage, makeImage } from "./image";
15
+ import { type BuiltMenu, buildMenu } from "./menu";
16
+ import {
17
+ NIL,
18
+ asId,
19
+ cls,
20
+ makeTarget,
21
+ nsString,
22
+ release,
23
+ retain,
24
+ sel,
25
+ send,
26
+ sendF64,
27
+ sendInt,
28
+ sendPtr,
29
+ sendPtrSelPtrVoid,
30
+ sendRetF64,
31
+ sendSelPtrF64,
32
+ sendUInt,
33
+ sharedApp,
34
+ } from "./objc";
35
+
36
+ const NSVariableStatusItemLength = -1;
37
+ const NSEventTypeLeftMouseUp = 2n;
38
+ const NSEventTypeRightMouseUp = 4n;
39
+ const NSEventMaskLeftMouseUp = 1n << NSEventTypeLeftMouseUp;
40
+ const NSEventMaskRightMouseUp = 1n << NSEventTypeRightMouseUp;
41
+ const NSEventModifierFlagControl = 1n << 18n;
42
+
43
+ let nextUid = 1;
44
+
45
+ /** Menu bar images are 18x18 points; the PNG's pixels (36 for Retina) are mapped onto that. */
46
+ const MENU_BAR_POINTS = 18;
47
+
48
+ export function createTray(options: TrayOptions): Tray {
49
+ const uid = nextUid++;
50
+ const statusBar = asId(send(cls("NSStatusBar"), sel("systemStatusBar")));
51
+ // statusItemWithLength: returns +0; keep our own reference until dispose.
52
+ const item = retain(asId(sendF64(statusBar, sel("statusItemWithLength:"), NSVariableStatusItemLength)));
53
+ if (item === NIL) throw new Error("sweetnative: NSStatusBar.statusItemWithLength: returned nil");
54
+ const button = asId(send(item, sel("button")));
55
+
56
+ let menu: MenuSource | undefined = options.menu;
57
+ let built: BuiltMenu | undefined;
58
+ let disposed = false;
59
+
60
+ const applyIcon = (icon: TrayIcon | undefined) => {
61
+ const made = makeImage(icon?.macos, MENU_BAR_POINTS);
62
+ applyImage(button, "setImage:", made);
63
+ sendPtr(button, sel("setTitle:"), nsString(made.img === NIL ? "●" : ""));
64
+ };
65
+
66
+ const showMenu = () => {
67
+ if (menu === undefined) return;
68
+ const template = resolveMenu(menu);
69
+ if (template.length === 0) return;
70
+ const previous = built;
71
+ built = buildMenu(template);
72
+ sendPtr(item, sel("setMenu:"), built.menu);
73
+ sendPtr(button, sel("performClick:"), NIL); // tracks the menu modally
74
+ sendPtr(item, sel("setMenu:"), NIL);
75
+ // The chosen item's action has already fired; the old menu can go now.
76
+ previous?.dispose();
77
+ };
78
+
79
+ const target = makeTarget(`NativeShellTrayTarget${uid}`, {
80
+ "trayAction:": () => {
81
+ const event = asId(send(sharedApp(), sel("currentEvent")));
82
+ if (event === NIL) return;
83
+ const type = BigInt(sendInt(event, sel("type")) ?? 0);
84
+ const control = (BigInt(sendInt(event, sel("modifierFlags")) ?? 0) & NSEventModifierFlagControl) !== 0n;
85
+ // macOS convention: a menu bar item's menu opens on any click.
86
+ if (menu !== undefined || type === NSEventTypeRightMouseUp || (type === NSEventTypeLeftMouseUp && control)) {
87
+ showMenu();
88
+ return;
89
+ }
90
+ if (type !== NSEventTypeLeftMouseUp) return;
91
+ const clicks = BigInt(sendInt(event, sel("clickCount")) ?? 1);
92
+ if (clicks >= 2n) {
93
+ sendPtrSelPtrVoid(cls("NSObject"), sel("cancelPreviousPerformRequestsWithTarget:selector:object:"), target, sel("traySingleClick:"), NIL);
94
+ options.onDoubleClick?.();
95
+ } else if (options.onClick) {
96
+ const delay = Number(sendRetF64(cls("NSEvent"), sel("doubleClickInterval")) ?? 0.3);
97
+ sendSelPtrF64(target, sel("performSelector:withObject:afterDelay:"), sel("traySingleClick:"), NIL, delay);
98
+ }
99
+ },
100
+ "traySingleClick:": () => options.onClick?.(),
101
+ });
102
+
103
+ applyIcon(options.icon);
104
+ if (options.tooltip) sendPtr(button, sel("setToolTip:"), nsString(options.tooltip));
105
+ sendPtr(button, sel("setTarget:"), target);
106
+ sendPtr(button, sel("setAction:"), sel("trayAction:"));
107
+ sendUInt(button, sel("sendActionOn:"), NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp);
108
+
109
+ return {
110
+ setTooltip(text) {
111
+ if (!disposed) sendPtr(button, sel("setToolTip:"), nsString(text));
112
+ },
113
+ setIcon(icon) {
114
+ if (!disposed) applyIcon(icon);
115
+ },
116
+ setMenu(next) {
117
+ menu = next;
118
+ },
119
+ dispose() {
120
+ if (disposed) return;
121
+ disposed = true;
122
+ sendPtrSelPtrVoid(cls("NSObject"), sel("cancelPreviousPerformRequestsWithTarget:selector:object:"), target, sel("traySingleClick:"), NIL);
123
+ sendPtr(statusBar, sel("removeStatusItem:"), item);
124
+ release(item);
125
+ built?.dispose();
126
+ built = undefined;
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,64 @@
1
+ // Show/hide and close interception for the webview's NSWindow. The close hook
2
+ // retargets the red close button to our own target instead of replacing the
3
+ // window delegate, so webview's delegate (windowWillClose: -> quit) is
4
+ // untouched for the real quit path.
5
+
6
+ import type { NativeWindow, WindowHandle } from "../window";
7
+ import { NIL, type Id, makeTarget, sel, send, sendBool, sendPtr, sendRetBool, sendUIntRetPtr, sharedApp } from "./objc";
8
+
9
+ const NSWindowCloseButton = 0n;
10
+
11
+ const closeHandlers = new Map<Id, () => void>();
12
+ let target: Id | undefined;
13
+ const closeTarget = () =>
14
+ (target ??= makeTarget("NativeShellCloseTarget", {
15
+ "closeRequested:": (sender) => {
16
+ // sender is the button; its window is the one being closed.
17
+ const win = send(sender, sel("window"));
18
+ closeHandlers.get(win === null ? NIL : BigInt(win))?.();
19
+ },
20
+ }));
21
+
22
+ export const asNSWindow = (handle: WindowHandle): Id => {
23
+ if (handle === null || handle === 0 || handle === 0n) throw new Error("sweetnative: window handle is nil");
24
+ return BigInt(handle);
25
+ };
26
+
27
+ export function nativeWindow(handle: WindowHandle): NativeWindow {
28
+ const win = asNSWindow(handle);
29
+ const closeButton = () => {
30
+ const b = sendUIntRetPtr(win, sel("standardWindowButton:"), NSWindowCloseButton);
31
+ return b === null ? NIL : BigInt(b);
32
+ };
33
+ const self: NativeWindow = {
34
+ isVisible: () => Boolean(sendRetBool(win, sel("isVisible"))),
35
+ show() {
36
+ sendPtr(win, sel("makeKeyAndOrderFront:"), NIL);
37
+ sendBool(sharedApp(), sel("activateIgnoringOtherApps:"), true);
38
+ },
39
+ hide() {
40
+ sendPtr(win, sel("orderOut:"), NIL);
41
+ },
42
+ toggle() {
43
+ if (self.isVisible()) self.hide();
44
+ else self.show();
45
+ },
46
+ setIcon() {
47
+ // A Cocoa window has no icon of its own; setAppIcon covers the Dock.
48
+ },
49
+ onCloseRequest(handler) {
50
+ const button = closeButton();
51
+ if (button === NIL) throw new Error("sweetnative: window has no close button");
52
+ closeHandlers.set(win, handler);
53
+ sendPtr(button, sel("setTarget:"), closeTarget());
54
+ sendPtr(button, sel("setAction:"), sel("closeRequested:"));
55
+ return () => {
56
+ if (closeHandlers.get(win) !== handler) return;
57
+ closeHandlers.delete(win);
58
+ sendPtr(button, sel("setTarget:"), NIL);
59
+ sendPtr(button, sel("setAction:"), sel("performClose:"));
60
+ };
61
+ },
62
+ };
63
+ return self;
64
+ }
@@ -0,0 +1,15 @@
1
+ // The OS folder dialog (IFileOpenDialog on Windows, NSOpenPanel on macOS).
2
+
3
+ import { backend } from "./platform";
4
+ import type { WindowHandle } from "./window";
5
+
6
+ type Backend = { pickFolder(owner: WindowHandle, title: string): string | null };
7
+ const impl = backend<Backend>(
8
+ () => require("./win32/folder-picker"),
9
+ () => require("./darwin/folder-picker"),
10
+ );
11
+
12
+ /** Modal. Blocks until the user picks or cancels. Returns the absolute path, or null when cancelled. */
13
+ export function pickFolder(owner: WindowHandle, title: string): string | null {
14
+ return impl().pickFolder(owner, title);
15
+ }