hyapp-h5/scripts/aslan_flutter_webview.js
2026-07-09 19:18:58 +08:00

512 lines
16 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <value> Aslan login account or special ID',
' -p, --password <value> Aslan login password',
' --token <value> Existing Aslan token; skips account password login',
' -u, --url <value> Target H5 URL. Relative paths use the local H5 dev server.',
' --api-base <url> API base, default https://api.atuchat.com/',
' --origin <value> Req-Sys-Origin origin, default ATYOU',
' --origin-child <value> Req-Sys-Origin originChild, default same as origin',
' --lang <value> Req-Lang, default en',
' --chrome <path> 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 <seconds> Auto close, useful for smoke tests',
' --screenshot <path> 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);
});