perf(portable): U盘启动加速 — 缓存搬本机 + 启动首屏 + 首轮预热
便携版从 U 盘启动慢,瓶颈在 U 盘随机小写 IO + 首屏无反馈 + 首轮冷启动。 移植 v2 u-clawx 4.0 的 4 个可在纯脚本层复刻的手段(均零依赖、静默失败): - portable-cache.mjs: 把浏览器 user-data(几百 MB 随机小写)+ V8 编译缓存 从 U 盘搬到本机 SSD(win junction / mac symlink 重定向 .openclaw/browser, NODE_COMPILE_CACHE 落本机)。UUID 隔离让换盘符仍命中同一份缓存。 业务数据(openclaw.json/memory/账号)仍留 U 盘,便携性不变。 - loading.html: 双击即弹的启动首屏,自轮询 /ready,gateway 真就绪后自动跳 Dashboard——天然规避"gateway 没起就开 Dashboard 拒连"(issue #46/#48)。 - prewarm.mjs: gateway 首轮预热,后台静默唤醒 config/model 子系统。 - Windows-Start.bat: 等 config-server 改写死 timeout 为动态轮询 18788。 - wait-gateway.bat 退居兜底(首屏页负责主跳转)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
138
portable/lib/loading.html
Normal file
138
portable/lib/loading.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>U-Claw 正在启动…</title>
|
||||
<!--
|
||||
启动首屏(移植自 v2 u-clawx 4.0 的 splash 思路)。
|
||||
双击启动脚本后立刻打开本页,给用户即时反馈,消除"黑窗假死"体感。
|
||||
本页每秒自查 gateway 是否就绪(fetch /ready),就绪后自动跳转 Dashboard。
|
||||
|
||||
端口通过 URL 查询参数传入:loading.html?port=18789
|
||||
(启动脚本打开时拼好。默认 18789。)
|
||||
-->
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.box {
|
||||
text-align: center;
|
||||
max-width: 460px;
|
||||
padding: 40px;
|
||||
}
|
||||
.logo { font-size: 64px; line-height: 1; margin-bottom: 18px; }
|
||||
h1 { font-size: 1.7em; font-weight: 600; margin-bottom: 8px; }
|
||||
.tip { opacity: .85; font-size: 1.02em; line-height: 1.6; margin-bottom: 28px; }
|
||||
.spinner {
|
||||
width: 46px; height: 46px;
|
||||
margin: 0 auto 24px;
|
||||
border: 4px solid rgba(255,255,255,.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.bar {
|
||||
height: 6px; width: 100%;
|
||||
background: rgba(255,255,255,.2);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.bar > i {
|
||||
display: block; height: 100%; width: 30%;
|
||||
background: #fff; border-radius: 999px;
|
||||
animation: slide 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes slide {
|
||||
0% { margin-left: -30%; }
|
||||
100% { margin-left: 100%; }
|
||||
}
|
||||
.status { font-size: .95em; opacity: .9; min-height: 1.4em; }
|
||||
.elapsed { font-size: .82em; opacity: .6; margin-top: 6px; }
|
||||
.note {
|
||||
margin-top: 30px; font-size: .82em; opacity: .65; line-height: 1.6;
|
||||
}
|
||||
a.manual { color: #fff; text-decoration: underline; opacity: .9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="logo">🦞</div>
|
||||
<h1>U-Claw 正在启动</h1>
|
||||
<div class="tip">首次从 U 盘启动需要展开内置组件,<br>稍等片刻,就绪后会自动打开。</div>
|
||||
<div class="spinner"></div>
|
||||
<div class="bar"><i></i></div>
|
||||
<div class="status" id="status">正在等待 OpenClaw 网关…</div>
|
||||
<div class="elapsed" id="elapsed"></div>
|
||||
<div class="note">
|
||||
慢 U 盘首次启动通常 30–90 秒。<br>
|
||||
如果迟迟没反应,可手动打开
|
||||
<a class="manual" id="manual" href="#">控制台</a>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var port = parseInt(params.get('port'), 10) || 18789;
|
||||
var token = params.get('token') || 'uclaw';
|
||||
var base = 'http://127.0.0.1:' + port;
|
||||
var dashboard = base + '/#token=' + encodeURIComponent(token);
|
||||
var configCenter = 'http://127.0.0.1:18788/';
|
||||
|
||||
document.getElementById('manual').href = dashboard;
|
||||
|
||||
var startedAt = Date.now();
|
||||
var redirected = false;
|
||||
|
||||
function fmt(ms) {
|
||||
var s = Math.floor(ms / 1000);
|
||||
return '已等待 ' + s + ' 秒';
|
||||
}
|
||||
|
||||
function tickElapsed() {
|
||||
document.getElementById('elapsed').textContent = fmt(Date.now() - startedAt);
|
||||
}
|
||||
setInterval(tickElapsed, 1000);
|
||||
tickElapsed();
|
||||
|
||||
function goDashboard() {
|
||||
if (redirected) return;
|
||||
redirected = true;
|
||||
document.getElementById('status').textContent = '网关已就绪,正在打开控制台…';
|
||||
setTimeout(function () { location.replace(dashboard); }, 400);
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (redirected) return;
|
||||
// /ready 就绪即跳。no-cors 下拿不到 status,但 fetch 成功 resolve 就说明端口活了。
|
||||
fetch(base + '/ready', { mode: 'no-cors', cache: 'no-store' })
|
||||
.then(function () { goDashboard(); })
|
||||
.catch(function () {
|
||||
// 端口还没起,继续等
|
||||
setTimeout(poll, 1000);
|
||||
});
|
||||
}
|
||||
poll();
|
||||
|
||||
// 兜底:5 分钟还没起来,提示去控制台
|
||||
setTimeout(function () {
|
||||
if (!redirected) {
|
||||
document.getElementById('status').innerHTML =
|
||||
'启动较慢,可先打开 <a class="manual" href="' + configCenter + '">配置中心</a> 检查';
|
||||
}
|
||||
}, 300000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
185
portable/lib/portable-cache.mjs
Normal file
185
portable/lib/portable-cache.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
// portable-cache.mjs — 把"重 IO、可重建"的缓存从 U 盘搬到本机硬盘
|
||||
//
|
||||
// 背景(U 盘启动慢的最大根因):
|
||||
// - OpenClaw 的浏览器 user-data 落在 OPENCLAW_STATE_DIR/browser/<profile>/user-data,
|
||||
// 即 U 盘 data/.openclaw/ 下。Chromium 会对它做海量随机小写,慢 U 盘上极致拖累。
|
||||
// - Node 的 V8 编译缓存(module.enableCompileCache)默认落系统 temp,可能被清而每次重编译。
|
||||
//
|
||||
// 方案(移植自 v2 u-clawx 4.0 的 portable-session-data.ts 思路):
|
||||
// 把这两类"可重建、不需便携"的缓存重定向到本机硬盘的固定位置:
|
||||
// Windows: %LOCALAPPDATA%\U-Claw\...
|
||||
// macOS: ~/Library/Caches/U-Claw/...
|
||||
// Linux: $XDG_CACHE_HOME/U-Claw 或 ~/.cache/U-Claw
|
||||
// 业务数据(openclaw.json、memory、账号)仍留在 U 盘 data/,便携性不变。
|
||||
//
|
||||
// 浏览器 user-data 的重定向手法:
|
||||
// OpenClaw 把浏览器 profile 硬编码在 CONFIG_DIR/browser/(无单独环境变量可改),
|
||||
// 只有 OPENCLAW_STATE_DIR 能整体搬走——但那会连 openclaw.json 一起搬,破坏便携。
|
||||
// 所以这里用"目录联接/符号链接":把 U 盘的 data/.openclaw/browser 做成一个
|
||||
// junction(Windows) / symlink(mac/linux),指向本机硬盘缓存。OpenClaw 照常写
|
||||
// CONFIG_DIR/browser,字节却落在本机盘上。配置仍在 U 盘,便携性不受影响。
|
||||
//
|
||||
// UUID 隔离(移植自 4.0):
|
||||
// 缓存子目录名 = sha256("portable-id:<UUID>") 前 16 hex。UUID 存在 U 盘 STATE_DIR 里,
|
||||
// 所以同一支 U 盘从 D: 插到 E: 仍命中同一份本机缓存,不必重新热身。
|
||||
//
|
||||
// 设计原则:静默失败。任何一步出错都回退到"用 U 盘内目录",绝不阻断启动。
|
||||
//
|
||||
// CLI 用法(供 .bat / .command source):
|
||||
// node portable-cache.mjs <STATE_DIR> <USB_ROOT>
|
||||
// 输出(每行 KEY=VALUE,路径已 mkdir):
|
||||
// UCLAW_COMPILE_CACHE_DIR=...
|
||||
// UCLAW_BROWSER_USER_DATA_DIR=...
|
||||
// UCLAW_CACHE_ROOT=...
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, readlinkSync, symlinkSync, readdirSync, rmdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const CACHE_ID_FILE = 'portable-cache-id';
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// 本机缓存根:各平台的"用户缓存"约定位置。绝不放 U 盘。
|
||||
function systemCacheRoot(platform = process.platform, env = process.env) {
|
||||
if (platform === 'win32') {
|
||||
return env.LOCALAPPDATA?.trim() || join(homedir() || tmpdir(), 'AppData', 'Local');
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return join(homedir() || tmpdir(), 'Library', 'Caches');
|
||||
}
|
||||
return env.XDG_CACHE_HOME?.trim() || join(homedir() || tmpdir(), '.cache');
|
||||
}
|
||||
|
||||
// 读/建 U 盘上的稳定 UUID,使缓存身份与盘符解耦。
|
||||
function readOrCreateCacheId(stateDir) {
|
||||
if (!stateDir) return null;
|
||||
const idPath = join(stateDir, CACHE_ID_FILE);
|
||||
try {
|
||||
if (existsSync(idPath)) {
|
||||
const existing = readFileSync(idPath, 'utf8').trim();
|
||||
if (UUID_RE.test(existing)) return existing.toLowerCase();
|
||||
}
|
||||
const next = randomUUID();
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(idPath, `${next}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
return next;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析本机缓存目录集合。stateDir 缺失/不可写时回退到 U 盘内目录。
|
||||
export function resolvePortableCache({
|
||||
stateDir,
|
||||
usbRoot,
|
||||
platform = process.platform,
|
||||
env = process.env,
|
||||
} = {}) {
|
||||
const cacheId = readOrCreateCacheId(stateDir);
|
||||
// 有 UUID 用 UUID,否则退而用 U 盘路径做身份(仍稳定,只是换盘符会换目录)
|
||||
const identity = cacheId ? `portable-id:${cacheId}` : String(usbRoot || stateDir || 'u-claw').toLowerCase();
|
||||
const slot = createHash('sha256').update(identity).digest('hex').slice(0, 16);
|
||||
|
||||
let root;
|
||||
try {
|
||||
root = join(systemCacheRoot(platform, env), 'U-Claw', slot);
|
||||
mkdirSync(root, { recursive: true });
|
||||
} catch {
|
||||
// 本机缓存根不可写 → 整体回退到 U 盘内(保证不报错,只是没加速)
|
||||
root = stateDir ? join(stateDir, 'cache') : join(tmpdir(), 'u-claw-cache', slot);
|
||||
try { mkdirSync(root, { recursive: true }); } catch { /* 实在不行就让调用方拿到路径自己兜底 */ }
|
||||
}
|
||||
|
||||
const compileCacheDir = join(root, 'node-compile-cache');
|
||||
const browserUserDataDir = join(root, 'browser');
|
||||
for (const d of [compileCacheDir, browserUserDataDir]) {
|
||||
try { mkdirSync(d, { recursive: true }); } catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
// 把 U 盘的 .openclaw/browser 链到本机缓存。失败就不链——OpenClaw 退回原地写 U 盘,只是没加速。
|
||||
let browserLinked = false;
|
||||
if (stateDir) {
|
||||
browserLinked = linkBrowserDir(join(stateDir, 'browser'), browserUserDataDir, platform);
|
||||
}
|
||||
|
||||
return { root, compileCacheDir, browserUserDataDir, browserLinked, cacheId };
|
||||
}
|
||||
|
||||
// 判断一个路径是否"已是指向 target 的链接"。
|
||||
function isLinkTo(linkPath, target) {
|
||||
try {
|
||||
const st = lstatSync(linkPath);
|
||||
if (!st.isSymbolicLink()) {
|
||||
// Windows junction 在 lstat 下 isDirectory()=true、isSymbolicLink()=false,
|
||||
// 用 readlink 兜底判断:能 readlink 成功且指向 target 就算已链。
|
||||
try {
|
||||
const resolved = readlinkSync(linkPath);
|
||||
return resolved && target && resolved.replace(/[\\/]+$/, '') === target.replace(/[\\/]+$/, '');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const resolved = readlinkSync(linkPath);
|
||||
return resolved.replace(/[\\/]+$/, '') === target.replace(/[\\/]+$/, '');
|
||||
} catch {
|
||||
return false; // 不存在
|
||||
}
|
||||
}
|
||||
|
||||
// 把 linkPath(U 盘 .openclaw/browser)做成指向 target(本机缓存/browser)的链接。
|
||||
// 返回 true=已链好。已存在真实目录且非空则不动(保住用户既有 profile,宁可慢也不丢数据)。
|
||||
function linkBrowserDir(linkPath, target, platform) {
|
||||
try {
|
||||
if (existsSync(linkPath)) {
|
||||
if (isLinkTo(linkPath, target)) return true; // 之前已链好
|
||||
// 是真实目录:若为空可以安全替换成链接;非空则保守不动。
|
||||
let entries = [];
|
||||
try { entries = readdirSync(linkPath); } catch { return false; }
|
||||
if (entries.length > 0) return false; // 有既有 profile,不冒险,留在 U 盘
|
||||
// 空目录:删掉再链
|
||||
try { rmdirCompat(linkPath); } catch { return false; }
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
// mklink /J 目录联接:无需管理员权限,行为最接近"同一目录"
|
||||
execFileSync('cmd', ['/c', 'mklink', '/J', linkPath, target], { stdio: 'ignore' });
|
||||
} else {
|
||||
symlinkSync(target, linkPath, 'dir');
|
||||
}
|
||||
return isLinkTo(linkPath, target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function rmdirCompat(dir) {
|
||||
// 只删空目录,避免误删用户数据
|
||||
rmdirSync(dir);
|
||||
}
|
||||
|
||||
// CLI:打印 KEY=VALUE,供启动脚本逐行 set / export。
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const isMain = (() => {
|
||||
try { return !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; }
|
||||
catch { return false; }
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
const stateDir = process.argv[2] || process.env.OPENCLAW_STATE_DIR;
|
||||
const usbRoot = process.argv[3] || process.env.UCLAW_DIR;
|
||||
try {
|
||||
const c = resolvePortableCache({ stateDir, usbRoot });
|
||||
process.stdout.write(
|
||||
`UCLAW_CACHE_ROOT=${c.root}\n` +
|
||||
`UCLAW_COMPILE_CACHE_DIR=${c.compileCacheDir}\n` +
|
||||
`UCLAW_BROWSER_USER_DATA_DIR=${c.browserUserDataDir}\n` +
|
||||
`UCLAW_BROWSER_LINKED=${c.browserLinked ? '1' : '0'}\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
// 静默失败:不输出任何 KEY,启动脚本会按"未设置"继续(缓存留 U 盘)
|
||||
process.stderr.write(`[portable-cache] ${err && err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
91
portable/lib/prewarm.mjs
Normal file
91
portable/lib/prewarm.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
// prewarm.mjs — gateway 首轮预热(移植自 v2 u-clawx 4.0 的 first-turn-prewarm 思路)
|
||||
//
|
||||
// 问题:用户首次点"发送"时,gateway 才去加载配置、解析 provider 鉴权、拉模型列表,
|
||||
// 慢 U 盘 + 冷启动下这一下要等好几秒。
|
||||
// 方案:gateway 端口 LISTENING 后,后台静默地把这些"首轮才会触发"的子系统先唤醒一遍:
|
||||
// 轮询 /ready → 命中后依次 GET /status、/models(带 token),让 config/model
|
||||
// 缓存在 runtime 内存里热起来。用户真正发第一条消息时就不再等。
|
||||
//
|
||||
// 设计原则(与 lib/ 其它脚本一致):
|
||||
// - 纯 Node、零依赖(只用 fetch)
|
||||
// - 静默失败:任何错误都不抛、不影响 gateway
|
||||
// - 后台 detach 跑(启动脚本用 start /B 或 &),绝不阻塞
|
||||
// - 短超时、有上限,不会挂着不退
|
||||
//
|
||||
// CLI 用法(启动脚本后台调用):
|
||||
// node prewarm.mjs <PORT> [TOKEN]
|
||||
// 默认 PORT=18789, TOKEN=uclaw
|
||||
|
||||
const READY_TIMEOUT_MS = 90_000; // 最多等 gateway 就绪 90s(慢 U 盘首启可能要这么久)
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const STEP_TIMEOUT_MS = 8_000;
|
||||
|
||||
function log(msg) {
|
||||
// 写 stderr,不污染可能被采集的 stdout;启动脚本一般 >nul 丢弃
|
||||
try { process.stderr.write(`[prewarm] ${msg}\n`); } catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
async function httpGet(url, token, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: token ? { 'x-openclaw-token': token } : {},
|
||||
});
|
||||
// 读掉 body 让对端真正干完活(否则可能短路)
|
||||
await res.text().catch(() => {});
|
||||
return res.ok || res.status === 401; // 401 也算"服务在",说明端口活了
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitReady(base, token, deadline) {
|
||||
while (Date.now() < deadline) {
|
||||
if (await httpGet(`${base}/ready`, token, 3_000)) return true;
|
||||
if (await httpGet(`${base}/healthz`, token, 3_000)) return true;
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function prewarm({ port = 18789, token = 'uclaw' } = {}) {
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
|
||||
const ready = await waitReady(base, token, deadline);
|
||||
if (!ready) {
|
||||
log(`gateway not ready within ${READY_TIMEOUT_MS}ms, skip`);
|
||||
return { ok: false, reason: 'not-ready' };
|
||||
}
|
||||
|
||||
// 依次唤醒:status(gateway/config)→ models(provider 鉴权 + 模型目录)
|
||||
const steps = ['/status', '/models'];
|
||||
const failed = [];
|
||||
const started = Date.now();
|
||||
for (const path of steps) {
|
||||
const t0 = Date.now();
|
||||
const ok = await httpGet(`${base}${path}`, token, STEP_TIMEOUT_MS);
|
||||
log(`step ${path} ${ok ? 'ok' : 'failed'} ${Date.now() - t0}ms`);
|
||||
if (!ok) failed.push(path);
|
||||
}
|
||||
log(`done ${Date.now() - started}ms failed=${failed.length ? failed.join(',') : 'none'}`);
|
||||
return { ok: true, failed };
|
||||
}
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const isMain = (() => {
|
||||
try { return !!process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; }
|
||||
catch { return false; }
|
||||
})();
|
||||
|
||||
if (isMain) {
|
||||
const port = parseInt(process.argv[2], 10) || parseInt(process.env.UCLAW_GATEWAY_PORT, 10) || 18789;
|
||||
const token = process.argv[3] || process.env.UCLAW_GATEWAY_TOKEN || 'uclaw';
|
||||
prewarm({ port, token })
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => { log(`fatal ${err && err.message}`); process.exit(0); });
|
||||
}
|
||||
@@ -3,6 +3,10 @@ REM wait-gateway.bat - open Dashboard only after the Gateway is actually listeni
|
||||
REM Fixes issue #46/#48: on slow USB drives the gateway needs tens of seconds to
|
||||
REM stage bundled deps on first run; opening http://127.0.0.1:PORT before it is
|
||||
REM LISTENING shows "connection refused" and users think it is broken.
|
||||
REM
|
||||
REM 角色变更:现在 Windows-Start.bat 会先开 lib\loading.html 启动首屏,由首屏
|
||||
REM 自己轮询 /ready 并自动跳 Dashboard。本脚本退居"兜底"——万一首屏页的
|
||||
REM file:// fetch 被浏览器拦住没跳成,这里仍轮询端口、就绪后开 Dashboard。
|
||||
REM Usage (called in background by Windows-Start.bat): wait-gateway.bat PORT
|
||||
REM Polls every 2s, up to 150 tries (~5 min). Opens the browser once ready.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user