512 lines
16 KiB
JavaScript
Executable File
512 lines
16 KiB
JavaScript
Executable File
#!/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);
|
||
});
|