- 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:
decolua
2026-04-24 11:36:16 +07:00
parent fd8163e26e
commit 030fb34f88
15 changed files with 628 additions and 132 deletions

View File

@@ -78,48 +78,44 @@ function collectAppPids() {
return pids;
}
// Build the .bat content for Windows update flow
function buildWindowsScript(packageName) {
return `@echo off
timeout /t 3 /nobreak >nul
echo Installing new version...
npm install -g ${packageName}@latest --prefer-online
if %ERRORLEVEL% EQU 0 (
echo.
echo Update completed. Run "${packageName}" to start.
) else (
echo.
echo Update failed. Try manually: npm install -g ${packageName}@latest
)
pause
`;
// Copy updater.js into DATA_DIR so npm -g can overwrite node_modules safely
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");
}
// Build the .sh content for macOS/Linux update flow
function buildUnixScript(packageName) {
return `#!/bin/bash
echo "Installing new version..."
sleep 2
function resolveBundledUpdaterPath() {
if (process.env.UPDATER_SCRIPT_PATH && fs.existsSync(process.env.UPDATER_SCRIPT_PATH)) {
return process.env.UPDATER_SCRIPT_PATH;
}
// Production standalone: cwd is binAppDir (see bin/cli.js)
// Dev: cwd is app/
const fromCwd = path.join(process.cwd(), "src", "lib", "updater", "updater.js");
if (fs.existsSync(fromCwd)) return fromCwd;
const fromParent = path.join(process.cwd(), "..", "src", "lib", "updater", "updater.js");
if (fs.existsSync(fromParent)) return fromParent;
return fromCwd;
}
npm cache clean --force 2>/dev/null
EXIT_CODE=1
for i in 1 2 3; do
npm install -g ${packageName}@latest --prefer-online 2>&1
EXIT_CODE=$?
[ $EXIT_CODE -eq 0 ] && break
echo "Retry $i/3..."
sleep 5
done
if [ $EXIT_CODE -eq 0 ]; then
echo ""
echo "Update completed. Run \\"${packageName}\\" to start."
else
echo ""
echo "Update failed (exit code: $EXIT_CODE)"
echo "Try manually: npm install -g ${packageName}@latest"
fi
`;
function ensureRuntimeUpdater(bundledPath) {
try {
if (!bundledPath || !fs.existsSync(bundledPath)) return bundledPath;
const runtimeDir = path.join(getDataDir(), "runtime", "updater");
const runtimePath = path.join(runtimeDir, "updater.js");
if (fs.existsSync(runtimePath)) {
try {
if (fs.statSync(bundledPath).size === fs.statSync(runtimePath).size) return runtimePath;
} catch { /* recopy */ }
}
fs.mkdirSync(runtimeDir, { recursive: true });
fs.copyFileSync(bundledPath, runtimePath);
return runtimePath;
} catch {
return bundledPath;
}
}
// Kill all app-related processes to release file locks (esp. on Windows)
@@ -143,26 +139,27 @@ export async function killAppProcesses() {
}
}
// Spawn detached updater script and schedule current process to exit
// Spawn detached headless updater (Node process) then exit current server
export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName) {
const platform = process.platform;
if (platform === "win32") {
const scriptPath = path.join(os.tmpdir(), `${packageName}-update.bat`);
fs.writeFileSync(scriptPath, buildWindowsScript(packageName));
spawn("cmd", ["/c", "start", "", "cmd", "/c", scriptPath], {
detached: true,
stdio: "ignore",
windowsHide: false,
}).unref();
} else {
const scriptPath = path.join(os.tmpdir(), `${packageName}-update.sh`);
fs.writeFileSync(scriptPath, buildUnixScript(packageName), { mode: 0o755 });
spawn("sh", [scriptPath], {
detached: true,
stdio: "inherit",
}).unref();
}
const updaterPath = ensureRuntimeUpdater(resolveBundledUpdaterPath());
spawn(process.execPath, [updaterPath], {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
UPDATER_PKG_NAME: packageName,
UPDATER_PORT: String(UPDATER_CONFIG.statusPort),
UPDATER_TAIL_LINES: String(UPDATER_CONFIG.statusLogTailLines),
UPDATER_RETRIES: String(UPDATER_CONFIG.installRetries),
UPDATER_RETRY_DELAY_MS: String(UPDATER_CONFIG.installRetryDelayMs),
UPDATER_LINGER_MS: String(UPDATER_CONFIG.lingerAfterDoneMs),
UPDATER_WAIT_MIN_MS: String(UPDATER_CONFIG.waitForExitMinMs),
UPDATER_WAIT_MAX_MS: String(UPDATER_CONFIG.waitForExitMaxMs),
UPDATER_WAIT_CHECK_MS: String(UPDATER_CONFIG.waitForExitCheckMs),
UPDATER_APP_PORT: String(UPDATER_CONFIG.appPort),
},
}).unref();
setTimeout(() => process.exit(0), UPDATER_CONFIG.exitDelayMs);
}

