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