feat: add desktop app hot update channel
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// provider_sync.js —— 一键把历史会话同步到「当前账号」的 provider,使其在 Codex 侧栏重新显示。
|
||||
// 复刻 CodexPlusPlus 的 provider-sync:改 rollout 首行 provider + state DB(provider/has_user_event/cwd)。
|
||||
// 必须在 Codex 已退出时运行。数据库用纯 JS 的 sql.js(免原生编译,跨平台)。
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { execFileSync } = require("child_process");
|
||||
const initSqlJs = require("sql.js");
|
||||
|
||||
const CODEX_HOME = path.join(os.homedir(), ".codex");
|
||||
|
||||
// 检测 Codex 是否在运行(尽力而为,跨平台)
|
||||
function codexRunning() {
|
||||
try {
|
||||
if (process.platform === "darwin") {
|
||||
return execFileSync("pgrep", ["-f", "Codex.app/Contents/MacOS/Codex"], { encoding: "utf-8" }).trim().length > 0;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return /Codex\.exe/i.test(execFileSync("tasklist", [], { encoding: "utf-8" }));
|
||||
}
|
||||
} catch {
|
||||
/* pgrep 无匹配时退出码非 0,视为未运行 */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 目标 provider = config.toml 根级 model_provider,缺省 openai
|
||||
function targetProvider() {
|
||||
try {
|
||||
const head = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8").split("\n[")[0];
|
||||
const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m);
|
||||
if (m) return m[1];
|
||||
} catch {}
|
||||
return "openai";
|
||||
}
|
||||
|
||||
function findRollouts() {
|
||||
const out = [];
|
||||
for (const root of [path.join(CODEX_HOME, "sessions"), path.join(CODEX_HOME, "archived_sessions")]) {
|
||||
const walk = (dir) => {
|
||||
let ents;
|
||||
try {
|
||||
ents = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of ents) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(full);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ts() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
|
||||
if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。");
|
||||
const target = targetProvider();
|
||||
log(`目标 provider:${target}`);
|
||||
onProgress({ phase: "备份中", done: 0, total: 0 });
|
||||
|
||||
// 1) 备份
|
||||
const backup = path.join(CODEX_HOME, "backups_state", "provider-sync", ts());
|
||||
fs.mkdirSync(backup, { recursive: true });
|
||||
for (const name of ["config.toml", ".codex-global-state.json", ".codex-global-state.json.bak"]) {
|
||||
const src = path.join(CODEX_HOME, name);
|
||||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backup, name));
|
||||
}
|
||||
for (const db of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite/.test(f))) {
|
||||
fs.copyFileSync(path.join(CODEX_HOME, db), path.join(backup, db));
|
||||
}
|
||||
log(`已备份到 ${backup}`);
|
||||
|
||||
// 2) 遍历 rollout:改首行 provider,收集 thread_id / cwd / has_user_event
|
||||
const userEventIds = new Set();
|
||||
const cwdById = {};
|
||||
let changedFiles = 0;
|
||||
const rolloutFiles = findRollouts();
|
||||
const rtotal = rolloutFiles.length;
|
||||
let ri = 0;
|
||||
for (const file of rolloutFiles) {
|
||||
ri++;
|
||||
if (ri % 5 === 0 || ri === rtotal) onProgress({ phase: "改写会话文件", done: ri, total: rtotal });
|
||||
let lines;
|
||||
try {
|
||||
lines = fs.readFileSync(file, "utf-8").split(/(?<=\n)/); // 保留换行
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!lines.length) continue;
|
||||
const blob = lines.join("");
|
||||
const hasUE = blob.includes('"user_message"') || blob.includes('"user_input"');
|
||||
let tid = null;
|
||||
let rewrote = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
let rec;
|
||||
try {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "session_meta" && rec.payload && typeof rec.payload === "object") {
|
||||
tid = rec.payload.id || rec.payload.session_id || null;
|
||||
const cwd = rec.payload.cwd;
|
||||
if (tid && cwd) cwdById[tid] = cwd;
|
||||
if (rec.payload.model_provider !== target) {
|
||||
rec.payload.model_provider = target;
|
||||
lines[i] = JSON.stringify(rec) + "\n";
|
||||
rewrote = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rewrote) {
|
||||
fs.writeFileSync(file, lines.join(""), "utf-8");
|
||||
changedFiles++;
|
||||
}
|
||||
if (tid && hasUE) userEventIds.add(tid);
|
||||
}
|
||||
log(`改写 rollout 文件:${changedFiles} 个;含用户消息的会话:${userEventIds.size} 条`);
|
||||
|
||||
// 3) 更新所有 state_*.sqlite
|
||||
onProgress({ phase: "更新数据库", done: 0, total: 0 });
|
||||
const SQL = await initSqlJs({
|
||||
// wasm 恒在【包内】node_modules(热更包不含 node_modules),故用 BUNDLE_ROOT 而非 __dirname
|
||||
locateFile: (f) =>
|
||||
path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."),
|
||||
"node_modules", "sql.js", "dist", f),
|
||||
});
|
||||
let provRows = 0;
|
||||
let ueRows = 0;
|
||||
let cwdRows = 0;
|
||||
for (const name of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite$/.test(f))) {
|
||||
const dbPath = path.join(CODEX_HOME, name);
|
||||
let db;
|
||||
try {
|
||||
db = new SQL.Database(fs.readFileSync(dbPath));
|
||||
const tbl = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='threads'");
|
||||
if (!tbl.length) {
|
||||
db.close();
|
||||
continue;
|
||||
}
|
||||
const cols = new Set(db.exec("PRAGMA table_info(threads)")[0].values.map((r) => r[1]));
|
||||
if (!cols.has("model_provider")) {
|
||||
db.close();
|
||||
continue;
|
||||
}
|
||||
db.run("UPDATE threads SET model_provider=? WHERE COALESCE(model_provider,'')<>?", [target, target]);
|
||||
provRows += db.getRowsModified();
|
||||
if (cols.has("has_user_event")) {
|
||||
for (const tid of userEventIds) {
|
||||
db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [tid]);
|
||||
ueRows += db.getRowsModified();
|
||||
}
|
||||
}
|
||||
if (cols.has("cwd")) {
|
||||
for (const tid of Object.keys(cwdById)) {
|
||||
db.run("UPDATE threads SET cwd=? WHERE id=? AND COALESCE(cwd,'')<>?", [cwdById[tid], tid, cwdById[tid]]);
|
||||
cwdRows += db.getRowsModified();
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
} catch (e) {
|
||||
if (db) try { db.close(); } catch {}
|
||||
log(` 数据库跳过 ${name}:${e.message || e}`);
|
||||
}
|
||||
}
|
||||
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行`);
|
||||
onProgress({ phase: "完成", done: 0, total: 0 });
|
||||
|
||||
return {
|
||||
target,
|
||||
totalConversations: rtotal,
|
||||
changedFiles,
|
||||
userEvents: userEventIds.size,
|
||||
provRows,
|
||||
ueRows,
|
||||
cwdRows,
|
||||
backup,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { syncProviderToCurrent, codexRunning, targetProvider };
|
||||
Reference in New Issue
Block a user