#!/usr/bin/env node 'use strict'; const childProcess = require('node:child_process'); const fs = require('node:fs'); const http = require('node:http'); const path = require('node:path'); const readline = require('node:readline/promises'); const { stdin, stdout } = require('node:process'); const { chromium } = require('playwright-core'); const PROJECT_ROOT = path.resolve(__dirname, '..'); const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 8788; const DEFAULT_API_BASE = 'https://api.atuchat.com/'; const DEFAULT_URL = '/aslan/recharge-activity/index.html?app_code=aslan&codex_webview=1'; const DEFAULT_CHROME_PATHS = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', ]; function parseArgs(argv) { const options = { account: '', password: '', token: '', url: '', apiBase: DEFAULT_API_BASE, host: DEFAULT_HOST, port: DEFAULT_PORT, origin: 'ATYOU', originChild: '', lang: 'en', chrome: '', headless: false, devtools: false, disableWebSecurity: true, navigationPrompt: true, closeAfter: 0, screenshot: '', }; const aliases = { '-a': 'account', '--account': 'account', '-p': 'password', '--password': 'password', '--pwd': 'password', '--token': 'token', '-u': 'url', '--url': 'url', '--api-base': 'apiBase', '--host': 'host', '--port': 'port', '--origin': 'origin', '--origin-child': 'originChild', '--lang': 'lang', '--chrome': 'chrome', '--close-after': 'closeAfter', '--screenshot': 'screenshot', }; for (let i = 0; i < argv.length; i += 1) { const raw = argv[i]; const [flag, inlineValue] = raw.split(/=(.*)/s); if (flag === '--help' || flag === '-h') { options.help = true; continue; } if (flag === '--headless') { options.headless = true; continue; } if (flag === '--devtools') { options.devtools = true; continue; } if (flag === '--no-disable-web-security') { options.disableWebSecurity = false; continue; } if (flag === '--no-nav-prompt') { options.navigationPrompt = false; continue; } const key = aliases[flag]; if (!key) { throw new Error(`Unknown argument: ${raw}`); } const value = inlineValue !== undefined ? inlineValue : argv[++i]; if (value === undefined) throw new Error(`Missing value for ${raw}`); options[key] = key === 'port' || key === 'closeAfter' ? Number(value) : value; } options.originChild = options.originChild || options.origin; return options; } function usage() { return [ 'Usage:', ' npm run aslan:webview', ' npm run aslan:webview -- --account 6666 --password 123456 --url /aslan/recharge-activity/index.html?app_code=aslan', '', 'Options:', ' -a, --account Aslan login account or special ID', ' -p, --password Aslan login password', ' --token Existing Aslan token; skips account password login', ' -u, --url Target H5 URL. Relative paths use the local H5 dev server.', ' --api-base API base, default https://api.atuchat.com/', ' --origin Req-Sys-Origin origin, default ATYOU', ' --origin-child Req-Sys-Origin originChild, default same as origin', ' --lang Req-Lang, default en', ' --chrome Chrome executable path', ' --headless Run browser headless', ' --devtools Open DevTools', ' --no-disable-web-security Keep browser web security enabled', ' --no-nav-prompt Do not prompt for more URLs after opening', ' --close-after Auto close, useful for smoke tests', ' --screenshot Save a screenshot after initial load', ].join('\n'); } function normalizeBaseURL(value) { return String(value || DEFAULT_API_BASE).replace(/\/+$/, ''); } function resolveChromePath(explicitPath) { if (explicitPath) return explicitPath; return ( DEFAULT_CHROME_PATHS.find((candidate) => fs.existsSync(candidate)) || '' ); } function normalizeTargetURL(input, host, port) { const value = String(input || DEFAULT_URL).trim() || DEFAULT_URL; if (/^https?:\/\//i.test(value)) return value; const cleanPath = value.startsWith('/') ? value : `/${value.replace(/^\.?\//, '')}`; return `http://${host}:${port}${cleanPath}`; } function isLocalTarget(url, host, port) { try { const parsed = new URL(url); return ( parsed.hostname === host && String(parsed.port || 80) === String(port) ); } catch (_) { return false; } } function healthURL(host, port) { return `http://${host}:${port}/__h5_dev_server__/health`; } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function requestHealth(url) { return new Promise((resolve) => { const req = http.get(url, (res) => { res.resume(); resolve(res.statusCode === 200); }); req.on('error', () => resolve(false)); req.setTimeout(800, () => { req.destroy(); resolve(false); }); }); } async function ensureLocalServer(host, port) { const url = healthURL(host, port); if (await requestHealth(url)) return null; const server = childProcess.spawn( 'python3', [ path.join(PROJECT_ROOT, 'scripts/h5_dev_server.py'), '--host', host, '--port', String(port), '--directory', PROJECT_ROOT, ], { cwd: PROJECT_ROOT, stdio: ['ignore', 'pipe', 'pipe'], } ); server.stdout.on('data', (chunk) => process.stdout.write(`[h5-server] ${chunk}`) ); server.stderr.on('data', (chunk) => process.stderr.write(`[h5-server] ${chunk}`) ); for (let i = 0; i < 30; i += 1) { if (await requestHealth(url)) return server; await wait(200); } server.kill(); throw new Error(`Failed to start local H5 server: ${url}`); } async function askRequired(rl, label, currentValue) { if (currentValue) return currentValue; const value = (await rl.question(label)).trim(); if (!value) throw new Error(`${label.replace(/[::]\s*$/, '')} is required`); return value; } async function askPassword(rl, currentValue) { if (currentValue) return currentValue; if (!stdin.isTTY) return askRequired(rl, 'Password: ', currentValue); return new Promise((resolve, reject) => { let value = ''; stdout.write('Password: '); stdin.setRawMode(true); stdin.resume(); const onData = (buffer) => { const text = buffer.toString('utf8'); if (text === '\u0003') { stdin.setRawMode(false); stdin.off('data', onData); reject(new Error('Cancelled')); return; } if (text === '\r' || text === '\n') { stdin.setRawMode(false); stdin.off('data', onData); stdout.write('\n'); resolve(value); return; } if (text === '\u007f') { if (value.length > 0) { value = value.slice(0, -1); stdout.write('\b \b'); } return; } value += text; stdout.write('*'); }; stdin.on('data', onData); }); } function buildReqAppIntel(model, channel, imei) { return [ 'build=1', 'version=1.0.0', `model=${model}`, 'sysVersion=web', `channel=${channel}`, `Req-Imei=${imei}`, ].join(';'); } async function login(options) { const imei = 'codex-aslan-webview'; const response = await fetch( `${normalizeBaseURL(options.apiBase)}/auth/account/login`, { method: 'POST', headers: { 'content-type': 'application/json', 'req-lang': options.lang, 'req-client': 'Android', 'req-imei': imei, 'req-app-intel': buildReqAppIntel( 'CodexWebViewLogin', 'Codex', imei ), 'req-sys-origin': `origin=${options.origin};originChild=${options.originChild}`, 'req-version': 'V2', 'req-zone': 'Asia/Shanghai', 'user-agent': 'curl/8.7.1', }, body: JSON.stringify({ account: options.account, pwd: options.password, }), } ); const text = await response.text(); let payload; try { payload = text ? JSON.parse(text) : {}; } catch (_) { throw new Error( `Login returned non-JSON response: HTTP ${response.status} ${text.slice(0, 200)}` ); } if ( !response.ok || payload.status === false || !payload.body || !payload.body.token ) { throw new Error( `Login failed: HTTP ${response.status} ${payload.errorMsg || payload.message || text.slice(0, 200)}` ); } return { token: payload.body.token, profile: payload.body.userProfile || {}, }; } function buildInitScriptPayload(options, token) { const imei = 'codex-aslan-webview'; return { token, lang: options.lang, origin: options.origin, originChild: options.originChild, reqAppIntel: buildReqAppIntel('CodexFlutterWebView', 'Codex', imei), }; } async function openWebView(options, token, firstURL) { const chromePath = resolveChromePath(options.chrome); if (!chromePath) { throw new Error( 'Chrome executable not found. Pass --chrome /path/to/chrome.' ); } const launchArgs = ['--window-size=390,844']; if (options.disableWebSecurity) { launchArgs.push('--disable-web-security'); launchArgs.push('--disable-features=IsolateOrigins,site-per-process'); } const browser = await chromium.launch({ executablePath: chromePath, headless: options.headless, devtools: options.devtools, args: launchArgs, }); const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 14; Aslan Flutter WebView) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.0.0 Mobile Safari/537.36', }); await context.addInitScript( (config) => { window.FlutterApp = window.FlutterApp || { postMessage(data) { console.log('[FlutterApp.postMessage]', data); }, }; window.FlutterPageControl = window.FlutterPageControl || { postMessage(data) { console.log('[FlutterPageControl.postMessage]', data); }, }; window.app = window.app || {}; window.app.getAccessOrigin = function getAccessOrigin() { return JSON.stringify({ Authorization: `Bearer ${config.token}`, 'Req-Lang': config.lang, 'Req-App-Intel': config.reqAppIntel, 'Req-Sys-Origin': `origin=${config.origin};originChild=${config.originChild}`, }); }; window.app.getAuth = function getAuth() { return window.app.getAccessOrigin(); }; window.app.sendToFlutter = function sendToFlutter(data) { window.FlutterApp.postMessage(data); }; }, buildInitScriptPayload(options, token) ); const page = await context.newPage(); page.on('console', (message) => { const type = message.type(); if (type === 'error' || type === 'warning') { console.log(`[webview:${type}] ${message.text()}`); } }); await page.goto(firstURL, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page .waitForLoadState('networkidle', { timeout: 15000 }) .catch(() => {}); return { browser, page }; } async function promptNavigationLoop(rl, page, options) { console.log( '输入新的 URL 回车可跳转;输入 q 退出。相对路径会走本地 H5 服务。' ); while (!page.isClosed()) { const next = (await rl.question('WebView URL> ')).trim(); if (!next) continue; if (next.toLowerCase() === 'q' || next.toLowerCase() === 'quit') break; const targetURL = normalizeTargetURL(next, options.host, options.port); if (isLocalTarget(targetURL, options.host, options.port)) { await ensureLocalServer(options.host, options.port); } console.log(`Navigate: ${targetURL}`); await page.goto(targetURL, { waitUntil: 'domcontentloaded', timeout: 30000, }); await page .waitForLoadState('networkidle', { timeout: 15000 }) .catch(() => {}); } } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { console.log(usage()); return; } options.account = options.account || process.env.ASLAN_ACCOUNT || ''; options.password = options.password || process.env.ASLAN_PASSWORD || ''; options.token = options.token || process.env.ASLAN_TOKEN || ''; options.url = options.url || process.env.ASLAN_WEBVIEW_URL || ''; const rl = readline.createInterface({ input: stdin, output: stdout }); let localServer = null; let browser = null; try { if (!options.token) { options.account = await askRequired( rl, 'Account: ', options.account ); options.password = await askPassword(rl, options.password); } options.url = options.url || (await rl.question(`URL [${DEFAULT_URL}]: `)).trim() || DEFAULT_URL; const targetURL = normalizeTargetURL( options.url, options.host, options.port ); if (isLocalTarget(targetURL, options.host, options.port)) { localServer = await ensureLocalServer(options.host, options.port); } const loginResult = options.token ? { token: options.token, profile: { account: 'token' } } : await login(options); const displayAccount = loginResult.profile.account || loginResult.profile.id || options.account; console.log( `${options.token ? 'Token loaded' : 'Login OK'}: ${loginResult.profile.userNickname || '-'} / ${displayAccount}` ); console.log(`Open WebView: ${targetURL}`); const result = await openWebView(options, loginResult.token, targetURL); browser = result.browser; if (options.screenshot) { await result.page.screenshot({ path: path.resolve(options.screenshot), fullPage: true, }); console.log( `Screenshot saved: ${path.resolve(options.screenshot)}` ); } if (options.closeAfter > 0) { await wait(options.closeAfter * 1000); return; } if (options.navigationPrompt && !options.headless) { await promptNavigationLoop(rl, result.page, options); return; } await result.page.waitForEvent('close').catch(() => {}); } finally { rl.close(); if (browser) await browser.close().catch(() => {}); if (localServer) localServer.kill(); } } main().catch((error) => { console.error(error.message || error); process.exit(1); });