@followbin/dsh-selection 0.0.0-stage → 0.2.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/README.zh-CN.md +152 -0
  4. package/THIRD_PARTY_NOTICES.md +22 -0
  5. package/client.js +1070 -0
  6. package/cordis.patch.yml +4 -0
  7. package/icon.svg +1 -0
  8. package/index.js +41 -0
  9. package/locale/en.json +6 -0
  10. package/locale/zh.json +6 -0
  11. package/package.json +90 -4
  12. package/src/host/files.mjs +29 -0
  13. package/src/host/http.mjs +153 -0
  14. package/src/host/llm.mjs +194 -0
  15. package/src/host/log.mjs +40 -0
  16. package/src/host/service.mjs +334 -0
  17. package/src/host/settings.mjs +282 -0
  18. package/src/host/skills.mjs +88 -0
  19. package/src/host/supervisor.mjs +126 -0
  20. package/src/host/usage.mjs +128 -0
  21. package/src/shared/icons.mjs +66 -0
  22. package/src/shared/open-with-system.mjs +15 -0
  23. package/src/shared/paths.mjs +7 -0
  24. package/src/shared/protocol.mjs +4 -0
  25. package/src/worker/controller.mjs +274 -0
  26. package/src/worker/main.mjs +147 -0
  27. package/src/worker/native-window.mjs +319 -0
  28. package/src/worker/open-url.mjs +20 -0
  29. package/src/worker/overlay.mjs +266 -0
  30. package/src/worker/page.mjs +14 -0
  31. package/src/worker/placement.mjs +58 -0
  32. package/src/worker/speech.mjs +136 -0
  33. package/ui/markdown.js +122 -0
  34. package/ui/toolbar.css +162 -0
  35. package/ui/toolbar.html +54 -0
  36. package/ui/toolbar.js +552 -0
  37. package/vendor/selection-hook/LICENSE +21 -0
  38. package/vendor/selection-hook/index.d.ts +517 -0
  39. package/vendor/selection-hook/index.js +662 -0
  40. package/vendor/selection-hook/package.json +10 -0
  41. package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
  42. package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
  43. package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
  44. package/vendor/selection-hook/prebuilds/win32-x64/selection-hook.node +0 -0
