quickwin 2.1.1 → 2.3.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 (117) hide show
  1. package/README.en.md +9 -9
  2. package/README.md +9 -9
  3. package/constants.ts +1063 -0
  4. package/enum_map.d.ts +129 -0
  5. package/examples/PdfCanvas.js +2 -2
  6. package/examples/PdfCanvas.tsx +3 -2
  7. package/examples/{exec_server.js → execd/main.js} +1114 -287
  8. package/examples/execd/tray.js +192 -0
  9. package/examples/execd/worker.js +122 -0
  10. package/examples/pdf_preview2.js +46 -46
  11. package/examples/pdf_preview2.ts +55 -54
  12. package/examples/pdf_viewer.js +134 -130
  13. package/examples/pdf_viewer.tsx +6 -5
  14. package/examples/test_gallery.js +135 -131
  15. package/examples/test_gallery.tsx +9 -8
  16. package/examples/test_react_lazy.js +130 -128
  17. package/examples/test_react_lazy.tsx +7 -6
  18. package/examples/test_react_listbox.js +130 -128
  19. package/examples/test_react_listbox.tsx +8 -7
  20. package/examples/test_root_window.js +130 -128
  21. package/examples/test_root_window.tsx +5 -5
  22. package/examples/test_tab.js +130 -128
  23. package/examples/test_tab.tsx +7 -6
  24. package/examples/tray_demo.js +8 -8
  25. package/examples/tray_demo.tsx +9 -8
  26. package/lib/dumpRects.js +2 -2
  27. package/lib/dumpRects.ts +3 -2
  28. package/lib/fetch.js +15 -15
  29. package/lib/fetch.ts +16 -15
  30. package/lib/ffi/bind.js +3 -3
  31. package/lib/ffi/bind.ts +3 -3
  32. package/lib/ffi/ctype.ts +3 -1
  33. package/lib/ffi/struct.js +23 -7
  34. package/lib/ffi/struct.ts +50 -23
  35. package/lib/http-server.js +5 -5
  36. package/lib/http-server.ts +6 -5
  37. package/lib/react-qw/components/Button.js +2 -2
  38. package/lib/react-qw/components/CheckBox.js +4 -4
  39. package/lib/react-qw/components/ComboBox.js +10 -10
  40. package/lib/react-qw/components/DateTimePicker.js +12 -12
  41. package/lib/react-qw/components/Input.js +7 -7
  42. package/lib/react-qw/components/Link.js +4 -4
  43. package/lib/react-qw/components/ListBox.js +12 -12
  44. package/lib/react-qw/components/ListView.js +35 -35
  45. package/lib/react-qw/components/PathPicker.js +3 -3
  46. package/lib/react-qw/components/ProgressBar.js +4 -4
  47. package/lib/react-qw/components/RadioButton.js +4 -4
  48. package/lib/react-qw/components/ScrollView.js +28 -28
  49. package/lib/react-qw/components/Slider.js +11 -11
  50. package/lib/react-qw/components/Tab.js +10 -10
  51. package/lib/react-qw/components/Tooltip.js +8 -8
  52. package/lib/react-qw/components/TreeView.js +20 -20
  53. package/lib/react-qw/index.js +5 -5
  54. package/lib/react-qw/index.ts +6 -5
  55. package/lib/react-qw/props.js +4 -4
  56. package/lib/react-qw/props.ts +5 -4
  57. package/lib/react-qw/reconciler.js +10 -10
  58. package/lib/react-qw/reconciler.ts +11 -10
  59. package/lib/text-measure.js +3 -3
  60. package/lib/text-measure.ts +4 -3
  61. package/lib/websocket.js +17 -17
  62. package/lib/websocket.ts +18 -17
  63. package/lib/windows/advapi32.js +7 -7
  64. package/lib/windows/advapi32.ts +7 -7
  65. package/lib/windows/gdi32.js +17 -17
  66. package/lib/windows/gdi32.ts +17 -17
  67. package/lib/windows/kernel32.js +41 -2
  68. package/lib/windows/kernel32.ts +45 -2
  69. package/lib/windows/shell32.js +24 -3
  70. package/lib/windows/shell32.ts +27 -4
  71. package/lib/windows/structs.js +230 -92
  72. package/lib/windows/structs.ts +278 -92
  73. package/lib/windows/user32.js +18 -6
  74. package/lib/windows/user32.ts +18 -6
  75. package/lib/windows/winspool.js +3 -3
  76. package/lib/windows/winspool.ts +3 -3
  77. package/package.json +4 -3
  78. package/quickwin.d.ts +32 -10
  79. package/qwin-nowasm-x86.exe +0 -0
  80. package/qwin-nowasm.exe +0 -0
  81. package/qwin-x86.exe +0 -0
  82. package/qwin.exe +0 -0
  83. package/test/test_ffi_bind.js +12 -10
  84. package/test/test_ffi_bind.ts +14 -12
  85. package/test/test_ffi_struct.js +60 -26
  86. package/test/test_ffi_struct.ts +58 -28
  87. package/test/test_ffi_windows.js +1 -1
  88. package/test/test_ffi_windows.ts +1 -1
  89. package/test/test_files.js +7 -7
  90. package/test/test_files.ts +8 -8
  91. package/test/test_gdi_draw.js +51 -51
  92. package/test/test_gdi_draw.ts +57 -57
  93. package/test/test_http_server.js +3 -3
  94. package/test/test_http_server.ts +4 -3
  95. package/test/test_ipv6.js +3 -3
  96. package/test/test_ipv6.ts +4 -3
  97. package/test/test_net_event.js +3 -3
  98. package/test/test_net_event.ts +4 -3
  99. package/test/test_printer.js +4 -4
  100. package/test/test_printer.ts +5 -5
  101. package/test/test_react_complex.js +132 -130
  102. package/test/test_react_counter.js +130 -128
  103. package/test/test_react_flex.js +132 -130
  104. package/test/test_react_hidden.js +130 -128
  105. package/test/test_react_listview_link.js +131 -129
  106. package/test/test_react_notify.js +148 -146
  107. package/test/test_react_render.js +130 -128
  108. package/test/test_registry.js +6 -6
  109. package/test/test_registry.ts +8 -8
  110. package/test/test_wolfssl_read.js +8 -8
  111. package/test/test_wolfssl_read.ts +9 -8
  112. package/examples/exec_server.tsx +0 -488
  113. package/examples/exec_server_worker.js +0 -395
  114. package/examples/exec_server_worker.ts +0 -417
  115. package/examples/setres.js +0 -79
  116. package/examples/setres.ts +0 -86
  117. package/quickwin_const.d.ts +0 -1037
