@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.
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/README.zh-CN.md +152 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/client.js +1070 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +1 -0
- package/index.js +41 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +90 -4
- package/src/host/files.mjs +29 -0
- package/src/host/http.mjs +153 -0
- package/src/host/llm.mjs +194 -0
- package/src/host/log.mjs +40 -0
- package/src/host/service.mjs +334 -0
- package/src/host/settings.mjs +282 -0
- package/src/host/skills.mjs +88 -0
- package/src/host/supervisor.mjs +126 -0
- package/src/host/usage.mjs +128 -0
- package/src/shared/icons.mjs +66 -0
- package/src/shared/open-with-system.mjs +15 -0
- package/src/shared/paths.mjs +7 -0
- package/src/shared/protocol.mjs +4 -0
- package/src/worker/controller.mjs +274 -0
- package/src/worker/main.mjs +147 -0
- package/src/worker/native-window.mjs +319 -0
- package/src/worker/open-url.mjs +20 -0
- package/src/worker/overlay.mjs +266 -0
- package/src/worker/page.mjs +14 -0
- package/src/worker/placement.mjs +58 -0
- package/src/worker/speech.mjs +136 -0
- package/ui/markdown.js +122 -0
- package/ui/toolbar.css +162 -0
- package/ui/toolbar.html +54 -0
- package/ui/toolbar.js +552 -0
- package/vendor/selection-hook/LICENSE +21 -0
- package/vendor/selection-hook/index.d.ts +517 -0
- package/vendor/selection-hook/index.js +662 -0
- package/vendor/selection-hook/package.json +10 -0
- package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
- 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
|
+
}
|