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.
- package/README.en.md +8 -8
- package/README.md +6 -7
- package/examples/{exec_server.js → execd.js} +618 -228
- package/examples/execd.tsx +798 -0
- package/examples/execd_worker.js +122 -0
- package/examples/execd_worker.ts +127 -0
- package/examples/pdf_viewer.js +100 -111
- package/examples/test_gallery.js +100 -111
- package/examples/test_react_lazy.js +96 -109
- package/examples/test_react_listbox.js +96 -109
- package/examples/test_root_window.js +96 -109
- package/examples/test_tab.js +96 -109
- package/lib/ffi/ctype.ts +3 -1
- package/lib/ffi/struct.js +23 -7
- package/lib/ffi/struct.ts +50 -23
- package/lib/http-server.js +1 -1
- package/lib/http-server.ts +1 -1
- package/lib/windows/gdi32.js +1 -1
- package/lib/windows/gdi32.ts +1 -1
- package/lib/windows/structs.js +176 -92
- package/lib/windows/structs.ts +216 -92
- package/package.json +1 -1
- package/quickwin_const.d.ts +26 -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/test_ffi_bind.js +5 -3
- package/test/test_ffi_bind.ts +6 -4
- package/test/test_ffi_struct.js +58 -24
- package/test/test_ffi_struct.ts +55 -25
- package/test/test_gdi_draw.js +1 -1
- package/test/test_gdi_draw.ts +1 -1
- package/test/test_react_complex.js +96 -109
- package/test/test_react_counter.js +96 -109
- package/test/test_react_flex.js +96 -109
- package/test/test_react_hidden.js +96 -109
- package/test/test_react_listview_link.js +96 -109
- package/test/test_react_notify.js +96 -109
- package/test/test_react_render.js +96 -109
- package/examples/exec_server.tsx +0 -489
- package/examples/exec_server_worker.js +0 -395
- package/examples/exec_server_worker.ts +0 -417
- package/examples/setres.js +0 -79
- package/examples/setres.ts +0 -86
|
@@ -0,0 +1,798 @@
|
|
|
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
|
+
import { closure, WCHAR } from '../lib/ffi/bind.js'
|
|
9
|
+
import { NULL, type Ptr, PtrArrayBuffer } from '../lib/ffi/ctype.js'
|
|
10
|
+
import {
|
|
11
|
+
ChangeDisplaySettings, ClientToScreen, EnumDisplaySettings, EnumWindows, GetClassName,
|
|
12
|
+
GetClientRect, GetDC, GetSystemMetrics, GetWindowRect, GetWindowText, IsIconic,
|
|
13
|
+
IsWindowVisible, PrintWindow, ReleaseDC,
|
|
14
|
+
} from '../lib/windows/user32.js'
|
|
15
|
+
import {
|
|
16
|
+
BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, SelectObject,
|
|
17
|
+
} from '../lib/windows/gdi32.js'
|
|
18
|
+
import { BITMAPFILEHEADER, BITMAPINFOHEADER, DEVMODEW, POINT, RECT } from '../lib/windows/structs.js'
|
|
19
|
+
|
|
20
|
+
// WORKER_DATA_URL 由 esbuild --define 在 build.ts 中注入(base64 data: URL)
|
|
21
|
+
declare const WORKER_DATA_URL: string
|
|
22
|
+
|
|
23
|
+
interface ExecHandle {
|
|
24
|
+
stream: ReadableStream<Uint8Array>
|
|
25
|
+
ready: Promise<{ ok: boolean; error?: string }>
|
|
26
|
+
trailers: Promise<HeadersInit>
|
|
27
|
+
kill: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── CLI 参数 ──
|
|
31
|
+
|
|
32
|
+
interface Cfg {
|
|
33
|
+
/** 是否创建 GUI 日志窗口(--gui 0 关闭) */
|
|
34
|
+
gui: boolean
|
|
35
|
+
/** token 鉴权配置;null = 未配置(放行,兼容 CI) */
|
|
36
|
+
token: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 参数经 scriptArgs 透传:C 层只消费 -e/-o/-d,遇到未知选项即停止
|
|
40
|
+
// 解析并把剩余全部交给脚本(见 main.c 参数解析段)。因此空格分隔
|
|
41
|
+
// ("--gui 0") 与等号 ("--gui=0") 两种形式都由本函数自行解析。
|
|
42
|
+
function parseArgs(): Cfg {
|
|
43
|
+
let gui = true
|
|
44
|
+
let token: string | null = null
|
|
45
|
+
const args = typeof scriptArgs === 'undefined' ? [] : scriptArgs
|
|
46
|
+
const normToken = (v: string) => (v === '' ? null : v) // 空串=未配置(放行)
|
|
47
|
+
for (let i = 0; i < args.length; i++) {
|
|
48
|
+
const a = args[i] ?? ''
|
|
49
|
+
if (a === '--nogui') { gui = false }
|
|
50
|
+
else if (a === '--gui') { gui = (args[++i] ?? '') !== '0' }
|
|
51
|
+
else if (a.startsWith('--gui=')) { gui = a.slice(6) !== '0' }
|
|
52
|
+
else if (a === '--token') { token = normToken(args[++i] ?? '') }
|
|
53
|
+
else if (a.startsWith('--token=')) { token = normToken(a.slice(8)) }
|
|
54
|
+
}
|
|
55
|
+
return { gui, token }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cfg = parseArgs()
|
|
59
|
+
|
|
60
|
+
// ── GUI 日志状态 ──
|
|
61
|
+
|
|
62
|
+
const log = new RequestLog(100)
|
|
63
|
+
let activeWorkers = 0
|
|
64
|
+
let boundPort = 0
|
|
65
|
+
|
|
66
|
+
type GuiUpdater = (lines: string[], info: string) => void
|
|
67
|
+
let guiUpdater: GuiUpdater | null = null
|
|
68
|
+
let guiTimer: ReturnType<typeof os.setTimeout> | null = null
|
|
69
|
+
|
|
70
|
+
function two(n: number): string { return n < 10 ? '0' + String(n) : String(n) }
|
|
71
|
+
|
|
72
|
+
function fmtTime(ts: number): string {
|
|
73
|
+
const d = new Date(ts * 1000)
|
|
74
|
+
return two(d.getHours()) + ':' + two(d.getMinutes()) + ':' + two(d.getSeconds())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatLine(e: LogEntry): string {
|
|
78
|
+
let res: string
|
|
79
|
+
if (e.status === 200) {
|
|
80
|
+
res = e.exit === null ? 'ok' : (e.exit === 0 ? 'exit 0' : 'exit ' + e.exit)
|
|
81
|
+
} else if (e.status === 401) {
|
|
82
|
+
res = 'unauthorized'
|
|
83
|
+
} else if (e.status === 504) {
|
|
84
|
+
res = 'timeout'
|
|
85
|
+
} else {
|
|
86
|
+
res = e.note ?? 'http ' + e.status
|
|
87
|
+
}
|
|
88
|
+
return fmtTime(e.ts) + ' · ' + e.cmd + ' · ' + res + ' (' + e.durMs + 'ms)'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatInfo(): string {
|
|
92
|
+
return 'token: ' + (cfg.token !== null ? 'on' : 'off') + ' · port: ' + (boundPort || 8080) + ' · workers: ' + activeWorkers
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// GUI 重渲必须离开 HTTP / worker 回调栈:react-qw 的 reconciler 是同步 flush,
|
|
96
|
+
// 在请求处理中直接 setState 会与 http-server 状态机重入把事件循环锁死。
|
|
97
|
+
// 统一丢到 timer 0 异步执行,并合并同一 tick 内的多次更新。
|
|
98
|
+
function refreshGui(): void {
|
|
99
|
+
if (!guiUpdater || guiTimer !== null) return
|
|
100
|
+
guiTimer = os.setTimeout(() => {
|
|
101
|
+
guiTimer = null
|
|
102
|
+
const u = guiUpdater
|
|
103
|
+
if (u) u(log.list().map(formatLine), formatInfo())
|
|
104
|
+
}, 0)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function appendLog(e: LogEntry): void {
|
|
108
|
+
log.push(e)
|
|
109
|
+
refreshGui()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 路由统一记日志(收敛各路由手写的 ts/durMs 样板);exit 只在 /exec 完成时非 null
|
|
113
|
+
function logReq(t0: number, cmd: string, status: number, note?: string, exit: number | null = null): void {
|
|
114
|
+
appendLog({ ts: Date.now() / 1000, cmd, status, exit, durMs: Date.now() - t0, note })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function bumpWorkers(d: number): void {
|
|
118
|
+
activeWorkers += d
|
|
119
|
+
refreshGui()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function initGui(): void {
|
|
123
|
+
if (!cfg.gui) return
|
|
124
|
+
try {
|
|
125
|
+
const root = createRoot({
|
|
126
|
+
text: 'execd · :' + (boundPort || 8080),
|
|
127
|
+
x: 200, y: 200, width: 560, height: 420,
|
|
128
|
+
})
|
|
129
|
+
root.render(<LogPanel />)
|
|
130
|
+
// run.bat 用 `start /min` 启动,这里显式还原,避免窗口只留一条标题栏
|
|
131
|
+
gui.ShowWindow(root.hwnd, gui.ShowWindowCmd.RESTORE)
|
|
132
|
+
} catch (ex) {
|
|
133
|
+
// 无桌面/建窗失败时回退 headless,绝不影响服务
|
|
134
|
+
console.error('[execd] gui init failed (fallback headless):', ex)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function LogPanel() {
|
|
139
|
+
const [lines, setLines] = useState<string[]>([])
|
|
140
|
+
const [info, setInfo] = useState<string>(formatInfo())
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
guiUpdater = (l, i) => { setLines(l); setInfo(i) }
|
|
144
|
+
setLines(log.list().map(formatLine))
|
|
145
|
+
setInfo(formatInfo())
|
|
146
|
+
}, [])
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<w type="STATIC" ws={gui.WindowStyle.VISIBLE} style={{ flexDirection: 'column', gap: 4, flexGrow: 1 }}>
|
|
150
|
+
<w type="STATIC" ws={gui.WindowStyle.VISIBLE} text={info} style={{ height: 24 }} />
|
|
151
|
+
<ListBox items={lines} scrollToBottom style={{ flexGrow: 1 }} />
|
|
152
|
+
</w>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// token 鉴权:未配置 token 时放行(兼容现状);配置后 /exec、/screenshot、/window_list 需 Bearer。
|
|
157
|
+
function authCheck(req: Request): Response | null {
|
|
158
|
+
if (cfg.token === null) return null
|
|
159
|
+
const h = req.headers.get('authorization')
|
|
160
|
+
if (h !== null && h === 'Bearer ' + cfg.token) return null
|
|
161
|
+
return jsonError(401, 'unauthorized')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// timeoutMs <= 0 / null/NaN → 无上限:连接活着命令就一直跑,断连才 kill。
|
|
165
|
+
function runInWorker(cmd: string, signal: AbortSignal | null, timeoutMs: number | null): ExecHandle {
|
|
166
|
+
// worker 内以 "system32\cmd.exe /c <cmd>" 启动,保留 cmd shell 语义(内部命令/管道/for),
|
|
167
|
+
// cmd.exe 路径由 worker 用 GetSystemDirectoryW 拼(CreateProcess 直接 PATH 解析在本环境失败)。
|
|
168
|
+
// 子进程树由 CreateProcessW 返回的 pid 定位,超时/断开时 taskkill /T 递归杀掉。
|
|
169
|
+
const spawnCmd = cmd
|
|
170
|
+
const worker = new os.Worker(WORKER_DATA_URL)
|
|
171
|
+
bumpWorkers(1)
|
|
172
|
+
const id = 1
|
|
173
|
+
let innerPid: number | null = null
|
|
174
|
+
let settled = false
|
|
175
|
+
let readySettled = false
|
|
176
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null
|
|
177
|
+
let timer: ReturnType<typeof os.setTimeout> | null = null
|
|
178
|
+
|
|
179
|
+
const settleReady = (v: { ok: boolean; error?: string }): void => {
|
|
180
|
+
readySettled = true
|
|
181
|
+
resolveReady(v)
|
|
182
|
+
}
|
|
183
|
+
const failReady = (e: Error): void => {
|
|
184
|
+
readySettled = true
|
|
185
|
+
rejectReady(e)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let resolveReady!: (v: { ok: boolean; error?: string }) => void
|
|
189
|
+
let rejectReady!: (e: Error) => void
|
|
190
|
+
let resolveTrailers!: (v: HeadersInit) => void
|
|
191
|
+
const ready = new Promise<{ ok: boolean; error?: string }>((res, rej) => {
|
|
192
|
+
resolveReady = res
|
|
193
|
+
rejectReady = rej
|
|
194
|
+
})
|
|
195
|
+
const trailers = new Promise<HeadersInit>((res) => { resolveTrailers = res })
|
|
196
|
+
|
|
197
|
+
const finish = (fn: () => void): void => {
|
|
198
|
+
if (settled) return
|
|
199
|
+
settled = true
|
|
200
|
+
if (timer !== null) os.clearTimeout(timer)
|
|
201
|
+
worker.onmessage = null
|
|
202
|
+
bumpWorkers(-1)
|
|
203
|
+
fn()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// kill 整棵树:CreateProcessW 返回的 pid 即 cmd.exe 根,taskkill /T 递归。
|
|
207
|
+
// 杀掉后 worker 侧 ReadFile 断管道自动收尾。
|
|
208
|
+
const kill = (): void => {
|
|
209
|
+
if (innerPid === null) return
|
|
210
|
+
try {
|
|
211
|
+
const p = std.popen('taskkill /F /T /PID ' + innerPid, 'r')
|
|
212
|
+
if (p) p.close()
|
|
213
|
+
else console.error('[execd] taskkill spawn failed')
|
|
214
|
+
} catch (ex) {
|
|
215
|
+
console.error('[execd] kill error:', ex)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 客户端断连 → 立即 kill(不等 60s 超时)。abort 事件同步触发;
|
|
220
|
+
// 若断连发生在 worker 尚未回报 innerPid 时无法 kill,则在 'info' 到达后补刀。
|
|
221
|
+
const killOnAbort = (): void => { kill() }
|
|
222
|
+
if (signal) {
|
|
223
|
+
signal.addEventListener('abort', killOnAbort)
|
|
224
|
+
if (signal.aborted) killOnAbort()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (timeoutMs != null && timeoutMs > 0) {
|
|
228
|
+
timer = os.setTimeout(() => {
|
|
229
|
+
finish(() => {
|
|
230
|
+
kill()
|
|
231
|
+
resolveTrailers({ 'X-Exit-Code': '-1' })
|
|
232
|
+
if (readySettled) {
|
|
233
|
+
// 已有输出(200 已发):正常收尾(尾帧 + trailer -1)
|
|
234
|
+
if (controller) controller.close()
|
|
235
|
+
} else {
|
|
236
|
+
// 尚无任何输出:Response 未发,安全地报超时
|
|
237
|
+
failReady(new Error('exec timeout'))
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
}, timeoutMs)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
worker.onmessage = (e) => {
|
|
244
|
+
const msg = e.data as { type: string; id?: number; chunk?: Uint8Array; code?: number; pid?: number; error?: string | null }
|
|
245
|
+
if (msg.id !== undefined && msg.id !== id) return
|
|
246
|
+
switch (msg.type) {
|
|
247
|
+
case 'info':
|
|
248
|
+
innerPid = msg.pid ?? null
|
|
249
|
+
if (innerPid !== null && signal?.aborted) kill()
|
|
250
|
+
break
|
|
251
|
+
case 'data':
|
|
252
|
+
settleReady({ ok: true })
|
|
253
|
+
if (controller) controller.enqueue(msg.chunk as Uint8Array)
|
|
254
|
+
break
|
|
255
|
+
case 'result':
|
|
256
|
+
finish(() => {
|
|
257
|
+
if (controller) controller.close()
|
|
258
|
+
if (msg.error) settleReady({ ok: false, error: msg.error })
|
|
259
|
+
else settleReady({ ok: true })
|
|
260
|
+
})
|
|
261
|
+
resolveTrailers({ 'X-Exit-Code': String(msg.code ?? -1) })
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
267
|
+
start(c) { controller = c },
|
|
268
|
+
cancel() {
|
|
269
|
+
// http-server 在断连时主动 cancel 响应体 → 落这里 kill 子进程
|
|
270
|
+
finish(() => kill())
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
worker.postMessage({ type: 'run', id, cmd: spawnCmd })
|
|
276
|
+
} catch (ex) {
|
|
277
|
+
finish(() => {
|
|
278
|
+
failReady(ex instanceof Error ? ex : new Error(String(ex)))
|
|
279
|
+
resolveTrailers({ 'X-Exit-Code': '-1' })
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { stream, ready, trailers, kill }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface WindowEntry {
|
|
287
|
+
hwnd: number
|
|
288
|
+
title: string
|
|
289
|
+
className: string
|
|
290
|
+
rect: { left: number; top: number; right: number; bottom: number }
|
|
291
|
+
visible: boolean
|
|
292
|
+
minimized: boolean
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function jsonError(status: number, msg: string): Response {
|
|
296
|
+
return new Response(JSON.stringify({ error: msg }), {
|
|
297
|
+
status,
|
|
298
|
+
headers: { 'Content-Type': 'application/json' }
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface ShotOpts {
|
|
303
|
+
hwnd: number | null
|
|
304
|
+
area: 'window' | 'client'
|
|
305
|
+
mode: 'screen' | 'print'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseShotOpts(params: URLSearchParams): ShotOpts {
|
|
309
|
+
const area = params.get('area') ?? 'window'
|
|
310
|
+
if (area !== 'window' && area !== 'client') throw new Error('area must be "window" or "client"')
|
|
311
|
+
const rawMode = params.get('mode')
|
|
312
|
+
if (rawMode !== null && rawMode !== 'screen' && rawMode !== 'print') {
|
|
313
|
+
throw new Error('mode must be "screen" or "print"')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const raw = params.get('hwnd')
|
|
317
|
+
const hwnd = raw === null || raw === '' ? null : Number(raw)
|
|
318
|
+
if (hwnd !== null && (!Number.isInteger(hwnd) || hwnd <= 0)) {
|
|
319
|
+
throw new Error('hwnd must be a positive integer')
|
|
320
|
+
}
|
|
321
|
+
// 缺省 mode 随 hwnd 变:带 hwnd 默认 print——PrintWindow 让窗口自绘到内存 DC,
|
|
322
|
+
// 遮挡 / 最小化 / 超出屏幕都拿完整像素;screen 只会截可见部分并静默裁剪。
|
|
323
|
+
// 代价:print 依赖目标窗口消息循环响应(卡死窗口约挂 5s),screen 纯 BitBlt 零依赖。
|
|
324
|
+
const mode = rawMode ?? (hwnd === null ? 'screen' : 'print')
|
|
325
|
+
return { hwnd, area, mode }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── 截屏 / 窗口列表(GET /screenshot、GET /window_list 后端,主进程同步直跑)──────
|
|
329
|
+
// 内置端点跑的是我们自己的代码,同步直跑让 bug 当场 throw → 500(有栈有原因),
|
|
330
|
+
// 而不是在 worker 里静默挂掉、等 timer 兜底成 504(30s 后才暴露且原因被吞)。
|
|
331
|
+
// 耗时实测端到端 win7 ~70ms / XP ~300ms(BitBlt + BMP 编码,有界),低频人用,
|
|
332
|
+
// 主循环阻塞可接受。/exec 才进 worker——外部命令不可控,popen 读循环阻塞无上界。
|
|
333
|
+
// 像素不走分块消息(原 SCREENSHOT_CHUNK 已删):整块 BMP 在手直接当 Response body。
|
|
334
|
+
|
|
335
|
+
// —— 截屏:BitBlt → 32bpp 顶向下 → BMP 包装(无压缩)——
|
|
336
|
+
// 行宽:32bpp 下每行 w*4 字节,天然 4 字节对齐,与 BMP 的行填充规则一致,
|
|
337
|
+
// 所以 GetDIBits 拿到的像素可以原样当 BMP 数据体。
|
|
338
|
+
const SRCCOPY = 0x00cc0020
|
|
339
|
+
const DIB_RGB_COLORS = 0
|
|
340
|
+
// 单次捕获像素上限(≈160MB 像素缓冲),挡住畸形 rect 造成的巨量分配
|
|
341
|
+
const MAX_CAPTURE_PX = 40000000
|
|
342
|
+
const WIDE_BUF_CHARS = 512
|
|
343
|
+
const MAX_WINDOWS = 20000
|
|
344
|
+
|
|
345
|
+
// BMP 文件 = 文件头(14B, pack 2) + 信息头(40B) + 像素
|
|
346
|
+
const BMP_HEADERS_SIZE = BITMAPFILEHEADER.size + BITMAPINFOHEADER.size
|
|
347
|
+
|
|
348
|
+
function bmpHeader(size: number, w: number, h: number): Uint8Array {
|
|
349
|
+
const fileHdr = BITMAPFILEHEADER.encode({
|
|
350
|
+
bfType: 0x4d42, // 'BM'
|
|
351
|
+
bfSize: BMP_HEADERS_SIZE + size, // 文件总长 = 头 + 像素
|
|
352
|
+
bfOffBits: BMP_HEADERS_SIZE, // 像素数据偏移
|
|
353
|
+
})
|
|
354
|
+
const infoHdr = BITMAPINFOHEADER.encode({
|
|
355
|
+
biSize: BITMAPINFOHEADER.size,
|
|
356
|
+
biWidth: w,
|
|
357
|
+
biHeight: -h, // 负 = 顶向下
|
|
358
|
+
biPlanes: 1,
|
|
359
|
+
biBitCount: 32,
|
|
360
|
+
biCompression: 0, // BI_RGB
|
|
361
|
+
biSizeImage: size,
|
|
362
|
+
biXPelsPerMeter: 2835, // 72dpi
|
|
363
|
+
biYPelsPerMeter: 2835,
|
|
364
|
+
})
|
|
365
|
+
const out = new Uint8Array(BMP_HEADERS_SIZE)
|
|
366
|
+
out.set(new Uint8Array(fileHdr), 0)
|
|
367
|
+
out.set(new Uint8Array(infoHdr), BITMAPFILEHEADER.size)
|
|
368
|
+
return out
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 取 hdc 当前选中位图的全部像素,拼成完整 BMP。GetDIBits 短读返回 null。 */
|
|
372
|
+
function emitDib(hdc: Ptr<'HDC'>, hbm: Ptr<'HGDIOBJ'>, w: number, h: number): Uint8Array | null {
|
|
373
|
+
const pxB = new PtrArrayBuffer(w * h * 4)
|
|
374
|
+
const px = new Uint8Array(pxB)
|
|
375
|
+
const bmi = BITMAPINFOHEADER.encode({
|
|
376
|
+
biSize: BITMAPINFOHEADER.size,
|
|
377
|
+
biWidth: w,
|
|
378
|
+
biHeight: -h, // 负 = 顶向下
|
|
379
|
+
biPlanes: 1,
|
|
380
|
+
biBitCount: 32, // biCompression 缺省 0 = BI_RGB
|
|
381
|
+
biSizeImage: w * h * 4,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
const got = GetDIBits(hdc, hbm, 0, h, pxB, bmi.ptr, DIB_RGB_COLORS)
|
|
385
|
+
if (got !== h) return null
|
|
386
|
+
|
|
387
|
+
const out = new Uint8Array(BMP_HEADERS_SIZE + px.byteLength)
|
|
388
|
+
out.set(bmpHeader(px.byteLength, w, h), 0)
|
|
389
|
+
out.set(px, BMP_HEADERS_SIZE)
|
|
390
|
+
return out
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// PrintWindow 到整窗临时 DC,再用 BitBlt 裁出子矩形到 hdcOut。临时 DC 用完立即归还。
|
|
394
|
+
// 裁切在 GDI 里做(第二块 DC + BitBlt),不在 JS 里逐像素拷:既省一份整帧缓冲,
|
|
395
|
+
// 也不会把客户区偏移和 BitBlt 的源坐标重复应用。
|
|
396
|
+
function printAndCrop(hdcScreen: Ptr<'HDC'>, hwnd: Ptr<'HWND'>, frameW: number, frameH: number,
|
|
397
|
+
hdcOut: Ptr<'HDC'>, cropX: number, cropY: number, outW: number, outH: number): boolean {
|
|
398
|
+
const hdcTmp = CreateCompatibleDC(hdcScreen)
|
|
399
|
+
const hbmTmp = hdcTmp ? CreateCompatibleBitmap(hdcScreen, frameW, frameH) : 0
|
|
400
|
+
if (!hdcTmp || !hbmTmp) {
|
|
401
|
+
if (hdcTmp) DeleteDC(hdcTmp)
|
|
402
|
+
return false
|
|
403
|
+
}
|
|
404
|
+
const oldTmp = SelectObject(hdcTmp, hbmTmp)
|
|
405
|
+
// PrintWindow 从 (0,0) 填整张位图,所以必须给整窗尺寸;nFlags 传 0(XP/Win7 无 PW_* 语义)
|
|
406
|
+
const printed = PrintWindow(hwnd, hdcTmp, 0) !== 0
|
|
407
|
+
const cropped = printed && BitBlt(hdcOut, 0, 0, outW, outH, hdcTmp, cropX, cropY, SRCCOPY) !== 0
|
|
408
|
+
SelectObject(hdcTmp, oldTmp)
|
|
409
|
+
DeleteObject(hbmTmp)
|
|
410
|
+
DeleteDC(hdcTmp)
|
|
411
|
+
return cropped
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** 抓一张 [outW x outH] 的 BMP。hwnd 为 null:srcX/srcY 是屏幕坐标,BitBlt 直接截出;
|
|
415
|
+
* 非 null:PrintWindow 让窗口自行重绘(可截被遮挡、最小化的窗口),再裁出相对偏移子矩形。
|
|
416
|
+
* 注意:BitBlt 自 GetDC(NULL) 截的是「调用线程所在 session 的输入桌面」,
|
|
417
|
+
* 在 sshd / service(session 0)里跑会得到全黑图且**不报错**——调用方要校验像素内容。 */
|
|
418
|
+
function captureRect(frameW: number, frameH: number, srcX: number, srcY: number,
|
|
419
|
+
cropX: number, cropY: number, outW: number, outH: number, hwnd: Ptr<'HWND'> | null):
|
|
420
|
+
{ bmp: Uint8Array; width: number; height: number } {
|
|
421
|
+
// 用否定式判断:NaN 会让 `> 0` 与 `> 上限` 同时为假,正写会漏过
|
|
422
|
+
if (!(outW > 0 && outH > 0 && outW * outH <= MAX_CAPTURE_PX)) {
|
|
423
|
+
throw new Error('capture size invalid w=' + outW + ' h=' + outH)
|
|
424
|
+
}
|
|
425
|
+
if (hwnd !== null
|
|
426
|
+
&& !(cropX >= 0 && cropY >= 0 && cropX + outW <= frameW && cropY + outH <= frameH)) {
|
|
427
|
+
throw new Error('capture crop out of range crop=(' + cropX + ',' + cropY + ',' + outW + 'x' + outH
|
|
428
|
+
+ ') frame=' + frameW + 'x' + frameH)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const hdcScreen = GetDC(NULL)
|
|
432
|
+
if (!hdcScreen) {
|
|
433
|
+
throw new Error('GetDC(NULL)=0:无输入桌面(确认在交互 session 启动,而非 sshd/service)')
|
|
434
|
+
}
|
|
435
|
+
// 必须传屏幕 DC 建位图:CreateCompatibleBitmap 按「当前选中对象」决定位图格式,
|
|
436
|
+
// 传 hdcOut 时它选中的是 1×1 单色图 → 建出 1×1 位图,BitBlt 被裁剪到 1 像素
|
|
437
|
+
// (症状:blit=1、getdibits=h 都成功,但缓冲区几乎全 0)。
|
|
438
|
+
const hdcOut = CreateCompatibleDC(hdcScreen)
|
|
439
|
+
const hbmOut = hdcOut ? CreateCompatibleBitmap(hdcScreen, outW, outH) : 0
|
|
440
|
+
if (!hdcOut || !hbmOut) {
|
|
441
|
+
if (hdcOut) DeleteDC(hdcOut)
|
|
442
|
+
ReleaseDC(NULL, hdcScreen)
|
|
443
|
+
throw new Error('CreateCompatibleDC/Bitmap failed')
|
|
444
|
+
}
|
|
445
|
+
const oldOut = SelectObject(hdcOut, hbmOut)
|
|
446
|
+
const ok = hwnd === null
|
|
447
|
+
? BitBlt(hdcOut, 0, 0, outW, outH, hdcScreen, srcX, srcY, SRCCOPY) !== 0
|
|
448
|
+
: printAndCrop(hdcScreen, hwnd, frameW, frameH, hdcOut, cropX, cropY, outW, outH)
|
|
449
|
+
SelectObject(hdcOut, oldOut)
|
|
450
|
+
const bmp = ok ? emitDib(hdcOut, hbmOut, outW, outH) : null
|
|
451
|
+
DeleteObject(hbmOut)
|
|
452
|
+
DeleteDC(hdcOut)
|
|
453
|
+
ReleaseDC(NULL, hdcScreen)
|
|
454
|
+
|
|
455
|
+
if (!ok) {
|
|
456
|
+
throw new Error('capture failed out=' + outW + 'x' + outH
|
|
457
|
+
+ (hwnd === null ? ' at=(' + srcX + ',' + srcY + ')'
|
|
458
|
+
: ' hwnd=' + hwnd + ' crop=(' + cropX + ',' + cropY + ') frame=' + frameW + 'x' + frameH))
|
|
459
|
+
}
|
|
460
|
+
if (!bmp) throw new Error('GetDIBits short read out=' + outW + 'x' + outH)
|
|
461
|
+
return { bmp, width: outW, height: outH }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 抓主屏全屏 */
|
|
465
|
+
function captureScreen(): { bmp: Uint8Array; width: number; height: number } {
|
|
466
|
+
const w = GetSystemMetrics(0)
|
|
467
|
+
const h = GetSystemMetrics(1)
|
|
468
|
+
if (w <= 0 || h <= 0) throw new Error('GetSystemMetrics w=' + w + ' h=' + h)
|
|
469
|
+
return captureRect(w, h, 0, 0, 0, 0, w, h, null)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 抓单个窗口。area=client 只抓客户区;mode=print 让窗口自行重绘(可被遮挡、可最小化)。 */
|
|
473
|
+
function captureWindow(hwnd: Ptr<'HWND'>, area: 'window' | 'client', mode: 'screen' | 'print'):
|
|
474
|
+
{ bmp: Uint8Array; width: number; height: number } {
|
|
475
|
+
if (mode === 'screen' && IsIconic(hwnd) !== 0) {
|
|
476
|
+
throw new Error('window minimized:mode=screen 只会截到它下方的桌面,去掉 mode=screen 让默认 print 生效')
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const wRect = RECT.alloc()
|
|
480
|
+
if (!GetWindowRect(hwnd, wRect.ptr)) {
|
|
481
|
+
throw new Error('GetWindowRect(hwnd=' + hwnd + ')=0:窗口不存在或已销毁')
|
|
482
|
+
}
|
|
483
|
+
const { left: wl, top: wt, right: wr, bottom: wbot } = RECT.decode(wRect)
|
|
484
|
+
const ww = wr - wl
|
|
485
|
+
const wh = wbot - wt
|
|
486
|
+
|
|
487
|
+
let cropX = 0
|
|
488
|
+
let cropY = 0
|
|
489
|
+
let cw = ww
|
|
490
|
+
let ch = wh
|
|
491
|
+
if (area === 'client') {
|
|
492
|
+
const cRect = RECT.alloc()
|
|
493
|
+
if (!GetClientRect(hwnd, cRect.ptr)) throw new Error('GetClientRect(hwnd=' + hwnd + ')=0')
|
|
494
|
+
// 客户区坐标以客户区左上角为原点,left/top 恒为 0,不是窗口→客户的偏移;
|
|
495
|
+
// 边框和标题栏的位移必须用 ClientToScreen(0,0) 单独查。尺寸取差值(同理不假设 left/top)。
|
|
496
|
+
const { left: cl, top: ct, right: cr, bottom: cbot } = RECT.decode(cRect)
|
|
497
|
+
cw = cr - cl
|
|
498
|
+
ch = cbot - ct
|
|
499
|
+
const origin = POINT.encode({ x: 0, y: 0 })
|
|
500
|
+
if (!ClientToScreen(hwnd, origin.ptr)) throw new Error('ClientToScreen(hwnd=' + hwnd + ')=0')
|
|
501
|
+
const org = POINT.decode(origin)
|
|
502
|
+
cropX = org.x - wl
|
|
503
|
+
cropY = org.y - wt
|
|
504
|
+
}
|
|
505
|
+
// screen:srcX/srcY 给绝对屏幕坐标,截出的就是输出本身,crop 无意义(传 0 即可,反正被忽略);
|
|
506
|
+
// print:PrintWindow 只能从 (0,0) 画整窗,客户区靠 crop 从整窗位图里裁出来。
|
|
507
|
+
// 两条路径只走一种坐标,所以客户区偏移不会被应用两次。
|
|
508
|
+
let sx = mode === 'print' ? 0 : wl + cropX
|
|
509
|
+
let sy = mode === 'print' ? 0 : wt + cropY
|
|
510
|
+
if (mode === 'screen') {
|
|
511
|
+
// clamp 捕获区域到虚拟屏幕内:窗口超出屏幕时 BitBlt 从屏幕外只能截到黑区,
|
|
512
|
+
// 直接把发出的 BMP 裁到窗口在屏幕内的可见部分(dims 也随之收缩)。
|
|
513
|
+
const vsX = GetSystemMetrics(76) // SM_XVIRTUALSCREEN
|
|
514
|
+
const vsY = GetSystemMetrics(77) // SM_YVIRTUALSCREEN
|
|
515
|
+
const vsW = GetSystemMetrics(78) // SM_CXVIRTUALSCREEN
|
|
516
|
+
const vsH = GetSystemMetrics(79) // SM_CYVIRTUALSCREEN
|
|
517
|
+
const ax = Math.max(sx, vsX)
|
|
518
|
+
const ay = Math.max(sy, vsY)
|
|
519
|
+
const ax2 = Math.min(sx + cw, vsX + vsW)
|
|
520
|
+
const ay2 = Math.min(sy + ch, vsY + vsH)
|
|
521
|
+
if (ax2 <= ax || ay2 <= ay) {
|
|
522
|
+
throw new Error('capture area outside virtual screen at=(' + sx + ',' + sy + ',' + cw
|
|
523
|
+
+ 'x' + ch + ')')
|
|
524
|
+
}
|
|
525
|
+
sx = ax
|
|
526
|
+
sy = ay
|
|
527
|
+
cw = ax2 - ax
|
|
528
|
+
ch = ay2 - ay
|
|
529
|
+
}
|
|
530
|
+
return captureRect(ww, wh, sx, sy, cropX, cropY, cw, ch, mode === 'print' ? hwnd : null)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** 顶层窗口列表(不递归子控件)。EnumWindows 先拍窗口快照再回调:不像 FindWindowEx 链式遍历
|
|
534
|
+
* 那样,在「下一个窗口恰好被销毁」时拿到失效句柄,下一轮返回 NULL 把列表悄悄截断。
|
|
535
|
+
* 回调只收集句柄:回调内抛异常会被 closure 吞成返回 0(枚举静默提前结束),所以不在回调里查属性。
|
|
536
|
+
* 快照里的窗口在逐个查询时可能已销毁,GetWindowRect 返回 0 即跳过。
|
|
537
|
+
* 输出顺序是 EnumWindows 的顺序(与 FindWindowEx 链的顺序不同,集合相同;API 未承诺顺序)。 */
|
|
538
|
+
function listWindows(): WindowEntry[] {
|
|
539
|
+
const handles: Ptr<'HWND'>[] = []
|
|
540
|
+
const collect = closure('<HWND>ptr <>ptr -> i32', (h) => {
|
|
541
|
+
if (h) handles.push(h)
|
|
542
|
+
return handles.length < MAX_WINDOWS ? 1 : 0 // 返回 0 = 停止枚举(到上限)
|
|
543
|
+
})
|
|
544
|
+
try { EnumWindows(collect.ptr, NULL) } finally { collect.dispose() }
|
|
545
|
+
|
|
546
|
+
const wRect = RECT.alloc()
|
|
547
|
+
const tb = WCHAR.alloc(WIDE_BUF_CHARS)
|
|
548
|
+
const cb = WCHAR.alloc(WIDE_BUF_CHARS)
|
|
549
|
+
|
|
550
|
+
const out: WindowEntry[] = []
|
|
551
|
+
for (const hwnd of handles) {
|
|
552
|
+
if (GetWindowRect(hwnd, wRect.ptr)) {
|
|
553
|
+
const rc = RECT.decode(wRect)
|
|
554
|
+
const tl = GetWindowText(hwnd, tb, WIDE_BUF_CHARS)
|
|
555
|
+
const cl = GetClassName(hwnd, cb, WIDE_BUF_CHARS)
|
|
556
|
+
out.push({
|
|
557
|
+
hwnd,
|
|
558
|
+
// 缓冲跨窗口复用:长度 0 时 API 不保证写回终止符,不能 decode 残留内容
|
|
559
|
+
title: tl > 0 ? WCHAR.decode(tb) : '',
|
|
560
|
+
className: cl > 0 ? WCHAR.decode(cb) : '',
|
|
561
|
+
rect: { left: rc.left, top: rc.top, right: rc.right, bottom: rc.bottom },
|
|
562
|
+
visible: IsWindowVisible(hwnd) !== 0,
|
|
563
|
+
minimized: IsIconic(hwnd) !== 0
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── 显示模式(POST /set_resolution 后端)────────────────────────────────────
|
|
571
|
+
// 流程:Enum 当前模式取基准 → dmFields 置 DM_PELSWIDTH|DM_PELSHEIGHT 原地改宽高 →
|
|
572
|
+
// CDS_TEST 预检(被拒不碰实际显示)→ 动态 apply 即时生效 → persist 时再 CDS_UPDATEREGISTRY
|
|
573
|
+
// 写注册表跨重启。宽高必须在驱动模式列表内(QEMU -vga std),无任意值仿真;尺寸经
|
|
574
|
+
// GetSystemMetrics 实测回读,不信传入值;modes32 恒带(成功/失败都附 32bpp 可用列表)。
|
|
575
|
+
// 常量全部 gui const enum(gen_const 从 SDK 头提取):DM_PELSWIDTH=0x00080000——
|
|
576
|
+
// 手写易错成 DM_ICMMETHOD 的 0x00800000(旧 setres.ts 即栽在这 + scriptArgs 错位)。
|
|
577
|
+
|
|
578
|
+
interface DisplayModeChange {
|
|
579
|
+
/** 变更前分辨率 'WxH'(Enum 当前模式实读) */
|
|
580
|
+
before: string
|
|
581
|
+
/** 变更后分辨率 'WxH'(GetSystemMetrics 实测) */
|
|
582
|
+
after: string
|
|
583
|
+
/** CDS_TEST 预检结果(0 = 驱动接受该模式) */
|
|
584
|
+
testRet: number
|
|
585
|
+
/** 实际 apply 结果;testRet !== 0 时等于 testRet(预检即拒、未 apply) */
|
|
586
|
+
ret: number
|
|
587
|
+
/** ret 的 DISP_CHANGE_* 名(未知值 '?') */
|
|
588
|
+
retName: string
|
|
589
|
+
/** 恒带:32bpp 可用模式 'WxH@Hz'(成功/失败都给,免二次请求重挑) */
|
|
590
|
+
modes32: string[]
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const DISP_CHANGE_NAME: Record<number, string> = {
|
|
594
|
+
[gui.DisplayChangeResult.SUCCESSFUL]: 'SUCCESSFUL',
|
|
595
|
+
[gui.DisplayChangeResult.RESTART]: 'RESTART',
|
|
596
|
+
[gui.DisplayChangeResult.FAILED]: 'FAILED',
|
|
597
|
+
[gui.DisplayChangeResult.BADMODE]: 'BADMODE',
|
|
598
|
+
[gui.DisplayChangeResult.NOTUPDATED]: 'NOTUPDATED',
|
|
599
|
+
[gui.DisplayChangeResult.BADFLAGS]: 'BADFLAGS',
|
|
600
|
+
[gui.DisplayChangeResult.BADPARAM]: 'BADPARAM',
|
|
601
|
+
[gui.DisplayChangeResult.BADDUALVIEW]: 'BADDUALVIEW',
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function dispChangeName(ret: number): string {
|
|
605
|
+
return DISP_CHANGE_NAME[ret] ?? '?'
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** 当前屏幕实测尺寸 'WxH'(模式切换后立即反映) */
|
|
609
|
+
function currentScreen(): string {
|
|
610
|
+
return GetSystemMetrics(gui.SysMetrics.CXSCREEN) + 'x' + GetSystemMetrics(gui.SysMetrics.CYSCREEN)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** 枚举 32bpp 显示模式 → 'WxH@Hz' 列表(上限 200 次迭代防死循环) */
|
|
614
|
+
function listModes32(): string[] {
|
|
615
|
+
const probe = DEVMODEW.encode({ dmSize: DEVMODEW.size })
|
|
616
|
+
const out: string[] = []
|
|
617
|
+
for (let i = 0; i < 200; i++) {
|
|
618
|
+
if (!EnumDisplaySettings(NULL, i, probe.ptr)) break
|
|
619
|
+
const dm = DEVMODEW.decode(probe)
|
|
620
|
+
if (dm.dmBitsPerPel !== 32) continue
|
|
621
|
+
out.push(dm.dmPelsWidth + 'x' + dm.dmPelsHeight + '@' + dm.dmDisplayFrequency)
|
|
622
|
+
}
|
|
623
|
+
return out
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** 改显示分辨率。w/h 非法直接 throw;驱动拒绝(BADMODE 等)以结果对象返回。
|
|
627
|
+
* @param persist true = 动态生效后追加写注册表(跨重启保留);false = 只即时生效 */
|
|
628
|
+
function applyDisplayMode(w: number, h: number, persist: boolean): DisplayModeChange {
|
|
629
|
+
if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) {
|
|
630
|
+
throw new Error('applyDisplayMode: w/h must be positive integers, got ' + w + 'x' + h)
|
|
631
|
+
}
|
|
632
|
+
// dmSize 先置(Win32 硬要求,否则 API 拒绝);Enum 就地填当前模式其余字段
|
|
633
|
+
const dm = DEVMODEW.encode({ dmSize: DEVMODEW.size })
|
|
634
|
+
if (!EnumDisplaySettings(NULL, gui.EnumDisplaySetting.CURRENT_SETTINGS, dm.ptr)) {
|
|
635
|
+
throw new Error('applyDisplayMode: EnumDisplaySettings CURRENT failed')
|
|
636
|
+
}
|
|
637
|
+
const cur = DEVMODEW.decode(dm)
|
|
638
|
+
const before = cur.dmPelsWidth + 'x' + cur.dmPelsHeight
|
|
639
|
+
|
|
640
|
+
// 同一 buffer 原地改三项(encode 缺省字段跳过 = 保留 Enum 填的其余项)
|
|
641
|
+
DEVMODEW.encode({
|
|
642
|
+
dmFields: cur.dmFields | gui.DevModeField.PELSWIDTH | gui.DevModeField.PELSHEIGHT,
|
|
643
|
+
dmPelsWidth: w,
|
|
644
|
+
dmPelsHeight: h,
|
|
645
|
+
}, dm)
|
|
646
|
+
|
|
647
|
+
const testRet = ChangeDisplaySettings(dm.ptr, gui.ChangeDisplayFlag.TEST)
|
|
648
|
+
if (testRet !== 0) {
|
|
649
|
+
// 预检即拒:不碰实际显示,附可用列表
|
|
650
|
+
return {
|
|
651
|
+
before, after: currentScreen(), testRet,
|
|
652
|
+
ret: testRet, retName: dispChangeName(testRet), modes32: listModes32(),
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// flags=0 = 动态切换即时生效;persist 再写一遍注册表(CDS_UPDATEREGISTRY 跨重启)
|
|
657
|
+
let ret = ChangeDisplaySettings(dm.ptr, 0)
|
|
658
|
+
if (ret === 0 && persist) {
|
|
659
|
+
ret = ChangeDisplaySettings(dm.ptr, gui.ChangeDisplayFlag.UPDATEREGISTRY)
|
|
660
|
+
}
|
|
661
|
+
// 成功也带可用列表:一次请求拿到结果+全量模式,失败再请求才给列表就多一个来回
|
|
662
|
+
return { before, after: currentScreen(), testRet, ret, retName: dispChangeName(ret), modes32: listModes32() }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 非 API 路径的统一应答(/、/health、拼错路径/方法一律 200 免 token)。run.sh/
|
|
666
|
+
// http_test.sh 的 curl -sf 健康探针打到的就是这(判据只看状态码不看 body)。
|
|
667
|
+
// 不记日志:探针 3s 一打,会刷爆 GUI 环形日志窗。
|
|
668
|
+
const USAGE = `quickwin execd —— 本页即用法(API 之外任意路径均返回,HTTP 200)
|
|
669
|
+
|
|
670
|
+
API(配置 token 后需 Authorization: Bearer <token>,未配置则放行):
|
|
671
|
+
POST /exec JSON {"cmd":"命令","timeout":毫秒?} → 流式输出,Trailer X-Exit-Code
|
|
672
|
+
GET /screenshot ?hwnd=<十进制>&area=window|client&mode=screen|print → image/bmp
|
|
673
|
+
(X-Dimensions;不带 hwnd = 整屏 BitBlt;带 hwnd 默认 mode=print
|
|
674
|
+
让窗口自绘——遮挡/最小化/超屏也拿完整像素,screen 只截可见部分)
|
|
675
|
+
GET /window_list → JSON 窗口列表 [{hwnd,title,className,...}]
|
|
676
|
+
POST /set_resolution ?w=<px>&h=<px>&persist=0|1(默认 1 = 写注册表跨重启)→ JSON
|
|
677
|
+
{before,after,testRet,ret,retName,modes32};modes32 恒带 32bpp
|
|
678
|
+
可用模式(非法宽高会以 BADMODE 拒绝并给本列表)`
|
|
679
|
+
|
|
680
|
+
function usageResponse(): Response {
|
|
681
|
+
return new Response(USAGE, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const server = createServer(async (req) => {
|
|
685
|
+
const t0 = Date.now()
|
|
686
|
+
const path = new URL(req.url).pathname
|
|
687
|
+
const isApi = (req.method === 'POST' && (path === '/exec' || path === '/set_resolution')) ||
|
|
688
|
+
(req.method === 'GET' && (path === '/window_list' || path === '/screenshot'))
|
|
689
|
+
if (!isApi) return usageResponse()
|
|
690
|
+
const denied = authCheck(req)
|
|
691
|
+
if (denied !== null) {
|
|
692
|
+
logReq(t0, req.method + ' ' + path, 401, 'missing/invalid token')
|
|
693
|
+
return denied
|
|
694
|
+
}
|
|
695
|
+
if (req.method === 'GET' && path === '/window_list') {
|
|
696
|
+
try {
|
|
697
|
+
const wins = listWindows()
|
|
698
|
+
const resp = new Response(JSON.stringify(wins), { headers: { 'Content-Type': 'application/json' } })
|
|
699
|
+
logReq(t0, 'GET /window_list', 200)
|
|
700
|
+
return resp
|
|
701
|
+
} catch (ex) {
|
|
702
|
+
logReq(t0, 'GET /window_list', 500, String(ex))
|
|
703
|
+
return jsonError(500, String(ex))
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (req.method === 'GET' && path === '/screenshot') {
|
|
707
|
+
let opts: ShotOpts
|
|
708
|
+
try {
|
|
709
|
+
opts = parseShotOpts(new URL(req.url).searchParams)
|
|
710
|
+
} catch (ex) {
|
|
711
|
+
logReq(t0, 'GET /screenshot', 400, String(ex))
|
|
712
|
+
return jsonError(400, String(ex))
|
|
713
|
+
}
|
|
714
|
+
let cap: { bmp: Uint8Array; width: number; height: number }
|
|
715
|
+
try {
|
|
716
|
+
cap = opts.hwnd === null
|
|
717
|
+
? captureScreen()
|
|
718
|
+
// JSON 来的句柄是普通 number,标 HWND 品牌即可(有效性由 Win32 判定)
|
|
719
|
+
: captureWindow(opts.hwnd as Ptr<'HWND'>, opts.area, opts.mode)
|
|
720
|
+
} catch (ex) {
|
|
721
|
+
logReq(t0, 'GET /screenshot', 500, String(ex))
|
|
722
|
+
return jsonError(500, String(ex))
|
|
723
|
+
}
|
|
724
|
+
const target = opts.hwnd !== null ? ' hwnd=' + opts.hwnd + ' ' + opts.area + '/' + opts.mode : ' screen'
|
|
725
|
+
logReq(t0, 'GET /screenshot' + target, 200)
|
|
726
|
+
return new Response(cap.bmp, {
|
|
727
|
+
headers: { 'Content-Type': 'image/bmp', 'X-Dimensions': cap.width + 'x' + cap.height }
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
// /set_resolution 改系统状态但毫秒级返回(Enum/Test/Apply 皆同步 FFI,无子进程无阻塞),
|
|
731
|
+
// 直接在主进程做(worker 只隔离 /exec 的长 popen——外部命令不可控、阻塞无上界)。
|
|
732
|
+
// 预检被拒(BADMODE 等)以 200 + 结果对象返回(含 modes32 可用列表),语义在 body 不在状态码。
|
|
733
|
+
if (req.method === 'POST' && path === '/set_resolution') {
|
|
734
|
+
let result: DisplayModeChange
|
|
735
|
+
let w = 0, h = 0, persist = true
|
|
736
|
+
try {
|
|
737
|
+
const params = new URL(req.url).searchParams
|
|
738
|
+
w = Number(params.get('w'))
|
|
739
|
+
h = Number(params.get('h'))
|
|
740
|
+
if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) {
|
|
741
|
+
throw new Error('w/h must be positive integers')
|
|
742
|
+
}
|
|
743
|
+
persist = params.get('persist') !== '0' // 默认开(写注册表跨重启);persist=0 只即时生效
|
|
744
|
+
result = applyDisplayMode(w, h, persist)
|
|
745
|
+
} catch (ex) {
|
|
746
|
+
logReq(t0, 'POST /set_resolution', 400, String(ex))
|
|
747
|
+
return jsonError(400, String(ex))
|
|
748
|
+
}
|
|
749
|
+
const resp = new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } })
|
|
750
|
+
logReq(t0, 'POST /set_resolution ' + w + 'x' + h + (persist ? '' : ' persist=0'), 200,
|
|
751
|
+
result.ret !== 0 ? result.retName : undefined)
|
|
752
|
+
return resp
|
|
753
|
+
}
|
|
754
|
+
if (req.method === 'POST' && path === '/exec') {
|
|
755
|
+
let out: ExecHandle
|
|
756
|
+
let cmd: string
|
|
757
|
+
try {
|
|
758
|
+
const body = await req.json() as { cmd: string; timeout?: unknown }
|
|
759
|
+
const t = body.timeout
|
|
760
|
+
const timeoutMs: number | null = typeof t === 'number' && Number.isFinite(t) && t > 0
|
|
761
|
+
? t
|
|
762
|
+
: null
|
|
763
|
+
cmd = body.cmd
|
|
764
|
+
out = runInWorker(body.cmd, req.signal, timeoutMs)
|
|
765
|
+
} catch (ex) {
|
|
766
|
+
return jsonError(500, 'setup: ' + String(ex))
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
const rd = await out.ready
|
|
770
|
+
if (!rd.ok) {
|
|
771
|
+
logReq(t0, cmd, 500, rd.error)
|
|
772
|
+
return jsonError(500, rd.error ?? 'exec failed')
|
|
773
|
+
}
|
|
774
|
+
out.trailers.then((h) => {
|
|
775
|
+
const code = (h as Record<string, string>)['X-Exit-Code']
|
|
776
|
+
logReq(t0, cmd, 200, undefined, code === undefined ? null : Number(code))
|
|
777
|
+
}).catch(() => { })
|
|
778
|
+
return new Response(out.stream, {
|
|
779
|
+
headers: {
|
|
780
|
+
'Content-Type': 'application/octet-stream',
|
|
781
|
+
'Trailer': 'X-Exit-Code',
|
|
782
|
+
},
|
|
783
|
+
trailers: out.trailers
|
|
784
|
+
})
|
|
785
|
+
} catch (ex) {
|
|
786
|
+
logReq(t0, cmd, 504, 'exec timeout')
|
|
787
|
+
return jsonError(504, String(ex))
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
// isApi 判定已收口所有未匹配路径——此处仅兜 async 回调的返回类型(运行时不可达)
|
|
791
|
+
return usageResponse()
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
const rc = server.listen(8080, '0.0.0.0')
|
|
795
|
+
console.log('listen rc=' + rc + ' addr=' + JSON.stringify(server.address()))
|
|
796
|
+
const addr = server.address()
|
|
797
|
+
if (addr) boundPort = addr.port
|
|
798
|
+
initGui()
|