@@ -3,6 +3,7 @@ import * as wolfssl from 'wolfssl'
3
3
  import * as os from 'os'
4
4
  import * as std from 'std'
5
5
  import { Tester } from './test_helper.js'
6
+ import { FdEvent, ReturnCode, SniType, VerifyMode } from '../constants'
6
7
 
7
8
  const HTTPS_PORT = 18924
8
9
  const WANT_READ = 2
@@ -110,7 +111,7 @@ export const suite = {
110
111
  sock.set_on_event(s, (event: { lNetworkEvents: number; iErrorCode: number[] }) => {
111
112
  if (done) return
112
113
 
113
- if (event.lNetworkEvents & sock.FdEvent.FD_CONNECT) {
114
+ if (event.lNetworkEvents & FdEvent.FD_CONNECT) {
114
115
  if (event.iErrorCode[0] !== 0) {
115
116
  assert('connect error code 0', false)
116
117
  finish()
@@ -120,15 +121,15 @@ export const suite = {
120
121
  const method = wolfssl.wolfTLSv1_2_client_method()
121
122
  ctx = wolfssl.wolfSSL_CTX_new(method)
122
123
  if (!ctx) { assert('CTX_new', false); finish(); return }
123
- wolfssl.wolfSSL_CTX_set_verify(ctx, wolfssl.VerifyMode.SSL_VERIFY_NONE)
124
+ wolfssl.wolfSSL_CTX_set_verify(ctx, VerifyMode.SSL_VERIFY_NONE)
124
125
  ssl = wolfssl.wolfSSL_new(ctx)
125
126
  if (!ssl) { assert('SSL_new', false); finish(); return }
126
127
  wolfssl.wolfSSL_set_fd(ssl, sock.get_fd(s))
127
- wolfssl.wolfSSL_UseSNI(ssl, wolfssl.SniType.WOLFSSL_SNI_HOST_NAME, 'localhost')
128
+ wolfssl.wolfSSL_UseSNI(ssl, SniType.WOLFSSL_SNI_HOST_NAME, 'localhost')
128
129
  // kick handshake immediately (FD_WRITE may not re-fire)
129
130
  {
130
131
  const ret = wolfssl.wolfSSL_connect(ssl)
131
- if (ret === wolfssl.ReturnCode.SSL_SUCCESS) {
132
+ if (ret === ReturnCode.SSL_SUCCESS) {
132
133
  handshakeDone = true
133
134
  sendReq()
134
135
  } else {
@@ -141,12 +142,12 @@ export const suite = {
141
142
  }
142
143
  }
143
144
 
144
- if ((event.lNetworkEvents & sock.FdEvent.FD_READ) ||
145
- (event.lNetworkEvents & sock.FdEvent.FD_WRITE)) {
145
+ if ((event.lNetworkEvents & FdEvent.FD_READ) ||
146
+ (event.lNetworkEvents & FdEvent.FD_WRITE)) {
146
147
  if (!ssl) return
147
148
  if (!handshakeDone) {
148
149
  const ret = wolfssl.wolfSSL_connect(ssl)
149
- if (ret === wolfssl.ReturnCode.SSL_SUCCESS) {
150
+ if (ret === ReturnCode.SSL_SUCCESS) {
150
151
  handshakeDone = true
151
152
  sendReq()
152
153
  } else {
@@ -161,7 +162,7 @@ export const suite = {
161
162
  if (sentRequest) onReadable()
162
163
  }
163
164
 
164
- if (event.lNetworkEvents & sock.FdEvent.FD_CLOSE) {
165
+ if (event.lNetworkEvents & FdEvent.FD_CLOSE) {
165
166
  // drain remaining then finish
166
167
  if (ssl && sentRequest && !sawNonNumber) {
167
168
  onReadable()
@@ -1,488 +0,0 @@
1
- import { createServer } from '../lib/http-server.js'
2
- import * as os from 'os'
3
- import * as std from 'std'
4
- import * as gui from 'gui'
5
- import { RequestLog, type LogEntry } from '../lib/exec-log.js'
6
- import { createRoot, ListBox } from '../lib/react-qw/index.js'
7
- import { useEffect, useState } from 'react'
8
-
9
- // WORKER_DATA_URL 由 esbuild --define 在 build.ts 中注入(base64 data: URL)
10
- declare const WORKER_DATA_URL: string
11
-
12
- interface ExecHandle {
13
- stream: ReadableStream<Uint8Array>
14
- ready: Promise<{ ok: boolean; error?: string }>
15
- trailers: Promise<HeadersInit>
16
- kill: () => void
17
- }
18
-
19
- // ── CLI 参数 ──
20
-
21
- interface Cfg {
22
- /** 是否创建 GUI 日志窗口(--gui 0 关闭) */
23
- gui: boolean
24
- /** token 鉴权配置;null = 未配置(放行,兼容 CI) */
25
- token: string | null
26
- }
27
-
28
- // 参数经 scriptArgs 透传:C 层只消费 -e/-o/-d,遇到未知选项即停止
29
- // 解析并把剩余全部交给脚本(见 main.c 参数解析段)。因此空格分隔
30
- // ("--gui 0") 与等号 ("--gui=0") 两种形式都由本函数自行解析。
31
- function parseArgs(): Cfg {
32
- let gui = true
33
- let token: string | null = null
34
- const args = typeof scriptArgs === 'undefined' ? [] : scriptArgs
35
- const normToken = (v: string) => (v === '' ? null : v) // 空串=未配置(放行)
36
- for (let i = 0; i < args.length; i++) {
37
- const a = args[i] ?? ''
38
- if (a === '--nogui') { gui = false }
39
- else if (a === '--gui') { gui = (args[++i] ?? '') !== '0' }
40
- else if (a.startsWith('--gui=')) { gui = a.slice(6) !== '0' }
41
- else if (a === '--token') { token = normToken(args[++i] ?? '') }
42
- else if (a.startsWith('--token=')) { token = normToken(a.slice(8)) }
43
- }
44
- return { gui, token }
45
- }
46
-
47
- const cfg = parseArgs()
48
-
49
- // ── GUI 日志状态 ──
50
-
51
- const log = new RequestLog(100)
52
- let activeWorkers = 0
53
- let boundPort = 0
54
-
55
- type GuiUpdater = (lines: string[], info: string) => void
56
- let guiUpdater: GuiUpdater | null = null
57
- let guiTimer: ReturnType<typeof os.setTimeout> | null = null
58
-
59
- function two(n: number): string { return n < 10 ? '0' + String(n) : String(n) }
60
-
61
- function fmtTime(ts: number): string {
62
- const d = new Date(ts * 1000)
63
- return two(d.getHours()) + ':' + two(d.getMinutes()) + ':' + two(d.getSeconds())
64
- }
65
-
66
- function formatLine(e: LogEntry): string {
67
- let res: string
68
- if (e.status === 200) {
69
- res = e.exit === null ? 'ok' : (e.exit === 0 ? 'exit 0' : 'exit ' + e.exit)
70
- } else if (e.status === 401) {
71
- res = 'unauthorized'
72
- } else if (e.status === 404) {
73
- res = '404'
74
- } else if (e.status === 504) {
75
- res = 'timeout'
76
- } else {
77
- res = e.note ?? 'http ' + e.status
78
- }
79
- return fmtTime(e.ts) + ' · ' + e.cmd + ' · ' + res + ' (' + e.durMs + 'ms)'
80
- }
81
-
82
- function formatInfo(): string {
83
- return 'token: ' + (cfg.token !== null ? 'on' : 'off') + ' · port: ' + (boundPort || 8080) + ' · workers: ' + activeWorkers
84
- }
85
-
86
- // GUI 重渲必须离开 HTTP / worker 回调栈:react-qw 的 reconciler 是同步 flush,
87
- // 在请求处理中直接 setState 会与 http-server 状态机重入把事件循环锁死。
88
- // 统一丢到 timer 0 异步执行,并合并同一 tick 内的多次更新。
89
- function refreshGui(): void {
90
- if (!guiUpdater || guiTimer !== null) return
91
- guiTimer = os.setTimeout(() => {
92
- guiTimer = null
93
- const u = guiUpdater
94
- if (u) u(log.list().map(formatLine), formatInfo())
95
- }, 0)
96
- }
97
-
98
- function appendLog(e: LogEntry): void {
99
- log.push(e)
100
- refreshGui()
101
- }
102
-
103
- function bumpWorkers(d: number): void {
104
- activeWorkers += d
105
- refreshGui()
106
- }
107
-
108
- function initGui(): void {
109
- if (!cfg.gui) return
110
- try {
111
- const root = createRoot({
112
- text: 'exec_server · :' + (boundPort || 8080),
113
- x: 200, y: 200, width: 560, height: 420,
114
- })
115
- root.render(<LogPanel />)
116
- // run.bat 用 `start /min` 启动,这里显式还原,避免窗口只留一条标题栏
117
- gui.ShowWindow(root.hwnd, gui.ShowWindowCmd.RESTORE)
118
- } catch (ex) {
119
- // 无桌面/建窗失败时回退 headless,绝不影响服务
120
- console.error('[exec_server] gui init failed (fallback headless):', ex)
121
- }
122
- }
123
-
124
- function LogPanel() {
125
- const [lines, setLines] = useState<string[]>([])
126
- const [info, setInfo] = useState<string>(formatInfo())
127
-
128
- useEffect(() => {
129
- guiUpdater = (l, i) => { setLines(l); setInfo(i) }
130
- setLines(log.list().map(formatLine))
131
- setInfo(formatInfo())
132
- }, [])
133
-
134
- return (
135
- <w type="STATIC" ws={gui.WindowStyle.VISIBLE} style={{ flexDirection: 'column', gap: 4, flexGrow: 1 }}>
136
- <w type="STATIC" ws={gui.WindowStyle.VISIBLE} text={info} style={{ height: 24 }} />
137
- <ListBox items={lines} scrollToBottom style={{ flexGrow: 1 }} />
138
- </w>
139
- )
140
- }
141
-
142
- // token 鉴权:未配置 token 时放行(兼容现状);配置后 /exec、/screenshot、/window_list 需 Bearer。
143
- function authCheck(req: Request): Response | null {
144
- if (cfg.token === null) return null
145
- const h = req.headers.get('authorization')
146
- if (h !== null && h === 'Bearer ' + cfg.token) return null
147
- return jsonError(401, 'unauthorized')
148
- }
149
-
150
- // timeoutMs <= 0 / null/NaN → 无上限:连接活着命令就一直跑,断连才 kill。
151
- function runInWorker(cmd: string, signal: AbortSignal | null, timeoutMs: number | null): ExecHandle {
152
- // worker 内以 "system32\cmd.exe /c <cmd>" 启动,保留 cmd shell 语义(内部命令/管道/for),
153
- // cmd.exe 路径由 worker 用 GetSystemDirectoryW 拼(CreateProcess 直接 PATH 解析在本环境失败)。
154
- // 子进程树由 CreateProcessW 返回的 pid 定位,超时/断开时 taskkill /T 递归杀掉。
155
- const spawnCmd = cmd
156
- const worker = new os.Worker(WORKER_DATA_URL)
157
- bumpWorkers(1)
158
- const id = 1
159
- let innerPid: number | null = null
160
- let settled = false
161
- let readySettled = false
162
- let controller: ReadableStreamDefaultController<Uint8Array> | null = null
163
- let timer: ReturnType<typeof os.setTimeout> | null = null
164
-
165
- const settleReady = (v: { ok: boolean; error?: string }): void => {
166
- readySettled = true
167
- resolveReady(v)
168
- }
169
- const failReady = (e: Error): void => {
170
- readySettled = true
171
- rejectReady(e)
172
- }
173
-
174
- let resolveReady!: (v: { ok: boolean; error?: string }) => void
175
- let rejectReady!: (e: Error) => void
176
- let resolveTrailers!: (v: HeadersInit) => void
177
- const ready = new Promise<{ ok: boolean; error?: string }>((res, rej) => {
178
- resolveReady = res
179
- rejectReady = rej
180
- })
181
- const trailers = new Promise<HeadersInit>((res) => { resolveTrailers = res })
182
-
183
- const finish = (fn: () => void): void => {
184
- if (settled) return
185
- settled = true
186
- if (timer !== null) os.clearTimeout(timer)
187
- worker.onmessage = null
188
- bumpWorkers(-1)
189
- fn()
190
- }
191
-
192
- // kill 整棵树:CreateProcessW 返回的 pid 即 cmd.exe 根,taskkill /T 递归。
193
- // 杀掉后 worker 侧 ReadFile 断管道自动收尾。
194
- const kill = (): void => {
195
- if (innerPid === null) return
196
- try {
197
- const p = std.popen('taskkill /F /T /PID ' + innerPid, 'r')
198
- if (p) p.close()
199
- else console.error('[exec_server] taskkill spawn failed')
200
- } catch (ex) {
201
- console.error('[exec_server] kill error:', ex)
202
- }
203
- }
204
-
205
- // 客户端断连 → 立即 kill(不等 60s 超时)。abort 事件同步触发;
206
- // 若断连发生在 worker 尚未回报 innerPid 时无法 kill,则在 'info' 到达后补刀。
207
- const killOnAbort = (): void => { kill() }
208
- if (signal) {
209
- signal.addEventListener('abort', killOnAbort)
210
- if (signal.aborted) killOnAbort()
211
- }
212
-
213
- if (timeoutMs != null && timeoutMs > 0) {
214
- timer = os.setTimeout(() => {
215
- finish(() => {
216
- kill()
217
- resolveTrailers({ 'X-Exit-Code': '-1' })
218
- if (readySettled) {
219
- // 已有输出(200 已发):正常收尾(尾帧 + trailer -1)
220
- if (controller) controller.close()
221
- } else {
222
- // 尚无任何输出:Response 未发,安全地报超时
223
- failReady(new Error('exec timeout'))
224
- }
225
- })
226
- }, timeoutMs)
227
- }
228
-
229
- worker.onmessage = (e) => {
230
- const msg = e.data as { type: string; id?: number; chunk?: Uint8Array; code?: number; pid?: number; error?: string | null }
231
- if (msg.id !== undefined && msg.id !== id) return
232
- switch (msg.type) {
233
- case 'info':
234
- innerPid = msg.pid ?? null
235
- if (innerPid !== null && signal?.aborted) kill()
236
- break
237
- case 'data':
238
- settleReady({ ok: true })
239
- if (controller) controller.enqueue(msg.chunk as Uint8Array)
240
- break
241
- case 'result':
242
- finish(() => {
243
- if (controller) controller.close()
244
- if (msg.error) settleReady({ ok: false, error: msg.error })
245
- else settleReady({ ok: true })
246
- })
247
- resolveTrailers({ 'X-Exit-Code': String(msg.code ?? -1) })
248
- break
249
- }
250
- }
251
-
252
- const stream = new ReadableStream<Uint8Array>({
253
- start(c) { controller = c },
254
- cancel() {
255
- // http-server 在断连时主动 cancel 响应体 → 落这里 kill 子进程
256
- finish(() => kill())
257
- }
258
- })
259
-
260
- try {
261
- worker.postMessage({ type: 'run', id, cmd: spawnCmd })
262
- } catch (ex) {
263
- finish(() => {
264
- failReady(ex instanceof Error ? ex : new Error(String(ex)))
265
- resolveTrailers({ 'X-Exit-Code': '-1' })
266
- })
267
- }
268
-
269
- return { stream, ready, trailers, kill }
270
- }
271
-
272
- const SCREENSHOT_TIMEOUT_MS = 30_000
273
- const WINDOWS_TIMEOUT_MS = 5_000
274
-
275
- interface WindowEntry {
276
- hwnd: number
277
- title: string
278
- className: string
279
- rect: { left: number; top: number; right: number; bottom: number }
280
- visible: boolean
281
- minimized: boolean
282
- }
283
-
284
- interface WorkerResult {
285
- type: string
286
- id?: number
287
- chunk?: Uint8Array
288
- error?: string | null
289
- width?: number
290
- height?: number
291
- windows?: WindowEntry[]
292
- }
293
-
294
- // /screenshot 与 /window_list 复用同一个 worker(同一份 WORKER_DATA_URL),但消息类型不是 'run':
295
- // 它们不起子进程,也就没有 innerPid,/exec 那套 kill/超时树杀逻辑天然不适用。
296
- // 帧格式沿用 {type:'data',chunk} / {type:'result'},这里只是按类型攒块或直接取字段。
297
- function withWorker<R>(
298
- timeoutMs: number,
299
- onTimeout: () => R,
300
- post: Record<string, unknown>,
301
- onData: (chunk: Uint8Array) => void,
302
- onResult: (msg: WorkerResult) => R
303
- ): Promise<R> {
304
- return new Promise((resolve) => {
305
- const worker = new os.Worker(WORKER_DATA_URL)
306
- bumpWorkers(1)
307
- const id = 1
308
- let settled = false
309
- let timer: ReturnType<typeof os.setTimeout> | null = null
310
-
311
- const settle = (r: R): void => {
312
- if (settled) return
313
- settled = true
314
- if (timer !== null) os.clearTimeout(timer)
315
- worker.onmessage = null
316
- bumpWorkers(-1)
317
- resolve(r)
318
- }
319
-
320
- // 兜底:worker 若静默挂掉(绑定失败等)不会发任何消息,避免请求永久悬挂。
321
- timer = os.setTimeout(() => settle(onTimeout()), timeoutMs)
322
-
323
- worker.onmessage = (e) => {
324
- const msg = e.data as WorkerResult
325
- if (msg.id !== undefined && msg.id !== id) return
326
- if (msg.type === 'data') { onData(msg.chunk as Uint8Array); return }
327
- if (msg.type !== 'result') return
328
- settle(onResult(msg))
329
- }
330
-
331
- try {
332
- worker.postMessage(post)
333
- } catch (ex) {
334
- settle(onResult({ type: 'error', error: 'spawn: ' + String(ex) }))
335
- }
336
- })
337
- }
338
-
339
- function jsonError(status: number, msg: string): Response {
340
- return new Response(JSON.stringify({ error: msg }), {
341
- status,
342
- headers: { 'Content-Type': 'application/json' }
343
- })
344
- }
345
-
346
- interface ShotOpts {
347
- hwnd: number | null
348
- area: 'window' | 'client'
349
- mode: 'screen' | 'print'
350
- }
351
-
352
- function parseShotOpts(params: URLSearchParams): ShotOpts {
353
- const area = params.get('area') ?? 'window'
354
- const mode = params.get('mode') ?? 'screen'
355
- if (area !== 'window' && area !== 'client') throw new Error('area must be "window" or "client"')
356
- if (mode !== 'screen' && mode !== 'print') throw new Error('mode must be "screen" or "print"')
357
-
358
- const raw = params.get('hwnd')
359
- if (raw === null || raw === '') return { hwnd: null, area, mode }
360
- const hwnd = Number(raw)
361
- if (!Number.isInteger(hwnd) || hwnd <= 0) throw new Error('hwnd must be a positive integer')
362
- return { hwnd, area, mode }
363
- }
364
-
365
- function windowsResponse(): Promise<Response> {
366
- return withWorker(WINDOWS_TIMEOUT_MS, () => jsonError(504, 'windows timeout'),
367
- { type: 'windows', id: 1 },
368
- () => { },
369
- (msg) => msg.error
370
- ? jsonError(500, msg.error)
371
- : new Response(JSON.stringify(msg.windows ?? []), { headers: { 'Content-Type': 'application/json' } }))
372
- }
373
-
374
- function screenshotResponse(opts: ShotOpts): Promise<Response> {
375
- const parts: Uint8Array[] = []
376
- let total = 0
377
- const post: Record<string, unknown> = { type: 'screenshot', id: 1 }
378
- if (opts.hwnd !== null) {
379
- post.hwnd = opts.hwnd
380
- post.area = opts.area
381
- post.mode = opts.mode
382
- }
383
- return withWorker(SCREENSHOT_TIMEOUT_MS, () => jsonError(504, 'screenshot timeout'), post,
384
- (c) => { parts.push(c); total += c.byteLength },
385
- (msg) => {
386
- if (msg.error) return jsonError(500, msg.error)
387
- if (total === 0) return jsonError(500, 'empty capture')
388
- const buf = new Uint8Array(total)
389
- let off = 0
390
- for (const p of parts) { buf.set(p, off); off += p.byteLength }
391
- const dims = msg.width !== undefined && msg.height !== undefined
392
- ? msg.width + 'x' + msg.height
393
- : ''
394
- return new Response(buf, {
395
- headers: { 'Content-Type': 'image/bmp', 'X-Dimensions': dims }
396
- })
397
- })
398
- }
399
-
400
- const server = createServer(async (req) => {
401
- const t0 = Date.now()
402
- const path = new URL(req.url).pathname
403
- if (req.method === 'GET' && (path === '/' || path === '/health')) {
404
- return new Response('ok', { headers: { 'Content-Type': 'text/plain' } })
405
- }
406
- const denied = authCheck(req)
407
- if (denied !== null) {
408
- appendLog({ ts: Date.now() / 1000, cmd: req.method + ' ' + path, status: 401, exit: null, durMs: Date.now() - t0, note: 'missing/invalid token' })
409
- return denied
410
- }
411
- if (req.method === 'GET' && path === '/window_list') {
412
- const resp = await windowsResponse()
413
- appendLog({ ts: Date.now() / 1000, cmd: 'GET /window_list', status: resp.status, exit: null, durMs: Date.now() - t0 })
414
- return resp
415
- }
416
- if (req.method === 'GET' && path === '/screenshot') {
417
- let opts: ShotOpts
418
- try {
419
- opts = parseShotOpts(new URL(req.url).searchParams)
420
- } catch (ex) {
421
- appendLog({ ts: Date.now() / 1000, cmd: 'GET /screenshot', status: 400, exit: null, durMs: Date.now() - t0, note: String(ex) })
422
- return jsonError(400, String(ex))
423
- }
424
- const resp = await screenshotResponse(opts)
425
- const target = opts.hwnd !== null ? ' hwnd=' + opts.hwnd + ' ' + opts.area + '/' + opts.mode : ' screen'
426
- appendLog({ ts: Date.now() / 1000, cmd: 'GET /screenshot' + target, status: resp.status, exit: null, durMs: Date.now() - t0 })
427
- return resp
428
- }
429
- if (req.method === 'POST' && path === '/exec') {
430
- let out: ExecHandle
431
- let cmd: string
432
- try {
433
- const body = await req.json() as { cmd: string; timeout?: unknown }
434
- const t = body.timeout
435
- const timeoutMs: number | null = typeof t === 'number' && Number.isFinite(t) && t > 0
436
- ? t
437
- : null
438
- cmd = body.cmd
439
- out = runInWorker(body.cmd, req.signal, timeoutMs)
440
- } catch (ex) {
441
- return new Response(JSON.stringify({ error: 'setup: ' + String(ex) }), {
442
- status: 500,
443
- headers: { 'Content-Type': 'application/json' }
444
- })
445
- }
446
- try {
447
- const rd = await out.ready
448
- if (!rd.ok) {
449
- appendLog({ ts: Date.now() / 1000, cmd, status: 500, exit: null, durMs: Date.now() - t0, note: rd.error })
450
- return new Response(JSON.stringify({ error: rd.error }), {
451
- status: 500,
452
- headers: { 'Content-Type': 'application/json' }
453
- })
454
- }
455
- out.trailers.then((h) => {
456
- const code = (h as Record<string, string>)['X-Exit-Code']
457
- appendLog({
458
- ts: Date.now() / 1000,
459
- cmd,
460
- status: 200,
461
- exit: code === undefined ? null : Number(code),
462
- durMs: Date.now() - t0,
463
- })
464
- }).catch(() => { })
465
- return new Response(out.stream, {
466
- headers: {
467
- 'Content-Type': 'application/octet-stream',
468
- 'Trailer': 'X-Exit-Code',
469
- },
470
- trailers: out.trailers
471
- })
472
- } catch (ex) {
473
- appendLog({ ts: Date.now() / 1000, cmd, status: 504, exit: null, durMs: Date.now() - t0, note: 'exec timeout' })
474
- return new Response(JSON.stringify({ error: String(ex) }), {
475
- status: 504,
476
- headers: { 'Content-Type': 'application/json' }
477
- })
478
- }
479
- }
480
- appendLog({ ts: Date.now() / 1000, cmd: req.method + ' ' + path, status: 404, exit: null, durMs: Date.now() - t0 })
481
- return new Response('Not Found', { status: 404 })
482
- })
483
-
484
- const rc = server.listen(8080, '0.0.0.0')
485
- console.log('listen rc=' + rc + ' addr=' + JSON.stringify(server.address()))
486
- const addr = server.address()
487
- if (addr) boundPort = addr.port
488
- initGui()