quickwin 1.3.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +18 -24
- package/README.md +17 -23
- package/examples/PdfCanvas.js +8 -37
- package/examples/PdfCanvas.tsx +11 -41
- package/examples/exec_server.js +20155 -1420
- package/examples/exec_server.tsx +489 -0
- package/examples/exec_server_worker.js +383 -33
- package/examples/exec_server_worker.ts +408 -30
- package/examples/pdf_preview2.js +25 -82
- package/examples/pdf_preview2.ts +27 -84
- package/examples/pdf_viewer.js +1424 -1180
- package/examples/pdf_viewer.tsx +16 -67
- package/examples/setres.js +22 -47
- package/examples/setres.ts +23 -57
- package/examples/test_gallery.js +1418 -1075
- package/examples/test_react_lazy.js +1391 -1059
- package/examples/test_react_listbox.js +1390 -1058
- package/examples/test_root_window.js +1384 -1052
- package/examples/test_tab.js +1391 -1059
- package/lib/abort.js +70 -0
- package/lib/abort.ts +69 -0
- package/lib/certs.js +219 -0
- package/lib/certs.ts +229 -0
- package/lib/exec-log.js +15 -0
- package/lib/exec-log.ts +30 -0
- package/lib/fetch.js +88 -7
- package/lib/fetch.ts +73 -7
- package/lib/ffi/bind.js +441 -0
- package/lib/ffi/bind.ts +539 -0
- package/lib/ffi/ctype.js +194 -0
- package/lib/ffi/ctype.ts +369 -0
- package/lib/ffi/index.js +6 -0
- package/lib/ffi/index.ts +6 -0
- package/lib/ffi/struct.js +490 -0
- package/lib/ffi/struct.ts +753 -0
- package/lib/http-common.js +43 -5
- package/lib/http-common.ts +48 -6
- package/lib/http-server.js +187 -29
- package/lib/http-server.ts +177 -26
- package/lib/react-qw/components/DateTimePicker.js +18 -37
- package/lib/react-qw/components/Input.js +3 -10
- package/lib/react-qw/components/Link.js +6 -19
- package/lib/react-qw/components/ListView.js +50 -154
- package/lib/react-qw/components/PathPicker.js +28 -88
- package/lib/react-qw/components/ScrollView.js +15 -8
- package/lib/react-qw/components/Slider.js +2 -1
- package/lib/react-qw/components/Tab.js +6 -22
- package/lib/react-qw/components/Tooltip.js +10 -21
- package/lib/react-qw/components/TreeView.js +8 -31
- package/lib/react-qw/index.js +5 -3
- package/lib/react-qw/index.ts +8 -3
- package/lib/react-qw/nmhdr.js +3 -24
- package/lib/react-qw/nmhdr.ts +5 -30
- package/lib/react-qw/props.js +7 -2
- package/lib/react-qw/props.ts +7 -3
- package/lib/react-qw/reconciler.js +23 -13
- package/lib/react-qw/reconciler.ts +22 -13
- package/lib/text-measure.js +20 -23
- package/lib/text-measure.ts +22 -26
- package/lib/websocket.js +44 -4
- package/lib/websocket.ts +40 -4
- package/lib/windows/advapi32.js +53 -0
- package/lib/windows/advapi32.ts +64 -0
- package/lib/windows/comctl32.js +18 -0
- package/lib/windows/comctl32.ts +19 -0
- package/lib/windows/comdlg32.js +8 -0
- package/lib/windows/comdlg32.ts +10 -0
- package/lib/windows/gdi32.js +392 -0
- package/lib/windows/gdi32.ts +420 -0
- package/lib/windows/kernel32.js +262 -0
- package/lib/windows/kernel32.ts +291 -0
- package/lib/windows/msimg32.js +38 -0
- package/lib/windows/msimg32.ts +45 -0
- package/lib/windows/ntdll.js +9 -0
- package/lib/windows/ntdll.ts +11 -0
- package/lib/windows/ole32.js +7 -0
- package/lib/windows/ole32.ts +9 -0
- package/lib/windows/psapi.js +32 -0
- package/lib/windows/psapi.ts +37 -0
- package/lib/windows/shell32.js +13 -0
- package/lib/windows/shell32.ts +16 -0
- package/lib/windows/structs.js +519 -0
- package/lib/windows/structs.ts +566 -0
- package/lib/windows/user32.js +572 -0
- package/lib/windows/user32.ts +607 -0
- package/lib/windows/version.js +29 -0
- package/lib/windows/version.ts +35 -0
- package/lib/windows/winspool.js +71 -0
- package/lib/windows/winspool.ts +80 -0
- package/package.json +1 -1
- package/quickwin.d.ts +31 -40
- package/quickwin_const.d.ts +137 -0
- package/qwin-nowasm-x86.exe +0 -0
- package/qwin-nowasm.exe +0 -0
- package/qwin-x86.exe +0 -0
- package/qwin.exe +0 -0
- package/test/bench_ffi_callraw.js +236 -0
- package/test/bench_ffi_callraw.ts +236 -0
- package/test/bench_ffi_overhead.js +139 -0
- package/test/bench_ffi_overhead.ts +141 -0
- package/test/bench_sendmessage.js +104 -0
- package/test/bench_sendmessage.ts +100 -0
- package/test/bench_sendmessage_nonnull.js +187 -0
- package/test/bench_sendmessage_nonnull.ts +188 -0
- package/test/run.js +19 -4
- package/test/run.ts +20 -5
- package/test/test_basic_fetch.js +29 -0
- package/test/test_basic_fetch.ts +26 -0
- package/test/test_ffi.js +27 -45
- package/test/test_ffi.ts +35 -54
- package/test/test_ffi_abi.js +290 -0
- package/test/test_ffi_abi.ts +274 -0
- package/test/test_ffi_array.js +116 -0
- package/test/test_ffi_array.ts +141 -0
- package/test/test_ffi_bind.js +620 -8
- package/test/test_ffi_bind.ts +630 -8
- package/test/test_ffi_struct.js +632 -88
- package/test/test_ffi_struct.ts +666 -91
- package/test/test_ffi_struct_layout.js +495 -0
- package/test/test_ffi_struct_layout.ts +543 -0
- package/test/test_ffi_user32.js +103 -0
- package/test/test_ffi_user32.ts +113 -0
- package/test/test_ffi_windows.js +101 -0
- package/test/test_ffi_windows.ts +113 -0
- package/test/test_files.js +102 -0
- package/test/test_files.ts +121 -0
- package/test/test_gdi_draw.js +681 -0
- package/test/test_gdi_draw.ts +725 -0
- package/test/test_helper.js +8 -1
- package/test/test_helper.ts +9 -1
- package/test/test_http_import.js +18 -0
- package/test/test_http_import.ts +19 -0
- package/test/test_http_server.js +73 -1
- package/test/test_http_server.ts +75 -1
- package/test/test_ipv6.js +2 -2
- package/test/test_ipv6.ts +2 -2
- package/test/test_net_fetch.js +74 -11
- package/test/test_net_fetch.ts +73 -10
- package/test/test_net_websocket.js +1 -1
- package/test/test_net_websocket.ts +1 -1
- package/test/test_printer.js +137 -0
- package/test/test_printer.ts +148 -0
- package/test/test_proc.js +141 -0
- package/test/test_proc.ts +163 -0
- package/test/test_react_complex.js +1401 -1069
- package/test/test_react_counter.js +1391 -1059
- package/test/test_react_flex.js +1411 -1072
- package/test/test_react_hidden.js +1394 -1062
- package/test/test_react_listview_link.js +1391 -1059
- package/test/test_react_notify.js +18638 -0
- package/test/test_react_render.js +1391 -1059
- package/test/test_registry.js +74 -0
- package/test/test_registry.ts +95 -0
- package/test/test_sysinfo.js +122 -0
- package/test/test_sysinfo.ts +141 -0
- package/test/test_wolfssl_read.js +45 -43
- package/test/test_wolfssl_read.ts +42 -40
- package/test/test_worker_net.js +35 -17
- package/test/test_worker_net.ts +27 -15
- package/tsconfig.json +1 -1
- package/examples/exec_server.ts +0 -80
- package/lib/ffi-bind.js +0 -85
- package/lib/ffi-bind.ts +0 -170
- package/lib/ffi-struct.js +0 -224
- package/lib/ffi-struct.ts +0 -239
- package/lib/win-common-structs.js +0 -39
- package/lib/win-common-structs.ts +0 -41
|
@@ -0,0 +1,489 @@
|
|
|
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()
|