- Updated markAccountUnavailable function to accept resetsAtMs for precise cooldown management.
- Added email backfill functionality for Codex OAuth connections to improve account information accuracy.
This commit is contained in:
187
src/lib/updater/updater.js
Normal file
187
src/lib/updater/updater.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// Standalone detached updater process.
|
||||
// Spawns `npm i -g <pkg>@latest`, exposes progress via tiny HTTP server.
|
||||
// Survives after parent Next server exits (detached + unref by spawner).
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
const packageName = process.env.UPDATER_PKG_NAME || "9router";
|
||||
const port = parseInt(process.env.UPDATER_PORT || "20129", 10);
|
||||
const tailLines = parseInt(process.env.UPDATER_TAIL_LINES || "8", 10);
|
||||
const maxRetries = parseInt(process.env.UPDATER_RETRIES || "3", 10);
|
||||
const retryDelayMs = parseInt(process.env.UPDATER_RETRY_DELAY_MS || "5000", 10);
|
||||
const lingerMs = parseInt(process.env.UPDATER_LINGER_MS || "30000", 10);
|
||||
const waitMinMs = parseInt(process.env.UPDATER_WAIT_MIN_MS || "3000", 10);
|
||||
const waitMaxMs = parseInt(process.env.UPDATER_WAIT_MAX_MS || "15000", 10);
|
||||
const waitCheckMs = parseInt(process.env.UPDATER_WAIT_CHECK_MS || "500", 10);
|
||||
const appPort = parseInt(process.env.UPDATER_APP_PORT || "20128", 10);
|
||||
|
||||
// Data directory (match mitm/paths.js logic)
|
||||
function getDataDir() {
|
||||
if (process.env.DATA_DIR) return process.env.DATA_DIR;
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router");
|
||||
}
|
||||
return path.join(os.homedir(), ".9router");
|
||||
}
|
||||
const updateDir = path.join(getDataDir(), "update");
|
||||
try { fs.mkdirSync(updateDir, { recursive: true }); } catch { /* best effort */ }
|
||||
const statusFile = path.join(updateDir, "status.json");
|
||||
const logFile = path.join(updateDir, "install.log");
|
||||
|
||||
const state = {
|
||||
phase: "starting",
|
||||
packageName,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
attempt: 0,
|
||||
maxRetries,
|
||||
done: false,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
error: null,
|
||||
logTail: [],
|
||||
};
|
||||
|
||||
function pushLog(line) {
|
||||
const trimmed = line.replace(/\r?\n$/, "");
|
||||
if (!trimmed) return;
|
||||
state.logTail.push(trimmed);
|
||||
if (state.logTail.length > tailLines) state.logTail = state.logTail.slice(-tailLines);
|
||||
try { fs.appendFileSync(logFile, `${trimmed}\n`); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
function persistStatus() {
|
||||
try { fs.writeFileSync(statusFile, JSON.stringify(state, null, 2)); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
function setPhase(phase) {
|
||||
state.phase = phase;
|
||||
persistStatus();
|
||||
}
|
||||
|
||||
// HTTP server exposing status (browser polls this while Next server is dead)
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
if (req.url === "/update/status" || req.url === "/") {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(state));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
});
|
||||
|
||||
server.on("error", (e) => {
|
||||
state.error = `status server error: ${e.message}`;
|
||||
persistStatus();
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
persistStatus();
|
||||
waitForAppExit().then(runInstall);
|
||||
});
|
||||
|
||||
// Check if app port is still being listened on (= app server still alive)
|
||||
function isAppPortBusy() {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const done = (busy) => {
|
||||
socket.destroy();
|
||||
resolve(busy);
|
||||
};
|
||||
socket.setTimeout(300);
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("timeout", () => done(false));
|
||||
socket.once("error", () => done(false));
|
||||
socket.connect(appPort, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for app process to fully exit before running npm (avoids Windows file-lock)
|
||||
async function waitForAppExit() {
|
||||
setPhase("waitingForExit");
|
||||
pushLog(`[updater] waiting for app to exit (min ${Math.round(waitMinMs / 1000)}s)...`);
|
||||
|
||||
// Hard minimum delay: OS needs time to release file handles
|
||||
await sleep(waitMinMs);
|
||||
|
||||
// Poll app port until free or max timeout
|
||||
const deadline = Date.now() + (waitMaxMs - waitMinMs);
|
||||
while (Date.now() < deadline) {
|
||||
const busy = await isAppPortBusy();
|
||||
if (!busy) {
|
||||
pushLog(`[updater] app port :${appPort} is free, proceeding`);
|
||||
return;
|
||||
}
|
||||
await sleep(waitCheckMs);
|
||||
}
|
||||
pushLog(`[updater] timeout waiting for app, proceeding anyway`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function runInstall() {
|
||||
state.attempt += 1;
|
||||
setPhase("installing");
|
||||
pushLog(`[updater] attempt ${state.attempt}/${maxRetries} — npm i -g ${packageName}`);
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const cmd = isWin ? "npm.cmd" : "npm";
|
||||
const args = ["i", "-g", packageName];
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
shell: isWin,
|
||||
});
|
||||
|
||||
child.stdout.on("data", (buf) => {
|
||||
buf.toString().split(/\r?\n/).forEach(pushLog);
|
||||
persistStatus();
|
||||
});
|
||||
child.stderr.on("data", (buf) => {
|
||||
buf.toString().split(/\r?\n/).forEach(pushLog);
|
||||
persistStatus();
|
||||
});
|
||||
|
||||
child.on("error", (e) => {
|
||||
pushLog(`[updater] spawn error: ${e.message}`);
|
||||
finalize(false, null, e.message);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
pushLog(`[updater] npm exited with code ${code}`);
|
||||
if (code === 0) {
|
||||
finalize(true, code, null);
|
||||
return;
|
||||
}
|
||||
if (state.attempt < maxRetries) {
|
||||
pushLog(`[updater] retrying in ${Math.round(retryDelayMs / 1000)}s...`);
|
||||
setTimeout(runInstall, retryDelayMs);
|
||||
return;
|
||||
}
|
||||
finalize(false, code, `Install failed after ${maxRetries} attempts`);
|
||||
});
|
||||
}
|
||||
|
||||
function finalize(success, exitCode, error) {
|
||||
state.done = true;
|
||||
state.success = success;
|
||||
state.exitCode = exitCode;
|
||||
state.error = error;
|
||||
state.finishedAt = Date.now();
|
||||
setPhase(success ? "done" : "error");
|
||||
// Linger so browser can poll final status, then exit & close the port
|
||||
setTimeout(() => {
|
||||
try { server.close(); } catch { /* ignore */ }
|
||||
process.exit(success ? 0 : 1);
|
||||
}, lingerMs);
|
||||
}
|
||||
Reference in New Issue
Block a user