WScreenctl.mjs

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