View File

@@ -32,21 +32,38 @@ const BASE64_BLOCK_SIZE = 4;
* @param {string} accessToken
* @returns {string|undefined}
*/
function extractEmailFromAccessToken(accessToken) {
function decodeJwtPayload(jwt) {
try {
if (!accessToken || typeof accessToken !== "string") return undefined;
const parts = accessToken.split(".");
if (parts.length !== 3) return undefined;
if (!jwt || typeof jwt !== "string") return null;
const parts = jwt.split(".");
if (parts.length !== 3) return null;
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
const padded = base64 + "=".repeat(missingPadding);
const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
return payload.email || payload.preferred_username || payload.sub || undefined;
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
} catch {
return undefined;
return null;
}
}
function extractEmailFromAccessToken(accessToken) {
const payload = decodeJwtPayload(accessToken);
if (!payload) return undefined;
return payload.email || payload.preferred_username || payload.sub || undefined;
}
// Extract codex account info from id_token
export function extractCodexAccountInfo(idToken) {
const payload = decodeJwtPayload(idToken);
if (!payload) return {};
const chatgpt = payload["https://api.openai.com/auth"] || {};
return {
email: payload.email,
chatgptAccountId: chatgpt.chatgpt_account_id,
chatgptPlanType: chatgpt.chatgpt_plan_type,
};
}
// Provider configurations
const PROVIDERS = {
claude: {
@@ -150,12 +167,23 @@ const PROVIDERS = {
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
}),
mapTokens: (tokens) => {
const info = extractCodexAccountInfo(tokens.id_token);
const mapped = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
};
if (info.email) mapped.email = info.email;
if (info.chatgptAccountId || info.chatgptPlanType) {
mapped.providerSpecificData = {
chatgptAccountId: info.chatgptAccountId,
chatgptPlanType: info.chatgptPlanType,
};
}
return mapped;
},
},
"gemini-cli": {
@@ -1256,3 +1284,41 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
return { success: false, error: result.data.error, errorDescription: result.data.error_description };
}
// Run-once guard across the process lifetime
let codexBackfillDone = false;
// Backfill email + chatgpt account info for existing codex OAuth connections missing them
export async function backfillCodexEmails() {
if (codexBackfillDone) return;
codexBackfillDone = true;
try {
const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb");
const connections = await getProviderConnections();
const targets = connections.filter((c) => {
if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false;
const hasEmail = !!c.email;
const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId;
return !hasEmail || !hasAccountInfo;
});
for (const conn of targets) {
const info = extractCodexAccountInfo(conn.idToken);
if (!info.email && !info.chatgptAccountId) continue;
const patch = {};
if (!conn.email && info.email) patch.email = info.email;
if (info.chatgptAccountId || info.chatgptPlanType) {
patch.providerSpecificData = {
...(conn.providerSpecificData || {}),
chatgptAccountId: info.chatgptAccountId,
chatgptPlanType: info.chatgptPlanType,
};
}
if (Object.keys(patch).length) {
await updateProviderConnection(conn.id, patch);
}
}
} catch (err) {
codexBackfillDone = false;
console.log("backfillCodexEmails failed:", err?.message || err);
}
}

187
src/lib/updater/updater.js Normal file
View 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);
}

View File

@@ -703,6 +703,39 @@ export async function getUsageStats(period = "all") {
if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey;
}
}
// Overlay lastUsed with precise ISO timestamps from live history (dailySummary only has YYYY-MM-DD)
const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0;
for (const entry of history) {
const ts = entry.timestamp;
if (!ts || new Date(ts).getTime() < overlayCutoff) continue;
const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model;
if (stats.byModel[modelKey] && new Date(ts) > new Date(stats.byModel[modelKey].lastUsed)) {
stats.byModel[modelKey].lastUsed = ts;
}
if (entry.connectionId) {
const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`;
const accountKey = `${entry.model} (${entry.provider} - ${accountName})`;
if (stats.byAccount[accountKey] && new Date(ts) > new Date(stats.byAccount[accountKey].lastUsed)) {
stats.byAccount[accountKey].lastUsed = ts;
}
}
const apiKeyKey = (entry.apiKey && typeof entry.apiKey === "string")
? `${entry.apiKey}|${entry.model}|${entry.provider || "unknown"}`
: "local-no-key";
if (stats.byApiKey[apiKeyKey] && new Date(ts) > new Date(stats.byApiKey[apiKeyKey].lastUsed)) {
stats.byApiKey[apiKeyKey].lastUsed = ts;
}
const endpoint = entry.endpoint || "Unknown";
const endpointKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`;
if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) {
stats.byEndpoint[endpointKey].lastUsed = ts;
}
}
} else {
// 24h: use live history (original logic)
const cutoff = Date.now() - PERIOD_MS["24h"];