import Hapi from '@hapi/hapi'
import get from 'lodash-es/get.js'
import ispint from 'wsemi/src/ispint.mjs'
import cint from 'wsemi/src/cint.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import { normalizeKey } from './util/keyAlias.mjs'
import bkLinux from './backend/bkLinux.mjs'
import bkWin from './backend/bkWin.mjs'
import ChromeManager from './chromeManager.mjs'
import { systemRoute, chromeRoute, lifecycleRoute, errResponse } from './routeFactory.mjs'
import * as S from './schemas.mjs'
/**
* 啟動 w-screenctl 服務,對外提供 REST API 控制作業系統桌面(滑鼠、鍵盤、截圖)與 Chrome 瀏覽器
*
* 跨平台支援:Linux 走 xdotool / ImageMagick,Windows 走 w-mousekey / AHK;Chrome 控制全平台皆透過 Playwright
*
* 零信任設計:caller 可任意 retry / 中斷 / agent swap,server 維持自洽狀態。所有 chrome lifecycle
* 透過 ChromeManager 序列化;所有路由經由 routeFactory 統一錯誤處理;所有 payload 經 Joi 驗證。
*
* @param {Object} [opt={}] 輸入設定物件
* @param {Integer} [opt.port=7000] 服務監聽 port,優先序:env PORT > opt.port > 7000
* @param {String} [opt.fdUserData] Chrome user data 資料夾路徑,優先序:env CHROME_USER_DATA > opt.fdUserData > 當前工作路徑下的 ./user_data
* @returns {Promise<Server>} Hapi server 實例
*/
async function WScreenctl(opt = {}) {
let port = get(opt, 'port', null)
if (ispint(process.env.PORT)) port = cint(process.env.PORT)
if (!ispint(port)) port = 7000
port = cint(port)
const backend = process.platform === 'win32' ? bkWin : bkLinux
let fdUserData = process.env.CHROME_USER_DATA
if (!isestr(fdUserData)) fdUserData = get(opt, 'fdUserData', null)
if (!isestr(fdUserData)) {
fdUserData = './user_data'
}
const chromeManager = new ChromeManager()
// ── Hapi 伺服器 ──────────────────────────────────────────
// 統一 Joi 失敗響應,符合 { ok, error, retry } 契約
const validationFailAction = (req, h, err) => h.response({
ok: false,
error: err.message,
code: 'VALIDATION',
retry: 'never',
}).code(400).takeover()
const server = Hapi.server({
port,
host: '0.0.0.0',
routes: {
cors: true,
payload: { maxBytes: 10 * 1024 * 1024 },
validate: { failAction: validationFailAction },
},
})
const validate = (payload) => ({ payload, failAction: validationFailAction })
// ─── Chrome 生命週期路由 ────────────────────────────────
server.route({
method: 'POST',
path: '/chrome/open',
options: { validate: validate(S.schemaChromeOpen) },
handler: lifecycleRoute('chrome/open', async (p) => {
const r = await chromeManager.open({
url: p.url,
mode: p.mode,
window: p.window,
userDataDir: p.userData || fdUserData,
opt: p.opt || {},
})
return { ok: true, ...r }
}),
})
server.route({
method: 'GET',
path: '/chrome',
handler: () => chromeManager.snapshot(),
})
server.route({
method: 'DELETE',
path: '/chrome',
handler: lifecycleRoute('chrome/delete', async () => {
const r = await chromeManager.close()
return { ok: true, ...r }
}),
})
// ─── Chrome 頁面操作 ────────────────────────────────────
server.route({
method: 'POST',
path: '/chrome/screenshot',
handler: chromeRoute('chrome/screenshot', chromeManager, async (inst) => {
const buf = await inst.page.screenshot({ fullPage: false, timeout: 5000 })
return { ok: true, image: buf.toString('base64'), format: 'png' }
}),
})
server.route({
method: 'POST',
path: '/chrome/navigate',
options: { validate: validate(S.schemaChromeNavigate) },
handler: chromeRoute('chrome/navigate', chromeManager, async (inst, p) => {
// goto 失敗直接 throw 給 chromeRoute → errResponse 統一處理(含 retry hint)
await inst.page.goto(p.url, { waitUntil: 'commit', timeout: 15000 })
return { ok: true, url: inst.page.url() }
}),
})
server.route({
method: 'POST',
path: '/chrome/evaluate',
options: { validate: validate(S.schemaChromeEvaluate) },
handler: chromeRoute('chrome/evaluate', chromeManager, async (inst, p) => {
// timeout 與 script 錯誤都 throw 出去給 chromeRoute → errResponse 統一分類
// 包成 IIFE 在頁內 Promise.race,timeout 字樣會被 errClassify 抓成 retry: 'after_1s'
let timer
try {
const result = await Promise.race([
inst.page.evaluate(p.script),
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('evaluate timeout 5s')), 5000)
}),
])
return { ok: true, result }
}
finally {
if (timer) clearTimeout(timer)
}
}),
})
// ─── Chrome 頁面級輸入 ──────────────────────────────────
const PAGE_BTN = { 1: 'left', 2: 'middle', 3: 'right' }
server.route({
method: 'POST',
path: '/chrome/mouse/click',
options: { validate: validate(S.schemaChromeMouseClick) },
handler: chromeRoute('chrome/mouse/click', chromeManager, async (inst, p) => {
await inst.page.mouse.click(p.x, p.y, { button: PAGE_BTN[p.button] })
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/chrome/mouse/dblclick',
options: { validate: validate(S.schemaChromeMouseDblClick) },
handler: chromeRoute('chrome/mouse/dblclick', chromeManager, async (inst, p) => {
await inst.page.mouse.dblclick(p.x, p.y)
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/chrome/mouse/drag',
options: { validate: validate(S.schemaChromeMouseDrag) },
handler: chromeRoute('chrome/mouse/drag', chromeManager, async (inst, p) => {
const btn = PAGE_BTN[p.button]
await inst.page.mouse.move(p.fromX, p.fromY)
await inst.page.mouse.down({ button: btn })
await inst.page.mouse.move(p.toX, p.toY)
await inst.page.mouse.up({ button: btn })
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/chrome/mouse/scroll',
options: { validate: validate(S.schemaChromeMouseScroll) },
handler: chromeRoute('chrome/mouse/scroll', chromeManager, async (inst, p) => {
const step = 100
const deltas = {
up: [0, -step * p.amount],
down: [0, step * p.amount],
left: [-step * p.amount, 0],
right: [step * p.amount, 0],
}
const [dx, dy] = deltas[p.direction]
await inst.page.mouse.move(p.x, p.y)
await inst.page.mouse.wheel(dx, dy)
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/chrome/keyboard/key',
options: { validate: validate(S.schemaChromeKeyboardKey) },
handler: chromeRoute('chrome/keyboard/key', chromeManager, async (inst, p) => {
await inst.page.keyboard.press(p.keys)
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/chrome/keyboard/type',
options: { validate: validate(S.schemaChromeKeyboardType) },
handler: chromeRoute('chrome/keyboard/type', chromeManager, async (inst, p) => {
await inst.page.keyboard.type(p.text)
return { ok: true }
}),
})
// ─── 系統級控制 ─────────────────────────────────────────
server.route({
method: 'POST',
path: '/screenshot',
options: { validate: validate(S.schemaSystemScreenshot) },
handler: systemRoute('screenshot', async (p) => {
const image = await backend.screenshot(p.region || null)
return { ok: true, image, format: 'png' }
}),
})
server.route({
method: 'POST',
path: '/mouse/click',
options: { validate: validate(S.schemaSystemMouseClick) },
handler: systemRoute('mouse/click', async (p) => {
await backend.mouseClick(p.x, p.y, p.button)
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/mouse/dblclick',
options: { validate: validate(S.schemaSystemMouseDblClick) },
handler: systemRoute('mouse/dblclick', async (p) => {
await backend.mouseDblClick(p.x, p.y)
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/mouse/drag',
options: { validate: validate(S.schemaSystemMouseDrag) },
handler: systemRoute('mouse/drag', async (p) => {
await backend.mouseDrag(p.fromX, p.fromY, p.toX, p.toY, p.button)
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/mouse/scroll',
options: { validate: validate(S.schemaSystemMouseScroll) },
handler: systemRoute('mouse/scroll', async (p) => {
await backend.mouseScroll(p.x, p.y, p.direction, p.amount)
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/keyboard/key',
options: { validate: validate(S.schemaSystemKeyboardKey) },
handler: systemRoute('keyboard/key', async (p) => {
await backend.keyboardKey(normalizeKey(p.keys))
return { ok: true }
}),
})
server.route({
method: 'POST',
path: '/keyboard/type',
options: { validate: validate(S.schemaSystemKeyboardType) },
handler: systemRoute('keyboard/type', async (p) => {
await backend.keyboardType(p.text)
return { ok: true }
}),
})
// ─── 健康檢查(30s cache) ───────────────────────────────
function fmtBytes(bytes) {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + 'GB'
return (bytes / 1048576).toFixed(0) + 'MB'
}
let healthCache = null
let healthCacheTime = 0
const HEALTH_TTL = 30000
async function getHealthMemory() {
const now = Date.now()
if (healthCache && now - healthCacheTime < HEALTH_TTL) return healthCache
const m = await backend.getMemoryInfo()
healthCache = m ? {
total: fmtBytes(m.total),
used: fmtBytes(m.used),
available: fmtBytes(m.available),
chromeRSS: fmtBytes(m.chromeRSS),
} : null
healthCacheTime = now
return healthCache
}
server.route({
method: 'GET',
path: '/health',
handler: async () => {
const memory = await getHealthMemory().catch(() => null)
return {
status: 'ok',
platform: process.platform,
chrome: chromeManager.snapshot().state,
uptime: process.uptime(),
memory,
}
},
})
await server.start()
console.log(`running at ${server.info.uri} (platform=${process.platform})`)
// ─── Graceful shutdown ──────────────────────────────────
//
// 順序設計:
// 1. chromeManager.shutdown() 先:拒新 enqueue + 排空既有 + 關 chromium
// (若直接 server.stop 先,正在 launch 的 chromium 會變孤兒)
// 2. server.stop() 後:拒新 HTTP 連線 + 等 in-flight 完成
// 3. 整體有 60s 硬上限(須 ≥ chromeManager 內 launch timeout 20s + cleanup grace 30s = 50s),
// 超時直接 exit(1),由 OS 回收 chromium
let shuttingDown = false
async function gracefulShutdown(signal) {
if (shuttingDown) return
shuttingDown = true
console.log(`[${signal}] shutting down...`)
const hardKillTimer = setTimeout(() => {
console.error('[shutdown] hard timeout 60s, force exit')
process.exit(1)
}, 60000)
hardKillTimer.unref()
try {
await chromeManager.shutdown()
}
catch (err) {
console.error('[shutdown] chromeManager.shutdown failed:', err.message)
}
try {
await server.stop({ timeout: 10000 })
}
catch (err) {
console.error('[shutdown] server.stop failed:', err.message)
}
clearTimeout(hardKillTimer)
process.exit(0)
}
process.once('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.once('SIGINT', () => gracefulShutdown('SIGINT'))
server.gracefulShutdown = () => gracefulShutdown('manual')
return server
}
export { errResponse }
export default WScreenctl