@@ -0,0 +1,319 @@
1
+ // Window behaviour @webviewjs/webview does not expose: no Dock icon, clicks that do not steal focus,
2
+ // and a level above full-screen apps. System APIs are called through koffi, so nothing is compiled.
3
+ // The Windows branch has not been run on real hardware yet.
4
+ import { createRequire } from 'node:module'
5
+
6
+ const require = createRequire(import.meta.url)
7
+
8
+ /**
9
+ * Loads koffi's platform package directly. The `koffi` meta package runs an install script that
10
+ * DSH's pnpm refuses by default; the platform packages carry the same native API and no script.
11
+ */
12
+ function loadKoffi() {
13
+ const name = `@koromix/koffi-${process.platform}-${process.arch}`
14
+ try {
15
+ return require(name)
16
+ } catch (error) {
17
+ throw new Error(`${name} is missing; reinstall the plugin for this platform (${error.message})`)
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Controls one overlay window. Coordinates are logical screen points with a top-left origin,
23
+ * matching what selection-hook reports.
24
+ * @typedef {object} OverlayWindow
25
+ * @property {(rect: { x: number, y: number, width: number, height: number }) => void} setBounds
26
+ * @property {() => void} showInactive Shows the window without activating the app.
27
+ * @property {() => void} hide
28
+ * @property {(keyable: boolean) => void} setKeyable Lets the window take keyboard input (the ask box) or not.
29
+ * @property {() => Record<string, unknown>} inspect Reads back the effective native state, for self tests.
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} NativeWindowTweaks
34
+ * @property {() => void} preventActivation Keeps this process from ever activating; call before the first event pump.
35
+ * @property {() => void} hideFromDock
36
+ * @property {(point: { x: number, y: number }) => ({ x: number, y: number, width: number, height: number } | null)} displayBoundsAt
37
+ * @property {(win: any) => OverlayWindow} adopt Turns a @webviewjs window into a floating overlay.
38
+ */
39
+
40
+ /** @returns {NativeWindowTweaks | null} */
41
+ export function createNativeWindowTweaks() {
42
+ if (process.platform === 'darwin') return macTweaks(loadKoffi())
43
+ if (process.platform === 'win32') return windowsTweaks(loadKoffi())
44
+ return null
45
+ }
46
+
47
+ /** Fallback without native tweaks: the window activates on show. Only for unsupported platforms. */
48
+ export function plainOverlayWindow(win) {
49
+ return {
50
+ setBounds(rect) {
51
+ win.setSize(rect.width, rect.height, true)
52
+ win.setPosition(rect.x, rect.y, true)
53
+ },
54
+ showInactive: () => win.show(),
55
+ hide: () => win.hide(),
56
+ setKeyable: () => {},
57
+ inspect: () => ({}),
58
+ }
59
+ }
60
+
61
+ function macTweaks(koffi) {
62
+ const objc = koffi.load('/usr/lib/libobjc.A.dylib')
63
+ const coreGraphics = koffi.load('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
64
+ const CGPoint = koffi.struct('CGPoint', { x: 'double', y: 'double' })
65
+ const CGSize = koffi.struct('CGSize', { width: 'double', height: 'double' })
66
+ const CGRect = koffi.struct('CGRect', { origin: CGPoint, size: CGSize })
67
+ const mainDisplay = coreGraphics.func('uint32_t CGMainDisplayID(void)')
68
+ const displayBounds = coreGraphics.func('CGRect CGDisplayBounds(uint32_t display)')
69
+ const displaysWithPoint = coreGraphics.func('int32_t CGGetDisplaysWithPoint(CGPoint point, uint32_t maxDisplays, _Out_ uint32_t *displays, _Out_ uint32_t *count)')
70
+
71
+ const getClass = objc.func('void *objc_getClass(const char *name)')
72
+ const selector = objc.func('void *sel_registerName(const char *name)')
73
+ const classOf = objc.func('void *object_getClass(void *obj)')
74
+ const className = objc.func('const char *class_getName(void *cls)')
75
+ const allocateClassPair = objc.func('void *objc_allocateClassPair(void *superclass, const char *name, size_t extraBytes)')
76
+ const registerClassPair = objc.func('void objc_registerClassPair(void *cls)')
77
+ // On arm64 objc_msgSend must be declared with the exact signature of each call; it is not variadic.
78
+ const send = {
79
+ id: objc.func('objc_msgSend', 'void *', ['void *', 'void *']),
80
+ idIndex: objc.func('objc_msgSend', 'void *', ['void *', 'void *', 'unsigned long']),
81
+ initPanel: objc.func('objc_msgSend', 'void *', ['void *', 'void *', CGRect, 'unsigned long', 'unsigned long', 'bool']),
82
+ bool: objc.func('objc_msgSend', 'bool', ['void *', 'void *']),
83
+ boolId: objc.func('objc_msgSend', 'bool', ['void *', 'void *', 'void *']),
84
+ boolLong: objc.func('objc_msgSend', 'bool', ['void *', 'void *', 'long']),
85
+ long: objc.func('objc_msgSend', 'long', ['void *', 'void *']),
86
+ ulong: objc.func('objc_msgSend', 'unsigned long', ['void *', 'void *']),
87
+ void: objc.func('objc_msgSend', 'void', ['void *', 'void *']),
88
+ voidId: objc.func('objc_msgSend', 'void', ['void *', 'void *', 'void *']),
89
+ voidLong: objc.func('objc_msgSend', 'void', ['void *', 'void *', 'long']),
90
+ voidUlong: objc.func('objc_msgSend', 'void', ['void *', 'void *', 'unsigned long']),
91
+ voidBool: objc.func('objc_msgSend', 'void', ['void *', 'void *', 'bool']),
92
+ voidRect: objc.func('objc_msgSend', 'void', ['void *', 'void *', CGRect]),
93
+ voidRectBool: objc.func('objc_msgSend', 'void', ['void *', 'void *', CGRect, 'bool']),
94
+ // Struct returns only go through plain objc_msgSend on arm64; x86_64 would need objc_msgSend_stret.
95
+ rect: process.arch === 'arm64' ? objc.func('objc_msgSend', CGRect, ['void *', 'void *']) : null,
96
+ }
97
+ const sel = (name) => selector(name)
98
+ const app = () => send.id(getClass('NSApplication'), sel('sharedApplication'))
99
+ const boolType = process.arch === 'arm64' ? 'B' : 'c'
100
+
101
+ // Method implementations installed with class_replaceMethod. AppKit calls them on the main thread
102
+ // while the event pump runs; the references must stay alive for the life of the process.
103
+ const NoArgBool = koffi.proto('bool NoArgBool(void *self, void *cmd)')
104
+ const EventBool = koffi.proto('bool EventBool(void *self, void *cmd, void *event)')
105
+ const NoArgVoid = koffi.proto('void NoArgVoid(void *self, void *cmd)')
106
+ const BoolArgVoid = koffi.proto('void BoolArgVoid(void *self, void *cmd, bool flag)')
107
+ const replace = {
108
+ noArgBool: objc.func('void *class_replaceMethod(void *cls, void *name, NoArgBool *imp, const char *types)'),
109
+ eventBool: objc.func('void *class_replaceMethod(void *cls, void *name, EventBool *imp, const char *types)'),
110
+ noArgVoid: objc.func('void *class_replaceMethod(void *cls, void *name, NoArgVoid *imp, const char *types)'),
111
+ boolArgVoid: objc.func('void *class_replaceMethod(void *cls, void *name, BoolArgVoid *imp, const char *types)'),
112
+ }
113
+ let keyable = false
114
+ const callbacks = {
115
+ canBecomeKey: koffi.register(() => keyable, koffi.pointer(NoArgBool)),
116
+ never: koffi.register(() => false, koffi.pointer(NoArgBool)),
117
+ always: koffi.register(() => true, koffi.pointer(EventBool)),
118
+ noop: koffi.register(() => {}, koffi.pointer(NoArgVoid)),
119
+ noopFlag: koffi.register(() => {}, koffi.pointer(BoolArgVoid)),
120
+ }
121
+
122
+ let panelClass = null
123
+ // Only NSPanel honours the non-activating style; AppKit rejects it on a plain NSWindow. Key status is
124
+ // off unless the ask box needs typing, so clicking the toolbar leaves the source app's selection alone.
125
+ function overlayPanelClass() {
126
+ if (panelClass) return panelClass
127
+ panelClass = allocateClassPair(getClass('NSPanel'), 'DSHSelectionPanel', 0)
128
+ registerClassPair(panelClass)
129
+ replace.noArgBool(panelClass, sel('canBecomeKeyWindow'), callbacks.canBecomeKey, `${boolType}@:`)
130
+ replace.noArgBool(panelClass, sel('canBecomeMainWindow'), callbacks.never, `${boolType}@:`)
131
+ return panelClass
132
+ }
133
+
134
+ function findWebView(view, depth = 0) {
135
+ if (send.boolId(view, sel('isKindOfClass:'), getClass('WKWebView'))) return view
136
+ if (depth > 4) return null
137
+ const subviews = send.id(view, sel('subviews'))
138
+ const count = Number(send.ulong(subviews, sel('count')))
139
+ for (let i = 0; i < count; i++) {
140
+ const found = findWebView(send.idIndex(subviews, sel('objectAtIndex:'), i), depth + 1)
141
+ if (found) return found
142
+ }
143
+ return null
144
+ }
145
+
146
+ // Top-left logical coordinates → Cocoa coordinates (origin at the primary display's bottom-left, y up).
147
+ function toCocoaFrame(rect) {
148
+ const primaryHeight = displayBounds(mainDisplay()).size.height
149
+ return { origin: { x: rect.x, y: primaryHeight - rect.y - rect.height }, size: { width: rect.width, height: rect.height } }
150
+ }
151
+
152
+ function fromCocoaFrame({ origin, size }) {
153
+ const primaryHeight = displayBounds(mainDisplay()).size.height
154
+ return { x: origin.x, y: primaryHeight - origin.y - size.height, width: size.width, height: size.height }
155
+ }
156
+
157
+ return {
158
+ preventActivation() {
159
+ // tao calls activateIgnoringOtherApps once launching finishes, which would steal focus from the
160
+ // app the user is selecting in. This process never needs to be the active app.
161
+ const appClass = classOf(app())
162
+ replace.boolArgVoid(appClass, sel('activateIgnoringOtherApps:'), callbacks.noopFlag, `v@:${boolType}`)
163
+ replace.noArgVoid(appClass, sel('activate'), callbacks.noop, 'v@:')
164
+ },
165
+ hideFromDock() {
166
+ send.boolLong(app(), sel('setActivationPolicy:'), 1) // NSApplicationActivationPolicyAccessory
167
+ },
168
+ displayBoundsAt(point) {
169
+ const ids = [0]
170
+ const count = [0]
171
+ if (displaysWithPoint({ x: point.x, y: point.y }, 1, ids, count) !== 0 || count[0] === 0) return null
172
+ const { origin, size } = displayBounds(ids[0])
173
+ return { x: origin.x, y: origin.y, width: size.width, height: size.height }
174
+ },
175
+ adopt(win) {
176
+ // tao's window is an NSWindow subclass under KVO, so it cannot become a panel. Build a real panel
177
+ // and move wry's web view into it; the tao window stays hidden and only hosts the web view's origin.
178
+ const contentView = win.getNativeHandle()
179
+ const webView = findWebView(contentView)
180
+ if (!webView) throw new Error('WKWebView not found in the overlay window')
181
+ let node = webView
182
+ for (let parent = send.id(node, sel('superview')); parent && parent !== contentView; parent = send.id(node, sel('superview'))) node = parent
183
+
184
+ const size = { width: 1, height: 1 }
185
+ const panel = send.initPanel(
186
+ send.id(overlayPanelClass(), sel('alloc')),
187
+ sel('initWithContentRect:styleMask:backing:defer:'),
188
+ { origin: { x: 0, y: 0 }, size },
189
+ 1 << 7, // borderless + NSWindowStyleMaskNonactivatingPanel
190
+ 2, // NSBackingStoreBuffered
191
+ false,
192
+ )
193
+ send.voidBool(panel, sel('setOpaque:'), false)
194
+ send.voidId(panel, sel('setBackgroundColor:'), send.id(getClass('NSColor'), sel('clearColor')))
195
+ send.voidBool(panel, sel('setHasShadow:'), false)
196
+ send.voidBool(panel, sel('setFloatingPanel:'), true)
197
+ send.voidBool(panel, sel('setBecomesKeyOnlyIfNeeded:'), true)
198
+ send.voidBool(panel, sel('setHidesOnDeactivate:'), false)
199
+ send.voidBool(panel, sel('setReleasedWhenClosed:'), false)
200
+ // kCGScreenSaverWindowLevel + 1: above full-screen apps, like Electron's setAlwaysOnTop(true, 'screen-saver', 1).
201
+ send.voidLong(panel, sel('setLevel:'), 1001)
202
+ // canJoinAllSpaces | stationary | ignoresCycle | fullScreenAuxiliary
203
+ send.voidUlong(panel, sel('setCollectionBehavior:'), 1 | 16 | 64 | 256)
204
+
205
+ send.id(node, sel('retain')) // keep the view alive while it moves between windows
206
+ send.void(node, sel('removeFromSuperview'))
207
+ send.voidRect(node, sel('setFrame:'), { origin: { x: 0, y: 0 }, size })
208
+ send.voidUlong(node, sel('setAutoresizingMask:'), 2 | 16) // follow the panel's width and height
209
+ send.voidId(send.id(panel, sel('contentView')), sel('addSubview:'), node)
210
+ // A window that is not key normally spends the first click on becoming active; take it as a real click.
211
+ replace.eventBool(classOf(webView), sel('acceptsFirstMouse:'), callbacks.always, `${boolType}@:@`)
212
+
213
+ return {
214
+ setBounds(rect) {
215
+ send.voidRectBool(panel, sel('setFrame:display:'), toCocoaFrame(rect), true)
216
+ },
217
+ showInactive() {
218
+ send.void(panel, sel('orderFrontRegardless'))
219
+ },
220
+ hide() {
221
+ keyable = false
222
+ send.voidId(panel, sel('orderOut:'), null)
223
+ },
224
+ setKeyable(value) {
225
+ keyable = value
226
+ if (value) {
227
+ // A non-activating panel becomes key without activating the app, so the user can type
228
+ // into the ask box while the app they selected in stays frontmost.
229
+ send.void(panel, sel('makeKeyWindow'))
230
+ send.boolId(panel, sel('makeFirstResponder:'), webView)
231
+ } else if (send.bool(panel, sel('isKeyWindow'))) {
232
+ send.void(panel, sel('resignKeyWindow'))
233
+ }
234
+ },
235
+ inspect() {
236
+ return {
237
+ windowClass: className(classOf(panel)),
238
+ isPanel: send.boolId(panel, sel('isKindOfClass:'), getClass('NSPanel')),
239
+ activationPolicy: ['regular', 'accessory', 'prohibited'][Number(send.long(app(), sel('activationPolicy')))],
240
+ appActive: send.bool(app(), sel('isActive')),
241
+ nonactivatingPanel: (BigInt(send.ulong(panel, sel('styleMask'))) & (1n << 7n)) !== 0n,
242
+ level: Number(send.long(panel, sel('level'))),
243
+ collectionBehavior: Number(send.ulong(panel, sel('collectionBehavior'))),
244
+ canBecomeKey: send.bool(panel, sel('canBecomeKeyWindow')),
245
+ isKey: send.bool(panel, sel('isKeyWindow')),
246
+ isVisible: send.bool(panel, sel('isVisible')),
247
+ webViewAcceptsFirstMouse: send.boolId(webView, sel('acceptsFirstMouse:'), null),
248
+ frame: send.rect ? fromCocoaFrame(send.rect(panel, sel('frame'))) : null,
249
+ }
250
+ },
251
+ }
252
+ },
253
+ }
254
+ }
255
+
256
+ function windowsTweaks(koffi) {
257
+ const user32 = koffi.load('user32.dll')
258
+ const getLong = user32.func('intptr_t __stdcall GetWindowLongPtrW(void *hwnd, int index)')
259
+ const setLong = user32.func('intptr_t __stdcall SetWindowLongPtrW(void *hwnd, int index, intptr_t value)')
260
+ const showWindow = user32.func('bool __stdcall ShowWindow(void *hwnd, int cmd)')
261
+ const setPos = user32.func('bool __stdcall SetWindowPos(void *hwnd, intptr_t after, int x, int y, int cx, int cy, uint32_t flags)')
262
+ const setForeground = user32.func('bool __stdcall SetForegroundWindow(void *hwnd)')
263
+ const getForeground = user32.func('intptr_t __stdcall GetForegroundWindow()')
264
+ const GWL_EXSTYLE = -20
265
+ const WS_EX_TOPMOST = 0x8n
266
+ const WS_EX_TOOLWINDOW = 0x80n
267
+ const WS_EX_NOACTIVATE = 0x08000000n
268
+ const HWND_TOPMOST = -1
269
+ const SWP_NOSIZE_NOMOVE_NOACTIVATE = 0x1 | 0x2 | 0x10
270
+ const SW_HIDE = 0
271
+ const SW_SHOWNOACTIVATE = 4
272
+
273
+ return {
274
+ preventActivation() {},
275
+ hideFromDock() {},
276
+ displayBoundsAt: () => null,
277
+ adopt(win) {
278
+ const hwnd = win.getNativeHandle()
279
+ const isForeground = () => {
280
+ try {
281
+ return BigInt(getForeground()) === BigInt(hwnd)
282
+ } catch {
283
+ return false
284
+ }
285
+ }
286
+ const base = BigInt(getLong(hwnd, GWL_EXSTYLE)) | WS_EX_TOPMOST | WS_EX_TOOLWINDOW
287
+ setLong(hwnd, GWL_EXSTYLE, base | WS_EX_NOACTIVATE)
288
+ return {
289
+ // The overlay converts selection-hook's physical pixels to logical ones before calling this.
290
+ setBounds(rect) {
291
+ win.setSize(rect.width, rect.height, true)
292
+ win.setPosition(rect.x, rect.y, true)
293
+ },
294
+ showInactive() {
295
+ showWindow(hwnd, SW_SHOWNOACTIVATE)
296
+ setPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE_NOMOVE_NOACTIVATE)
297
+ },
298
+ hide() {
299
+ setLong(hwnd, GWL_EXSTYLE, base | WS_EX_NOACTIVATE)
300
+ showWindow(hwnd, SW_HIDE)
301
+ },
302
+ setKeyable(value) {
303
+ setLong(hwnd, GWL_EXSTYLE, value ? base : base | WS_EX_NOACTIVATE)
304
+ if (value) setForeground(hwnd)
305
+ },
306
+ inspect() {
307
+ const current = BigInt(getLong(hwnd, GWL_EXSTYLE))
308
+ return {
309
+ noActivate: (current & WS_EX_NOACTIVATE) !== 0n,
310
+ toolWindow: (current & WS_EX_TOOLWINDOW) !== 0n,
311
+ topmost: (current & WS_EX_TOPMOST) !== 0n,
312
+ // After setKeyable(true) the panel is the foreground window and gets the keystrokes.
313
+ isKey: isForeground(),
314
+ }
315
+ },
316
+ }
317
+ },
318
+ }
319
+ }
@@ -0,0 +1,20 @@
1
+ // Opens a URL with the system handler. Only web links and DSH's own scheme are accepted, because the
2
+ // URL can come from model output or from a page the user selected text in.
3
+ import { openWithSystem } from '../shared/open-with-system.mjs'
4
+
5
+ const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'dsh:'])
6
+
7
+ /** The URL to hand to the system, or null when it must not be opened. */
8
+ export function openableUrl(url) {
9
+ try {
10
+ const parsed = new URL(url)
11
+ return ALLOWED_PROTOCOLS.has(parsed.protocol) ? parsed.href : null
12
+ } catch {
13
+ return null
14
+ }
15
+ }
16
+
17
+ export function openUrl(url, log) {
18
+ const href = openableUrl(url)
19
+ if (href) openWithSystem(href, log)
20
+ }
@@ -0,0 +1,266 @@
1
+ // The floating toolbar and result panel: one system web view (WKWebView / WebView2) in one overlay window.
2
+ // Must run on the process main thread; window events are pumped from a Node timer.
3
+ import { join } from 'node:path'
4
+ import { Application } from '@webviewjs/webview'
5
+ import { dataDirectory } from '../shared/paths.mjs'
6
+ import { createNativeWindowTweaks, plainOverlayWindow } from './native-window.mjs'
7
+ import { pageHtml } from './page.mjs'
8
+ import { fitWindow, place, roomBelow } from './placement.mjs'
9
+
10
+ const PUMP_VISIBLE_MS = 16
11
+ const PUMP_HIDDEN_MS = 250
12
+ const PAGE_READY_TIMEOUT_MS = 5000
13
+ // The least height a panel gets for its answer before it scrolls: about eight lines plus the header and
14
+ // the follow-up box. Below the selection it may grow further, down to the bottom of the screen.
15
+ const PANEL_MIN_ROOM = 280
16
+
17
+ /**
18
+ * Where the selection ended, in screen coordinates as selection-hook reports them.
19
+ * @typedef {{ x: number, y: number, top: number, precise: boolean }} Anchor
20
+ * `precise` means `y` is the selection's bottom edge; otherwise it is the mouse-up position.
21
+ * `top` is the selection's top edge, used when the overlay has to go above the selection.
22
+ */
23
+
24
+ /**
25
+ * @param {{ onMessage: (message: any) => void, log: (level: string, message: string, fields?: object) => void }} options
26
+ */
27
+ export async function createOverlay({ onMessage, log }) {
28
+ const tweaks = createNativeWindowTweaks()
29
+ const app = new Application()
30
+ // Must happen before the first pump: tao activates the app when launching finishes.
31
+ tweaks?.preventActivation()
32
+ const ready = app.whenReady({ autoRun: false })
33
+ let pump = null
34
+ const setPump = (ms) => {
35
+ if (pump?.ms === ms) return
36
+ if (pump) clearInterval(pump.timer)
37
+ pump = { ms, timer: setInterval(() => app.pumpEvents(), ms) }
38
+ }
39
+ setPump(PUMP_VISIBLE_MS)
40
+ // tao resets the activation policy when launching finishes, so set it before and after.
41
+ tweaks?.hideFromDock()
42
+ await ready
43
+ tweaks?.hideFromDock()
44
+
45
+ // Never shown itself: on macOS its web view moves into a native panel, on Windows the tweaks take over.
46
+ const win = app.createBrowserWindow({
47
+ title: 'DSH Selection',
48
+ width: 320,
49
+ height: 80,
50
+ logical: true,
51
+ decorations: false,
52
+ transparent: true,
53
+ resizable: false,
54
+ minimizable: false,
55
+ maximizable: false,
56
+ visible: false,
57
+ focused: false,
58
+ alwaysOnTop: true,
59
+ windowsSkipTaskbar: true,
60
+ // tao keeps a system shadow on undecorated windows unless told not to; on Windows it outlines the
61
+ // whole transparent window with a 1px border and adds hidden insets around the client area.
62
+ windowsUndecoratedShadow: false,
63
+ macosHasShadow: false,
64
+ })
65
+ // WebView2 defaults its data folder next to the executable, i.e. inside the DSH install directory.
66
+ const webContext = process.platform === 'win32'
67
+ ? app.createWebContext({ dataDirectory: join(dataDirectory(), 'webview') })
68
+ : undefined
69
+
70
+ // Sizes the page reports: window size plus the primary card (anchored) and every clickable card.
71
+ const layoutOf = {
72
+ bar: { width: 420, height: 72, primary: { x: 18, y: 12, width: 384, height: 40 }, hits: [] },
73
+ panel: { width: 456, height: 260, primary: { x: 18, y: 12, width: 420, height: 224 }, hits: [] },
74
+ }
75
+ let mode = 'hidden'
76
+ /** @type {Anchor | null} */
77
+ let anchor = null
78
+ let placement = null
79
+ let bounds = null
80
+ // How tall the panel was last allowed to grow, and whether to fill that at once, as told to the page.
81
+ let room = null
82
+ // Once the user drags the window it stays where they put it: later size changes keep its top-left.
83
+ let moved = false
84
+ let drag = null
85
+
86
+ let resolvePage
87
+ const pageReady = new Promise((resolve) => { resolvePage = resolve })
88
+ // The page must never leave its own document: a dropped or clicked link would otherwise load a foreign
89
+ // page that keeps `window.ipc` and would receive every later selection. Only the first load is allowed,
90
+ // and IPC is accepted only from the document that announced itself first.
91
+ let loaded = false
92
+ let pageUri = null
93
+ const webview = win.createWebview({
94
+ html: pageHtml(),
95
+ transparent: true,
96
+ webContext,
97
+ navigationHandler: () => !loaded,
98
+ newWindowHandler: () => false,
99
+ })
100
+ webview.setBackgroundColor(0, 0, 0, 0)
101
+ webview.onIpcMessage((message) => {
102
+ if (pageUri !== null && message.uri !== pageUri) return
103
+ let parsed
104
+ try {
105
+ parsed = JSON.parse(message.body.toString())
106
+ } catch {
107
+ return
108
+ }
109
+ if (parsed?.type === 'page-ready' && pageUri === null) {
110
+ pageUri = message.uri
111
+ loaded = true
112
+ resolvePage()
113
+ return
114
+ }
115
+ if (pageUri === null || parsed?.type === 'page-ready') return
116
+ if (parsed?.type === 'size') resize(parsed)
117
+ else if (parsed?.type === 'page-error') log('warn', 'toolbar page error', { error: String(parsed.message).slice(0, 300) })
118
+ else onMessage(parsed)
119
+ })
120
+ const overlayWindow = tweaks ? tweaks.adopt(win) : plainOverlayWindow(win)
121
+ await Promise.race([pageReady, new Promise((resolve) => setTimeout(resolve, PAGE_READY_TIMEOUT_MS))])
122
+
123
+ const call = (fn, ...args) => webview.evaluateScript(`window.bar.${fn}(${args.map((arg) => JSON.stringify(arg)).join(',')})`)
124
+
125
+ function screenAt(point) {
126
+ if (tweaks?.displayBoundsAt && process.platform === 'darwin') return tweaks.displayBoundsAt(point)
127
+ const monitor = win.getMonitorFromPoint(point.x, point.y)
128
+ if (!monitor) return null
129
+ const scale = monitor.scaleFactor || 1
130
+ return { x: monitor.position.x / scale, y: monitor.position.y / scale, width: monitor.size.width / scale, height: monitor.size.height / scale }
131
+ }
132
+
133
+ /** selection-hook reports physical pixels on Windows and points on macOS; layout works in points. */
134
+ function toLogical(point) {
135
+ if (process.platform !== 'win32') return point
136
+ const scale = win.getMonitorFromPoint(point.x, point.y)?.scaleFactor || 1
137
+ return { ...point, x: point.x / scale, y: point.y / scale, ...(point.top === undefined ? {} : { top: point.top / scale }) }
138
+ }
139
+
140
+ function apply() {
141
+ const layout = layoutOf[mode]
142
+ // A dragged window keeps its place; otherwise the primary card starts where the selection ends,
143
+ // to its right and below it.
144
+ const origin = moved && bounds
145
+ ? { x: bounds.x, y: bounds.y }
146
+ : { x: anchor.x - layout.primary.x, y: placement.top - layout.primary.y }
147
+ const screen = moved && bounds ? screenUnder(origin, layout) : screenAt(anchor)
148
+ const next = fitWindow({ origin, layout, screen })
149
+ if (bounds && Object.keys(next).every((key) => next[key] === bounds[key])) return
150
+ bounds = next
151
+ overlayWindow.setBounds(bounds)
152
+ }
153
+
154
+ /** The screen under a window's header; a window dragged off every screen belongs to the selection's. */
155
+ function screenUnder(origin, layout) {
156
+ const header = { x: origin.x + layout.primary.x + layout.primary.width / 2, y: origin.y + layout.primary.y + 12 }
157
+ return screenAt(header) ?? screenAt(anchor)
158
+ }
159
+
160
+ /** The panel scrolls inside instead of growing past its room, so the window never has to move. */
161
+ function setRoom(next, fill = false) {
162
+ const value = Number.isFinite(next) ? Math.floor(next) : null
163
+ const key = `${value}/${fill}`
164
+ if (mode !== 'panel' || key === room) return
165
+ room = key
166
+ call('setRoom', value, fill)
167
+ }
168
+
169
+ function resize(report) {
170
+ if (report.mode !== 'bar' && report.mode !== 'panel') return
171
+ layoutOf[report.mode] = { width: report.width, height: report.height, primary: report.primary, hits: report.hits ?? [] }
172
+ if (report.mode === mode && placement) apply()
173
+ }
174
+
175
+ function show(nextMode, minRoom) {
176
+ mode = nextMode
177
+ setPump(PUMP_VISIBLE_MS)
178
+ placement = place(anchor, minRoom, screenAt(anchor))
179
+ setRoom(placement.room, placement.fill)
180
+ apply()
181
+ overlayWindow.showInactive()
182
+ }
183
+
184
+ return {
185
+ /** @param {{ bar: object[], more: object[], style: 'full' | 'mini' }} toolbar Skills on the toolbar and in its menu, and how the toolbar is drawn. */
186
+ configure: (toolbar) => call('configure', toolbar),
187
+ /** @param {Anchor} point In selection-hook coordinates. */
188
+ showBar(point) {
189
+ anchor = toLogical(point)
190
+ moved = false
191
+ drag = null
192
+ call('showBar')
193
+ show('bar', layoutOf.bar.primary.height)
194
+ },
195
+ showPanel(payload) {
196
+ call('showPanel', payload)
197
+ show('panel', PANEL_MIN_ROOM)
198
+ },
199
+ /** A request started: clears the result (or opens a new turn in the ask panel). */
200
+ begin: (requestId, info) => call('begin', requestId, info),
201
+ append: (requestId, chunk) => call('append', requestId, chunk),
202
+ done: (requestId) => call('done', requestId),
203
+ fail: (requestId, failure) => call('fail', requestId, failure),
204
+ setSpeaking: (speaking) => call('setSpeaking', speaking),
205
+ /** Opens or closes the ⋯ menu (normally done by the page itself). */
206
+ setMenu: (open) => call('setMenu', open),
207
+ flash: (skillId) => call('flash', skillId),
208
+ setKeyable: (keyable) => overlayWindow.setKeyable(keyable),
209
+ /** Whether the panel has the keyboard, i.e. the user is typing into it. */
210
+ isKey: () => Boolean(overlayWindow.inspect().isKey),
211
+ /**
212
+ * Moves the window with the pointer. Points are the page's screen coordinates; only their
213
+ * difference from the start is used, so their origin does not matter.
214
+ * @param {'start' | 'move' | 'end'} phase
215
+ */
216
+ drag(phase, point) {
217
+ if (!bounds || !Number.isFinite(point?.x) || !Number.isFinite(point?.y)) return
218
+ if (phase === 'start') {
219
+ drag = { x: point.x, y: point.y, bounds: { ...bounds } }
220
+ moved = true
221
+ return
222
+ }
223
+ if (!drag) return
224
+ bounds = { ...bounds, x: Math.round(drag.bounds.x + point.x - drag.x), y: Math.round(drag.bounds.y + point.y - drag.y) }
225
+ overlayWindow.setBounds(bounds)
226
+ if (phase !== 'end') return
227
+ drag = null
228
+ // Dropped low, the panel still gets room for its answer and moves up as far as that needs.
229
+ const layout = layoutOf[mode]
230
+ setRoom(Math.max(PANEL_MIN_ROOM, roomBelow(bounds.y + layout.primary.y, screenUnder(bounds, layout))))
231
+ apply()
232
+ },
233
+ hide() {
234
+ if (mode === 'hidden') return
235
+ overlayWindow.hide()
236
+ mode = 'hidden'
237
+ placement = null
238
+ bounds = null
239
+ room = null
240
+ moved = false
241
+ drag = null
242
+ call('reset')
243
+ setPump(PUMP_HIDDEN_MS)
244
+ },
245
+ get mode() { return mode },
246
+ /** Where the window was placed, in the same coordinates as `contains` (self tests and diagnostics). */
247
+ get bounds() { return bounds },
248
+ /** Whether a point (selection-hook coordinates) is on one of the visible cards, not their shadow margin. */
249
+ contains(point) {
250
+ if (mode === 'hidden' || !bounds) return false
251
+ const { x, y } = toLogical(point)
252
+ const cards = layoutOf[mode].hits.length ? layoutOf[mode].hits : [layoutOf[mode].primary]
253
+ return cards.some((card) => x >= bounds.x + card.x && x <= bounds.x + card.x + card.width
254
+ && y >= bounds.y + card.y && y <= bounds.y + card.y + card.height)
255
+ },
256
+ inspect: () => overlayWindow.inspect(),
257
+ /** Makes the page report its layout through onMessage (self tests). */
258
+ probe: () => call('probe'),
259
+ /** Clicks an element in the page through its own click handling (self tests). */
260
+ click: (selector) => call('click', selector),
261
+ dispose() {
262
+ if (pump) clearInterval(pump.timer)
263
+ app.exit()
264
+ },
265
+ }
266
+ }
@@ -0,0 +1,14 @@
1
+ // The toolbar page as one HTML string: the web view loads it with no file or network access.
2
+ // Kept apart from overlay.mjs, which loads the native web view, so tests can load the same page.
3
+ import { readFileSync } from 'node:fs'
4
+ import { ICONS, brandIcon } from '../shared/icons.mjs'
5
+
6
+ /** Inlines the page's stylesheet, scripts and icon table. */
7
+ export function pageHtml() {
8
+ const read = (name) => readFileSync(new URL(`../../ui/${name}`, import.meta.url), 'utf8')
9
+ return read('toolbar.html')
10
+ .replace('<link rel="stylesheet" href="toolbar.css">', () => `<style>${read('toolbar.css')}</style>`)
11
+ .replace('<script src="icons.js"></script>', () => `<script>window.DSH_ICONS = ${JSON.stringify(ICONS)}; window.DSH_BRAND = ${JSON.stringify(brandIcon('brand', '5 5 26 26'))}</script>`)
12
+ .replace('<script src="markdown.js"></script>', () => `<script>${read('markdown.js')}</script>`)
13
+ .replace('<script src="toolbar.js"></script>', () => `<script>${read('toolbar.js')}</script>`)
14
+ }