feat: GiteaHubSync v1.0.0 — fnOS 原生应用:Gitea→GitHub 镜像同步

- 每次推送到飞牛 Gitea(3000 端口)的代码 10 秒内自动镜像到 GitHub 7 个私有仓库
  (SMB_Android / DCU_APP / GD32E50X_BOOT / DCU_DesktopController / Motor_v2 /
  DCU_ECAT_Master / GD32_BOOT),真镜像语义(删分支/标签同步删)
- 标准 .fpk 原生应用:不进 Docker,依赖 python312,安装后进入应用中心已安装列表
  和桌面启动台,支持启动/停止/重启/卸载,NAS 重启自动恢复
- 控制面板(:8899):自动同步开关、立即同步、仓库状态、同步日志、打开 Gitea
- GitHub 推送走 SSH 22 端口直连(HTTPS 443 本地网络被断),部署私钥随应用本地分发
- 附 fnos-install.js(命令行自动安装)、build.sh(重新构建 fpk)、genicon.py(图标生成)

注意:GitHub 部署私钥(GiteaHubSync/app/ssh/id_ed25519)与打包产物 .fpk
按 .gitignore 规则不进仓库,仅保留在工作目录本地分发。
This commit is contained in:
cbb
2026-10-13 19:12:49 +08:00
commit 4d6f71af5b
24 changed files with 1459 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env node
/* GiteaHubSync .fpk installer for fnOS via the app-center web API.
* 用法:
* FNOS_HOST=192.168.31.10 FNOS_USER=admin FNOS_PASS=*** \
* node fnos-install.js [path/to/GiteaHubSync.fpk]
* 流程(已在 192.168.31.10 随 AgentCheckin 验证):
* WebSocket 登录 -> ticket -> ost cookie -> 上传 fpk -> 轮询 -> 安装 -> 轮询
*/
const fs = require("fs");
const path = require("path");
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
const HOST = process.env.FNOS_HOST || "192.168.31.10";
const PORT = process.env.FNOS_PORT || "5667";
const BASE = `https://${HOST}:${PORT}`;
const USER = process.env.FNOS_USER;
const PASS = process.env.FNOS_PASS;
const APPNAME = "GiteaHubSync";
const VERSION = "1.0.0";
const FPK = process.argv[2] ||
path.join(__dirname, "GiteaHubSync", "GiteaHubSync.fpk");
const log = (...a) => console.log(...a);
if (!USER || !PASS) {
console.error("请设置环境变量 FNOS_USER 和 FNOS_PASS(NAS 管理员账号密码)");
process.exit(1);
}
if (!fs.existsSync(FPK)) {
console.error("找不到 fpk 文件:", FPK);
process.exit(1);
}
async function wsLogin() {
const ws = new WebSocket(`wss://${HOST}:${PORT}/websocket?type=main`);
let reqId = 1000;
const pending = new Map();
const send = (obj) => ws.send(JSON.stringify(obj));
const rpc = (req, data, opts = {}) => new Promise((resolve, reject) => {
const id = String(reqId++);
const frame = Object.assign({ req, reqid: id }, data || {});
pending.set(id, { resolve, reject });
send(opts.raw ? frame : { data: frame, config: {} });
setTimeout(() => {
if (pending.has(id)) { pending.delete(id); reject(new Error("timeout " + req)); }
}, opts.timeout || 15000);
});
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = (e) => rej(new Error("ws error: " + (e.message || e.type)));
});
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
const p = pending.get(msg.reqid);
if (p) { pending.delete(msg.reqid); p.resolve(msg); }
};
await rpc("util.getSI", {}, { raw: true });
await rpc("util.crypto.getRSAPub", {}, { raw: true });
const loginId = String(reqId++);
const loginResp = await new Promise((resolve, reject) => {
pending.set(loginId, { resolve, reject });
send({
req: "user.login", reqid: loginId,
user: USER, password: PASS, stay: 0,
deviceType: "Browser", deviceName: "Linux-Chrome", ver: 2,
did: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 15)}`,
});
setTimeout(() => {
if (pending.has(loginId)) { pending.delete(loginId); reject(new Error("login timeout")); }
}, 20000);
});
ws.close();
const d = loginResp.data || loginResp;
const ticket = d.ticket || d.secret || d.token;
if (!ticket) {
if (loginResp.errno === 131072 || loginResp.result === "fail") {
throw new Error("用户名或密码错误(请检查 FNOS_USER / FNOS_PASS)");
}
throw new Error("登录失败: " + JSON.stringify(loginResp).slice(0, 300));
}
return ticket;
}
async function ticketToCookie(ticket) {
const r = await fetch(`${BASE}/app/ticket`, {
method: "POST",
headers: { "Content-Type": "application/json", Origin: BASE },
body: JSON.stringify({ ticket }),
});
const setCookie = r.headers.getSetCookie
? r.headers.getSetCookie().join("; ")
: (r.headers.get("set-cookie") || "");
const m = /ost=([^;]+)/.exec(setCookie);
if (!m) throw new Error("未能从 /app/ticket 获取 ost cookie: " + setCookie.slice(0, 200));
return `ost=${m[1]}`;
}
async function uploadFpk(cookie) {
const taskId = `app-upload-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const buf = fs.readFileSync(FPK);
const fd = new FormData();
fd.append("taskID", taskId);
fd.append("file", new Blob([buf]), path.basename(FPK));
fd.append("volumeID", "1");
const r = await fetch(`${BASE}/app-center/v1/download/upload`, {
method: "POST",
headers: { Cookie: cookie, Origin: BASE },
body: fd,
});
const j = await r.json().catch(() => ({}));
if (j.code !== 0 && j.code !== 200) throw new Error("上传被拒绝: " + JSON.stringify(j).slice(0, 300));
log("upload accepted, taskId =", taskId);
return taskId;
}
async function poll(cookie, url, opts, isDone, isFail, label, timeoutMs = 300000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const r = await fetch(url, {
...opts,
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
});
const j = await r.json().catch(() => ({}));
const st = j.data && j.data.status;
log(` [${label}] status=${st}`);
if (isDone(j)) return j;
if (isFail(j)) throw new Error(`${label} 失败: ` + JSON.stringify(j).slice(0, 400));
await new Promise((res) => setTimeout(res, 2000));
}
throw new Error(`${label} 超时`);
}
async function uninstallIfInstalled(cookie) {
const r = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`,
{ headers: { Cookie: cookie, Origin: BASE } });
const j = await r.json().catch(() => ({}));
const apps = (j.data || {}).list || [];
const existing = apps.find((a) => a.appName === APPNAME);
if (!existing) {
log(`== ${APPNAME} not installed yet, skipping uninstall ==`);
return;
}
log(`== ${APPNAME} v${existing.version} already installed -> uninstalling first (data in @appdata is kept) ==`);
const ur = await fetch(`${BASE}/app-center/v1/uninstall/start`, {
method: "POST",
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
body: JSON.stringify({ appName: APPNAME, language: "zh-CN", customParameters: [] }),
});
const uj = await ur.json().catch(() => ({}));
if (uj.code !== 0) throw new Error("卸载启动失败: " + JSON.stringify(uj).slice(0, 300));
const start = Date.now();
while (Date.now() - start < 120000) {
await new Promise((res) => setTimeout(res, 2500));
const r2 = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`,
{ headers: { Cookie: cookie, Origin: BASE } });
const j2 = await r2.json().catch(() => ({}));
const still = ((j2.data || {}).list || []).find((a) => a.appName === APPNAME);
if (!still) { log(" uninstall done (data preserved)"); return; }
}
throw new Error("卸载超时,应用仍在列表中");
}
async function main() {
log("== 1. login (%s:%s)", HOST, PORT);
const ticket = await wsLogin();
log(" ticket ok");
const cookie = await ticketToCookie(ticket);
log(" ost cookie ok");
await uninstallIfInstalled(cookie);
log("== 2. upload fpk:", FPK, `(${(fs.statSync(FPK).size / 1024).toFixed(1)} KB)`);
const taskId = await uploadFpk(cookie);
await poll(cookie,
`${BASE}/app-center/v1/download/status?downloadTaskId=${taskId}&language=zh-CN`,
{ method: "GET" },
(j) => j.data && j.data.status === 2,
(j) => j.data && (j.data.status === 3 || j.data.status === 4),
"upload", 300000);
log(" upload done");
log("== 3. query install info (registers the package) ==");
const infoResp = await fetch(
`${BASE}/app-center/v1/install/info?appName=${APPNAME}&version=${VERSION}&packageType=local&language=zh-CN`,
{ headers: { Cookie: cookie, Origin: BASE } });
const info = await infoResp.json().catch(() => ({}));
log(" install info code:", info.code,
"| deps:", JSON.stringify(((info.data || {}).relation || {}).dependencyApps || []));
log("== 4. create install task");
const r = await fetch(`${BASE}/app-center/v1/install/task`, {
method: "POST",
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
body: JSON.stringify({
appName: APPNAME, version: VERSION, packageType: "local",
systemParameters: { agreedToProtocol: true, installVolumeID: 1, dataVolumeId: 1, immediateStart: true },
customParameters: [], language: "zh-CN",
}),
});
const j = await r.json().catch(() => ({}));
const installTaskId = j.data && j.data.taskId;
if (!installTaskId) throw new Error("创建安装任务失败: " + JSON.stringify(j).slice(0, 300));
log(" install task =", installTaskId);
log("== 5. poll install status");
await poll(cookie, `${BASE}/app-center/v1/install/status`, {
method: "POST",
body: JSON.stringify({ taskId: installTaskId, language: "zh-CN" }),
},
(jj) => jj.data && jj.data.status === 2,
(jj) => jj.data && (jj.data.status === 3 || jj.data.status === 4),
"install", 600000);
log("");
log(`✅ ${APPNAME} 安装完成!`);
log(` 桌面/启动台会出现「Gitea→GitHub 同步」图标`);
log(` 控制面板: http://${HOST}:8899`);
}
main().catch((e) => { console.error("FAILED:", e.message); process.exit(1); });