Files
fnos-agent-checkin/fnos-install.js
T
AgentCheckin 556e45d642 feat(qoder): 弄清国际版每日 100 Credits 的「按设备下发」规则 + 一键设备授权登录
排查结论(本机对真实接口做了 30+ 组对照实验)
- GET /sash/api/v1/me/campaigns 只返回一条常驻 VIEW_DETAILS 广告
  (act-20260901-493,BOGO 订阅推广,claimStatus=CLAIMED 与签到无关)
- 无论 Cosy-MachineToken/Code/Type 用 env 0/3/4/prod、裸调用/带 stdin、
  是否附 Cosy-MachineId/OS/Hostname/Origin/Referer/X-*、是否带 clientType,
  都拿不到 CLAIM_BEFIT 轮次;账号本人 balances/achievements 正常
- 三个社区实现(qoder2api-hub、dsh-connect-qoder、CreditDaddy)一致:
  每日轮次按「登录过官方客户端的那台设备」下发,NAS 侧另造的设备看不到它

修复
- _benefits() 改为只认 actionType=CLAIM_BEFIT(不再把 VIEW_DETAILS 当签到),
  窗口过期也排除;claimStatus 未知按可领处理(领取接口幂等)
- 两段式取活动:先裸取拿 uid → 按 uid 生成风控身份 → 再带设备身份重取
- 状态文案改为「未下发每日签到活动」而不是误导性的「今日已领取」

新功能
- Qoder 一键登录(OAuth 设备授权 / PKCE S256):应用内点按钮 → 浏览器确认一次,
  Token 直接绑定本机设备;同 uid 已存在则更新凭证而不是新增重复账号
- 手工导入桌面端设备身份接口 /api/qoder/import_device
- 诊断接口:/api/qoder/{probe,experiment,growth,matrix,desktop}

测试:39 后端 + 25 E2E + 9 前端全绿
2026-10-19 12:49:06 +08:00

251 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/* AgentCheckin .fpk installer for fnOS via the app-center web API.
*
* Full flow (validated against fnOS 1.x app center):
* 1. WebSocket wss://<host>:5667/websocket?type=main
* 2. util.getSI + util.crypto.getRSAPub (plain frames are accepted)
* 3. user.login (username/password) -> ticket
* 4. POST /app/ticket {ticket} -> ost cookie
* 5. POST /app-center/v1/download/upload (FormData: taskID/file/volumeID)
* 6. GET /app-center/v1/download/status (poll until status=2)
* 7. POST /app-center/v1/install/task (packageType=local)
* 8. POST /app-center/v1/install/status (poll until status=2)
*
* Usage:
* FNOS_HOST=192.168.31.10 FNOS_USER=admin FNOS_PASS=*** \
* node fnos-install.js [path/to/AgentCheckin.fpk]
*/
const fs = require("fs");
const path = require("path");
// fnOS uses a self-signed certificate on the LAN; accept it for this installer.
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 = "AgentCheckin";
const VERSION = "1.4.1";
const FPK = process.argv[2] ||
path.join(__dirname, "AgentCheckin", "AgentCheckin.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);
}
/* ------------------------------------------------------------------ */
/* 1. WebSocket login -> ticket */
/* ------------------------------------------------------------------ */
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 });
const pubResp = await rpc("util.crypto.getRSAPub", {}, { raw: true });
if (!pubResp.pub && !(pubResp.data && pubResp.data.pub)) {
throw new Error("getRSAPub 响应异常: " + JSON.stringify(pubResp).slice(0, 200));
}
// user.login must be sent as a raw frame with its own reqid (validated flow)
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;
// the login frame carries both `secret` and `ticket`; /app/ticket needs `ticket`
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;
}
/* ------------------------------------------------------------------ */
/* 2. ticket -> ost cookie */
/* ------------------------------------------------------------------ */
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]}`;
}
/* ------------------------------------------------------------------ */
/* 3-8. upload + install */
/* ------------------------------------------------------------------ */
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(` 桌面/启动台会出现「Agent 签到助手」图标`);
log(` 或访问 https://${HOST}:5667/cgi/ThirdParty/AgentCheckin/index.cgi/`);
}
main().catch((e) => { console.error("FAILED:", e.message); process.exit(1); });