commit 4d6f71af5b219eac4e380a4192cd80d7da0eb881 Author: cbb Date: Tue Oct 13 19:12:49 2026 +0800 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 规则不进仓库,仅保留在工作目录本地分发。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06433e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ + +# 打包产物(由 build.sh 生成) +GiteaHubSync/GiteaHubSync.fpk + +# 敏感文件:GitHub 部署私钥(有推送权限,禁止进 git;仅随 .fpk 本地分发) +GiteaHubSync/app/ssh/id_ed25519 + +# 日志 +*.log + +# 系统垃圾 +.DS_Store +Thumbs.db diff --git a/GiteaHubSync/ICON.PNG b/GiteaHubSync/ICON.PNG new file mode 100644 index 0000000..5ae9391 Binary files /dev/null and b/GiteaHubSync/ICON.PNG differ diff --git a/GiteaHubSync/ICON_256.PNG b/GiteaHubSync/ICON_256.PNG new file mode 100644 index 0000000..dbc0663 Binary files /dev/null and b/GiteaHubSync/ICON_256.PNG differ diff --git a/GiteaHubSync/app/backend/panel.py b/GiteaHubSync/app/backend/panel.py new file mode 100644 index 0000000..6a46ba5 --- /dev/null +++ b/GiteaHubSync/app/backend/panel.py @@ -0,0 +1,786 @@ +#!/usr/bin/env python3 +# ============================================================ +# GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板 +# 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以 +# setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync) +# 页面: / 控制面板(开关/立即同步/快捷入口) +# /status 项目状态(每个仓库的详细同步状态) +# /manage 项目管理(从 Gitea 快捷添加/移除同步项目) +# ============================================================ +import atexit +import json +import os +import re +import signal +import subprocess +import threading +import time +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +APP_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync")) +REPO_DIR = DATA_DIR / "repos" +STATE_DIR = DATA_DIR / ".last-pushed" +LOG_FILE = DATA_DIR / "sync.log" +PID_FILE = DATA_DIR / "daemon.pid" +PANEL_STATE = DATA_DIR / "state.json" +REPOS_FILE = DATA_DIR / "repos.json" +STATS_FILE = DATA_DIR / "stats.json" +SSH_KEY = APP_DIR / "ssh" / "id_ed25519" +if (DATA_DIR / "id_ed25519").exists(): + SSH_KEY = DATA_DIR / "id_ed25519" +KNOWN_HOSTS = DATA_DIR / "known_hosts" +GITEA = "http://127.0.0.1:3000/cbb" +GITEA_API = "http://127.0.0.1:3000/api/v1" +GH_USER = "cbb2625274797" +GITEA_UI = os.environ.get("GITEA_URL", "http://192.168.31.10:3000") +GH_URL = f"https://github.com/{GH_USER}?tab=repositories" +DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController", + "Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"] +INTERVAL = 10 +PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899")) +START_TS = time.time() +NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$") + +ENV = dict(os.environ) +ENV["GIT_SSH_COMMAND"] = (f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes " + f"-o StrictHostKeyChecking=accept-new " + f"-o UserKnownHostsFile={KNOWN_HOSTS}") +ENV["GIT_TERMINAL_PROMPT"] = "0" + +state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0, + "last_result": "尚未运行", "loop_last": None} +stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0} +op_lock = threading.Lock() + + +# ---------------- 基础工具 ---------------- +def log(msg): + try: + DATA_DIR.mkdir(parents=True, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n") + except Exception: + pass + + +def git(args, timeout=300): + return subprocess.run(["git"] + args, capture_output=True, text=True, + env=ENV, timeout=timeout) + + +def load_stats(): + global stats + try: + stats.update(json.loads(STATS_FILE.read_text(encoding="utf-8"))) + except Exception: + pass + + +def save_stats(): + try: + STATS_FILE.write_text(json.dumps(stats), encoding="utf-8") + except Exception: + pass + + +def load_repos(): + """同步项目列表(repos.json),首次运行播种默认 7 个""" + try: + data = json.loads(REPOS_FILE.read_text(encoding="utf-8")) + repos = [r for r in data.get("repos", []) if valid_name(r)] + if repos: + return repos + except Exception: + pass + save_repos(DEFAULT_REPOS) + return list(DEFAULT_REPOS) + + +def save_repos(repos): + try: + DATA_DIR.mkdir(parents=True, exist_ok=True) + REPOS_FILE.write_text(json.dumps({"repos": repos}, ensure_ascii=False, indent=1), + encoding="utf-8") + except Exception as e: + log(f"保存 repos.json 失败: {e}") + + +def valid_name(name): + return bool(name) and bool(NAME_RE.match(name)) and name not in (".", "..") + + +def gitea_api(path): + """访问本地 Gitea API,返回 (ok, json)""" + try: + r = subprocess.run(["curl", "-sS", "-m", "10", f"{GITEA_API}{path}"], + capture_output=True, text=True, timeout=15) + return r.returncode == 0, json.loads(r.stdout or "{}") + except Exception: + return False, {} + + +def gitea_repo_exists(name): + ok, d = gitea_api(f"/repos/cbb/{name}") + return ok and isinstance(d, dict) and d.get("full_name") == f"cbb/{name}" + + +def github_repo_exists(name): + try: + r = git(["ls-remote", f"git@github.com:{GH_USER}/{name}.git", "HEAD"], timeout=30) + return r.returncode == 0 + except Exception: + return False + + +def list_gitea_repos(): + ok, d = gitea_api("/users/cbb/repos?limit=100") + if ok and isinstance(d, list): + return [x.get("name") for x in d if x.get("name")] + return [] + + +# ---------------- 同步逻辑 ---------------- +def fingerprint(repo): + r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"]) + if r.returncode != 0: + return None + import hashlib + return hashlib.sha1(r.stdout.encode()).hexdigest() + + +def sync_repo(name): + """返回 'changed' / 'same' / 'error:...' """ + if not valid_name(name): + return "error: 非法仓库名" + repo = REPO_DIR / f"{name}.git" + try: + if not repo.exists(): + REPO_DIR.mkdir(parents=True, exist_ok=True) + r = git(["clone", "--quiet", "--mirror", f"{GITEA}/{name}.git", str(repo)]) + if r.returncode != 0: + return "error: 克隆失败 " + (r.stderr or "")[:120] + log(f"{name}: 首次克隆完成") + r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"]) + if r.returncode != 0: + return "error: fetch 失败 " + (r.stderr or "")[:120] + cur = fingerprint(repo) + if cur is None: + return "error: 读取 refs 失败" + st = STATE_DIR / name + prev = st.read_text().strip() if st.exists() else "" + if cur == prev: + return "same" + p = git(["-C", str(repo), "push", "--mirror", f"git@github.com:{GH_USER}/{name}.git"]) + if p.returncode != 0: + return "error: 推送失败 " + (p.stderr or p.stdout or "")[:120] + STATE_DIR.mkdir(parents=True, exist_ok=True) + st.write_text(cur) + detail = " ".join(l.strip() for l in p.stdout.splitlines() + if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->"))) + log(f"{name}: 已同步到 GitHub" + (f" | {detail}" if detail else "")) + return "changed" + except Exception as e: + return f"error: {e}" + + +def run_sync(repos=None): + with op_lock: + if state["syncing"]: + return False, "已有同步任务正在进行" + state["syncing"] = True + try: + repos = repos if repos is not None else load_repos() + errs, changed = [], 0 + for r in repos: + res = sync_repo(r) + if res.startswith("error"): + errs.append(f"{r} {res}") + stats["total_failures"] += 1 + elif res == "changed": + changed += 1 + stats["total_pushes"] += 1 + state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + state["checks"] += 1 + stats["total_checks"] += 1 + save_stats() + if errs: + state["last_result"] = "失败: " + "; ".join(errs)[:200] + return False, state["last_result"] + state["last_result"] = "成功" + return True, ("成功" if changed == 0 else f"成功(同步了 {changed} 个仓库)") + except Exception as e: + state["last_result"] = f"异常: {e}" + return False, state["last_result"] + finally: + state["syncing"] = False + + +def loop(): + while True: + state["loop_last"] = time.time() + if state["enabled"]: + run_sync() + time.sleep(INTERVAL) + + +def load_panel_state(): + try: + state["enabled"] = bool(json.loads(PANEL_STATE.read_text(encoding="utf-8")).get("enabled", True)) + except Exception: + pass + + +def save_panel_state(): + try: + PANEL_STATE.write_text(json.dumps({"enabled": state["enabled"]}, ensure_ascii=False), + encoding="utf-8") + except Exception: + pass + + +# ---------------- 状态详情 ---------------- +def repo_detail(name): + repo = REPO_DIR / f"{name}.git" + st = STATE_DIR / name + d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0, + "head": None, "last_commit": None, "size_mb": 0.0, + "status": "未同步", "last_push": None} + if st.exists(): + d["status"] = "已同步" + d["last_push"] = datetime.fromtimestamp(st.stat().st_mtime).strftime("%m-%d %H:%M:%S") + if not repo.exists(): + return d + try: + r = git(["-C", str(repo), "for-each-ref", "--format=%(refname)"], timeout=20) + if r.returncode == 0: + refs = r.stdout.split() + d["branches"] = sum(1 for x in refs if x.startswith("refs/heads/")) + d["tags"] = sum(1 for x in refs if x.startswith("refs/tags/")) + h = git(["-C", str(repo), "symbolic-ref", "--quiet", "HEAD"], timeout=10) + if h.returncode == 0 and h.stdout.strip(): + d["head"] = h.stdout.strip().replace("refs/heads/", "") + if d["head"]: + c = git(["-C", str(repo), "log", "-1", + "--format=%h\t%s\t%an\t%ad", "--date=format:%m-%d %H:%M", d["head"]], + timeout=10) + if c.returncode == 0 and c.stdout.strip(): + p = c.stdout.strip().split("\t", 3) + d["last_commit"] = {"hash": p[0], + "msg": p[1] if len(p) > 1 else "", + "author": p[2] if len(p) > 2 else "", + "time": p[3] if len(p) > 3 else ""} + d["size_mb"] = round(sum(f.stat().st_size for f in repo.rglob("*") if f.is_file()) / 1048576, 1) + except Exception: + pass + return d + + +def fmt_uptime(sec): + sec = int(sec) + d, rem = divmod(sec, 86400) + h, rem = divmod(rem, 3600) + m, s = divmod(rem, 60) + return (f"{d}天 " if d else "") + f"{h:02d}:{m:02d}:{s:02d}" + + +def global_status(): + repos = load_repos() + details = [repo_detail(r) for r in repos] + now = time.time() + next_in = None + if state["enabled"] and state["loop_last"]: + next_in = max(0, int(state["loop_last"] + INTERVAL - now)) + gitea_all = list_gitea_repos() + return { + "enabled": state["enabled"], + "syncing": state["syncing"], + "uptime": fmt_uptime(now - START_TS), + "interval": INTERVAL, + "last_check": state["last_check"], + "next_in": next_in, + "checks": state["checks"], + "stats": stats, + "last_result": state["last_result"], + "repo_count": len(repos), + "synced_count": sum(1 for x in details if x["status"] == "已同步"), + "total_size_mb": round(sum(x["size_mb"] for x in details), 1), + "repos": details, + "gitea_url": GITEA_UI, + "gitea_online": bool(gitea_all), + "available": [r for r in gitea_all if r not in repos], + } + + +def tail_log(n=80): + try: + return LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-n:] + except Exception: + return [] + + +# ---------------- 页面 ---------------- +CSS = """ +*{box-sizing:border-box;margin:0;padding:0} +body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f0f2f5;color:#1f2329;min-height:100vh} +.wrap{width:100%;padding:20px 20px 60px} +.top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px} +.top h1{font-size:20px;display:flex;align-items:center;gap:8px;min-width:0} +.pill{font-size:12px;padding:4px 12px;border-radius:999px;font-weight:600;white-space:nowrap;flex:none} +.pill.on{background:#e3f5e9;color:#0a7d34}.pill.off{background:#fee;color:#c0392b} +nav{display:flex;gap:6px;margin-bottom:16px;background:#fff;border-radius:12px;padding:6px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex-wrap:wrap} +nav a{padding:8px 16px;border-radius:8px;text-decoration:none;color:#646a73;font-size:14px;font-weight:600;white-space:nowrap} +nav a.active{background:#e8f0fe;color:#1f6feb} +.card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);min-width:0} +.row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:14px} +.switchbox{display:flex;align-items:center;gap:14px;min-width:0} +.switch{position:relative;width:64px;height:34px;flex:none} +.switch input{opacity:0;width:0;height:0} +.slider{position:absolute;inset:0;background:#cbd2d9;border-radius:34px;cursor:pointer;transition:.25s} +.slider:before{content:"";position:absolute;width:26px;height:26px;left:4px;top:4px;background:#fff;border-radius:50%;transition:.25s;box-shadow:0 1px 3px rgba(0,0,0,.25)} +input:checked+.slider{background:#0a7d34} +input:checked+.slider:before{transform:translateX(30px)} +.swlabel{font-size:16px;font-weight:600}.swsub{font-size:12px;color:#646a73;margin-top:2px} +.btns{display:flex;gap:10px;flex-wrap:wrap} +button,a.btn{border:none;border-radius:10px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;white-space:nowrap} +button:active,a.btn:active{transform:scale(.97)} +.b-sync{background:#0a7d34;color:#fff}.b-sync:hover{background:#086b2c} +.b-gitea{background:#1f6feb;color:#fff}.b-gitea:hover{background:#1a5fd0} +.b-gh{background:#24292f;color:#fff}.b-gh:hover{background:#000} +.b-add{background:#7c3aed;color:#fff}.b-add:hover{background:#6d28d9} +.b-del{background:#fee2e2;color:#b91c1c;padding:6px 12px;font-size:12px} +.b-del:hover{background:#fecaca} +.b-mini{background:#eef2ff;color:#4338ca;padding:6px 12px;font-size:12px} +button:disabled{background:#cbd2d9;color:#fff;cursor:not-allowed} +.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px} +.stat{background:#f6f8fa;border-radius:10px;padding:12px 14px;min-width:0} +.stat .k{font-size:12px;color:#646a73} +.stat .v{font-size:15px;font-weight:700;margin-top:4px;word-break:break-word} +.tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch} +table{width:100%;border-collapse:collapse;font-size:13px} +th,td{padding:9px 8px;text-align:left;border-bottom:1px solid #eef0f3;vertical-align:top} +th{color:#646a73;font-weight:600;font-size:12px;white-space:nowrap} +td{word-break:break-word} +td.ok{color:#0a7d34;font-weight:600;white-space:nowrap} +td.warn{color:#b45309;font-weight:600;white-space:nowrap} +.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px} +.commit{color:#646a73;font-size:12px;margin-top:2px;line-height:1.5} +h2{font-size:15px;margin-bottom:12px;color:#1f2329} +pre{background:#0d1117;color:#c9d1d9;border-radius:10px;padding:14px;font-size:12px;line-height:1.6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all} +.tip{font-size:12px;color:#646a73;margin-top:10px;line-height:1.6} +.addrow{display:flex;gap:10px;flex-wrap:wrap} +.addrow input{flex:1;min-width:180px;border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none} +.addrow input:focus{border-color:#7c3aed} +.avail{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px} +.chip{border:1px dashed #7c3aed;color:#7c3aed;border-radius:999px;padding:6px 14px;font-size:13px;cursor:pointer;background:#faf5ff;white-space:nowrap} +.chip:hover{background:#f3e8ff} +.msg{margin-top:10px;font-size:13px;padding:10px 14px;border-radius:8px;display:none;line-height:1.6;word-break:break-word} +.msg.err{display:block;background:#fee;color:#b91c1c} +.msg.ok{display:block;background:#e3f5e9;color:#0a7d34} +/* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */ +@media (max-width:760px){ + .wrap{padding:14px 10px 40px} + .top h1{font-size:16px} + nav{gap:4px} + nav a{padding:8px 12px;font-size:13px;flex:1;text-align:center} + .stats{grid-template-columns:repeat(2,1fr);gap:8px} + .stat{padding:10px} + .stat .v{font-size:13px} + .card{padding:14px;border-radius:12px} + button,a.btn{padding:10px 14px;font-size:13px} + .btns{width:100%} + .btns button,.btns a.btn{flex:1;justify-content:center} + .addrow{flex-direction:column} + .addrow input{min-width:0;width:100%} + .addrow button{width:100%;justify-content:center} + table.resp{min-width:0} + table.resp thead{display:none} + table.resp tbody tr{display:block;border:1px solid #e5e8ec;border-radius:12px;margin-bottom:10px;padding:10px 12px;background:#fbfcfd} + table.resp tbody td{display:flex;justify-content:space-between;align-items:baseline;gap:6px 14px;border:none;padding:5px 0;flex-wrap:wrap} + table.resp tbody td::before{content:attr(data-label);color:#646a73;font-size:12px;font-weight:600;flex:none} + table.resp tbody td .val{flex:1;text-align:right;min-width:0;word-break:break-word} + table.resp tbody td:has(button){justify-content:flex-end} + table.resp tbody td:has(button)::before{margin-right:auto} + pre{font-size:11px;padding:10px} +} +""" + +NAV = """""" + + +def page(title, body, active): + nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "") + .replace("__B__", 'class="active"' if active == "status" else "") + .replace("__C__", 'class="active"' if active == "manage" else "")) + return f""" + + + + +{title} · GiteaHubSync + + + + +
+

🔄 {title}

运行中
+{nav} +{body} +
+ + +""" + + +PAGE_HOME = """ +
+
+
+ +
+
自动同步:开
+
开启后每 10 秒检查一次 Gitea,有新提交自动推送到 GitHub
+
+
+ +
+
+
+
+
自动同步
—
+
同步项目
—
+
守护运行时长
—
+
最近实际同步
—
+
+
+
+

同步日志(最近 80 行)

+
加载中…
+
日志文件:__LOGPATH__ · 面板每 3 秒自动刷新
+
+ +""" + +PAGE_STATUS = """ +
+
+
同步项目
—
+
已同步
—
+
守护运行时长
—
+
距下次检查
—
+
累计检查轮数
—
+
累计实际同步
—
+
累计失败次数
—
+
镜像总占用
—
+
+
+
+
+

项目明细

+ +
+
+ + + +
项目状态默认分支分支/标签最近推送同步最新提交大小
+
+
+ +""" + +PAGE_MANAGE = """ +
+

快捷添加项目

+
+ + +
+
下面可直接点击你 Gitea 上还未同步的仓库(需 GitHub 端已存在同名仓库):
+
+
+
+
+

当前同步项目(0)

+ + + +
项目状态最近推送同步操作
+
移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。
+
+ +""" + + +# ---------------- HTTP 服务 ---------------- +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _json(self, obj, code=200): + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _html(self, html): + body = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _body(self): + try: + n = int(self.headers.get("Content-Length", 0) or 0) + return json.loads(self.rfile.read(n) or b"{}") + except Exception: + return {} + + def do_GET(self): + path = self.path.split("?")[0].rstrip("/") or "/" + if path == "/": + body = (PAGE_HOME.replace("__GITEA__", GITEA_UI) + .replace("__GH__", GH_URL) + .replace("__LOGPATH__", str(LOG_FILE))) + self._html(page("控制面板", body, "home")) + elif path == "/status": + self._html(page("项目状态", PAGE_STATUS, "status")) + elif path == "/manage": + self._html(page("项目管理", PAGE_MANAGE, "manage")) + elif path == "/api/state": + repos = load_repos() + self._json({"enabled": state["enabled"], "syncing": state["syncing"], + "last_check": state["last_check"], "checks": state["checks"], + "last_result": state["last_result"], + "repo_count": len(repos), + "synced_count": sum(1 for r in repos if (STATE_DIR / r).exists()), + "uptime": fmt_uptime(time.time() - START_TS), + "last_real": last_real_sync()}) + elif path == "/api/status": + self._json(global_status()) + elif path == "/api/log": + self._json({"lines": tail_log(80)}) + else: + self._json({"error": "not found"}, 404) + + def do_POST(self): + path = self.path.split("?")[0].rstrip("/") or "/" + data = self._body() + if path == "/api/toggle": + state["enabled"] = not state["enabled"] + save_panel_state() + self._json({"ok": True, "enabled": state["enabled"]}) + elif path == "/api/sync": + ok, msg = run_sync() + self._json({"ok": ok, "msg": msg}) + elif path == "/api/repos/sync": + name = data.get("name", "") + if not valid_name(name): + self._json({"ok": False, "msg": "非法仓库名"}) + return + with op_lock: + if state["syncing"]: + self._json({"ok": False, "msg": "已有同步任务正在进行"}) + return + state["syncing"] = True + try: + res = sync_repo(name) + state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if res.startswith("error"): + stats["total_failures"] += 1 + save_stats() + self._json({"ok": False, "msg": f"{name} {res}"}) + elif res == "changed": + stats["total_pushes"] += 1 + save_stats() + self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"}) + else: + self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"}) + finally: + state["syncing"] = False + elif path == "/api/repos/add": + name = (data.get("name") or "").strip() + if not valid_name(name): + self._json({"ok": False, "msg": "仓库名不合法(仅限字母/数字/._-)"}) + return + repos = load_repos() + if name in repos: + self._json({"ok": False, "msg": f"{name} 已在同步列表中"}) + return + if not gitea_repo_exists(name): + self._json({"ok": False, "msg": f"Gitea 上不存在仓库 cbb/{name},请检查名字"}) + return + if not github_repo_exists(name): + self._json({"ok": False, "msg": f"GitHub 上还没有 {GH_USER}/{name},请先在 GitHub 建好同名仓库(建议私有)再添加"}) + return + repos.append(name) + save_repos(repos) + log(f"{name}: 已加入同步列表") + res = sync_repo(name) + if res.startswith("error"): + self._json({"ok": False, "msg": f"已加入列表,但首次同步失败:{res}"}) + elif res == "changed": + stats["total_pushes"] += 1 + save_stats() + self._json({"ok": True, "msg": f"{name} 添加成功,已完成首次同步"}) + else: + self._json({"ok": True, "msg": f"{name} 添加成功,内容已是最新"}) + elif path == "/api/repos/remove": + name = (data.get("name") or "").strip() + repos = load_repos() + if name not in repos: + self._json({"ok": False, "msg": f"{name} 不在同步列表中"}) + return + repos = [r for r in repos if r != name] + save_repos(repos) + log(f"{name}: 已从同步列表移除") + self._json({"ok": True, "msg": f"{name} 已移除(GitHub 仓库与本地数据保留)"}) + else: + self._json({"error": "not found"}, 404) + + +def last_real_sync(): + try: + for line in reversed(LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()): + if "已同步到 GitHub" in line: + return line.strip() + except Exception: + pass + return "尚无记录" + + +def write_pidfile(): + try: + DATA_DIR.mkdir(parents=True, exist_ok=True) + PID_FILE.write_text(str(os.getpid())) + except Exception: + pass + + +def main(): + signal.signal(signal.SIGHUP, signal.SIG_IGN) + DATA_DIR.mkdir(parents=True, exist_ok=True) + (DATA_DIR / "logs").mkdir(exist_ok=True) + # fnOS 解包可能把密钥权限放宽,ssh 会拒绝;守护进程即属主,启动时自愈 + try: + if SSH_KEY.exists(): + os.chmod(SSH_KEY, 0o600) + except Exception: + pass + write_pidfile() + atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink()) + load_panel_state() + load_stats() + load_repos() + log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, 项目 {len(load_repos())} 个)") + threading.Thread(target=loop, daemon=True).start() + server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler) + log(f"控制面板监听 :{PANEL_PORT}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/GiteaHubSync/app/ui/config b/GiteaHubSync/app/ui/config new file mode 100644 index 0000000..e52f5ee --- /dev/null +++ b/GiteaHubSync/app/ui/config @@ -0,0 +1,11 @@ +{ + ".url": { + "GiteaHubSync.Application": { + "title": "Gitea→GitHub 同步", + "icon": "images/icon_{0}.png", + "type": "iframe", + "url": "/cgi/ThirdParty/GiteaHubSync/index.cgi/", + "allUsers": true + } + } +} diff --git a/GiteaHubSync/app/ui/images/icon_256.png b/GiteaHubSync/app/ui/images/icon_256.png new file mode 100644 index 0000000..dbc0663 Binary files /dev/null and b/GiteaHubSync/app/ui/images/icon_256.png differ diff --git a/GiteaHubSync/app/ui/images/icon_64.png b/GiteaHubSync/app/ui/images/icon_64.png new file mode 100644 index 0000000..5ae9391 Binary files /dev/null and b/GiteaHubSync/app/ui/images/icon_64.png differ diff --git a/GiteaHubSync/app/ui/index.cgi b/GiteaHubSync/app/ui/index.cgi new file mode 100644 index 0000000..d1188c4 --- /dev/null +++ b/GiteaHubSync/app/ui/index.cgi @@ -0,0 +1,59 @@ +#!/bin/bash +# GiteaHubSync CGI 入口:把控制面板(127.0.0.1:8899)代理到飞牛同源路径下, +# 使桌面图标 iframe 可以直接打开面板(避免 http/https 混合内容问题)。 +# 完整转发 HTTP 方法、Content-Type 与请求体(POST /api/toggle、/api/sync 等)。 +PANEL="http://127.0.0.1:8899" +APP_SLUG="GiteaHubSync" + +URI_NO_QUERY="${REQUEST_URI%%\?*}" +REL_PATH="/" +case "$URI_NO_QUERY" in + *index.cgi*) REL_PATH="${URI_NO_QUERY#*index.cgi}" ;; +esac +REL_PATH="${REL_PATH#/}" +[ -z "$REL_PATH" ] && REL_PATH="/" +case "$REL_PATH" in /*) ;; *) REL_PATH="/$REL_PATH" ;; esac + +METHOD="${REQUEST_METHOD:-GET}" +CTYPE="${CONTENT_TYPE:-application/json}" + +HDR="$(mktemp)"; BODY="$(mktemp)"; INBODY="" +CURL_ARGS=(-s -m 120 -X "$METHOD" -H "Content-Type: $CTYPE") +case "$METHOD" in + POST|PUT|PATCH) + CL="${CONTENT_LENGTH:-0}" + if [ "$CL" -gt 0 ] 2>/dev/null; then + INBODY="$(mktemp)" + head -c "$CL" > "$INBODY" + CURL_ARGS+=(--data-binary "@$INBODY") + fi + ;; +esac + +curl "${CURL_ARGS[@]}" -D "$HDR" -o "$BODY" "${PANEL}${REL_PATH}" +rc=$? +[ -n "$INBODY" ] && rm -f "$INBODY" +if [ $rc -ne 0 ]; then + rm -f "$HDR" "$BODY" + printf 'Status: 503 Service Unavailable\r\n' + printf 'Content-Type: text/html; charset=utf-8\r\n\r\n' + printf '

同步服务未运行

请到飞牛 应用中心 → 已安装 → GiteaHubSync 中启动应用。

\n' + exit 0 +fi + +CTYPE_OUT="$(grep -i '^content-type:' "$HDR" | head -1 | cut -d' ' -f2 | tr -d '\r')" +[ -z "$CTYPE_OUT" ] && CTYPE_OUT="text/html; charset=utf-8" +printf 'Content-Type: %s\r\n' "$CTYPE_OUT" +printf 'Cache-Control: no-store\r\n' +printf '\r\n' +case "$CTYPE_OUT" in + text/html*) + # 注入 ,让页面里的相对路径 API 请求落在本 CGI 前缀下 + sed "s|||" "$BODY" + ;; + *) + cat "$BODY" + ;; +esac +rm -f "$HDR" "$BODY" +exit 0 diff --git a/GiteaHubSync/cmd/config_callback b/GiteaHubSync/cmd/config_callback new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/GiteaHubSync/cmd/config_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/GiteaHubSync/cmd/config_init b/GiteaHubSync/cmd/config_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/GiteaHubSync/cmd/config_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/GiteaHubSync/cmd/install_callback b/GiteaHubSync/cmd/install_callback new file mode 100644 index 0000000..9dd23f1 --- /dev/null +++ b/GiteaHubSync/cmd/install_callback @@ -0,0 +1,18 @@ +#!/bin/bash +# GiteaHubSync 安装后初始化:建数据目录、默认开启自动同步、落一份 SSH 密钥、清理旧面板 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APPDEST="$(cd "$SCRIPT_DIR/.." && pwd)" +DATA_DIR="${TRIM_PKGVAR:-}" +if [ -n "$DATA_DIR" ] && [ -d "$DATA_DIR" ]; then + mkdir -p "${DATA_DIR}/repos" "${DATA_DIR}/logs" + [ -f "${DATA_DIR}/state.json" ] || printf '{"enabled":true}\n' > "${DATA_DIR}/state.json" + # 把内置 GitHub 密钥复制到数据目录,属主跟随数据目录(应用用户),保证守护进程可读 + if [ -f "${APPDEST}/ssh/id_ed25519" ] && [ ! -f "${DATA_DIR}/id_ed25519" ]; then + cp "${APPDEST}/ssh/id_ed25519" "${DATA_DIR}/id_ed25519" 2>/dev/null + chmod 600 "${DATA_DIR}/id_ed25519" 2>/dev/null + chown --reference="${DATA_DIR}" "${DATA_DIR}/id_ed25519" 2>/dev/null + fi +fi +# 停掉占用 8899 端口的旧面板进程(早期原生版或本应用旧实例),避免端口冲突 +pkill -f 'panel\.py$' 2>/dev/null +exit 0 diff --git a/GiteaHubSync/cmd/install_init b/GiteaHubSync/cmd/install_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/GiteaHubSync/cmd/install_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/GiteaHubSync/cmd/main b/GiteaHubSync/cmd/main new file mode 100644 index 0000000..8a8cd71 --- /dev/null +++ b/GiteaHubSync/cmd/main @@ -0,0 +1,103 @@ +#!/bin/bash +# GiteaHubSync service control(由 fnOS 应用中心在 启动/停止/查询状态 时调用) +# 守护进程以 setsid nohup 方式后台运行,pidfile 位于持久数据目录(@appdata/GiteaHubSync) + +PYBIN="" +for candidate in \ + /var/apps/python312/target/bin/python3 \ + /vol1/@appcenter/python312/target/bin/python3 \ + /var/apps/python311/target/bin/python3 \ + /var/apps/python310/target/bin/python3 \ + /usr/bin/python3 \ + /usr/local/bin/python3; do + if [ -x "$candidate" ]; then PYBIN="$candidate"; break; fi +done + +# TRIM_APPDEST 由 fnOS 注入;若缺失或指向其他应用,则回退到脚本自身位置 +APPDEST="${TRIM_APPDEST:-}" +if [ -z "$APPDEST" ] || [ ! -d "$APPDEST/cmd" ]; then + APPDEST="$(cd "$(dirname "$0")/.." && pwd)" +fi +BACKEND_DIR="${APPDEST}/backend" +[ -d "$BACKEND_DIR" ] || BACKEND_DIR="${APPDEST}/target/backend" +[ -d "$BACKEND_DIR" ] || BACKEND_DIR="${APPDEST}/app/backend" +DAEMON="${BACKEND_DIR}/panel.py" + +# 持久数据目录(TRIM_PKGVAR 由 fnOS 注入,通常为 /vol{n}/@appdata/GiteaHubSync) +if [ -n "${TRIM_PKGVAR}" ] && [ -d "${TRIM_PKGVAR}" ]; then + DATA_DIR="${TRIM_PKGVAR}" +else + DATA_DIR="" + for candidate in \ + "${APPDEST}/../../@appdata/GiteaHubSync" \ + "${APPDEST}/var" \ + "/var/apps/GiteaHubSync/var" \ + "/vol1/@appdata/GiteaHubSync" \ + "/vol2/@appdata/GiteaHubSync"; do + if [ -d "$candidate" ]; then DATA_DIR="$candidate"; break; fi + done + if [ -z "$DATA_DIR" ]; then + DATA_DIR="${APPDEST}/../../@appdata/GiteaHubSync" + fi +fi +PIDFILE="${DATA_DIR}/daemon.pid" + +daemon_alive() { + [ -f "$PIDFILE" ] || return 1 + local pid + pid="$(cat "$PIDFILE" 2>/dev/null)" + [ -n "$pid" ] || return 1 + kill -0 "$pid" 2>/dev/null +} + +case "$1" in +start) + if daemon_alive; then + echo "GiteaHubSync daemon already running (pid $(cat "$PIDFILE"))" + exit 0 + fi + if [ -z "$PYBIN" ]; then + echo "python3 runtime not found (install_dep_apps=python312)" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}" + exit 1 + fi + mkdir -p "$DATA_DIR" + # fnOS 解包可能放宽密钥权限,ssh 会拒绝;启动前收紧 + [ -f "${APPDEST}/ssh/id_ed25519" ] && chmod 600 "${APPDEST}/ssh/id_ed25519" 2>/dev/null + [ -f "${DATA_DIR}/id_ed25519" ] && chmod 600 "${DATA_DIR}/id_ed25519" 2>/dev/null + GHS_DATA_DIR="$DATA_DIR" setsid nohup "$PYBIN" "$DAEMON" >> "${DATA_DIR}/daemon.out" 2>&1 & + for _ in 1 2 3 4 5 6 7 8 9 10; do + if daemon_alive; then + echo "GiteaHubSync daemon started (pid $(cat "$PIDFILE"))" + exit 0 + fi + sleep 0.5 + done + echo "GiteaHubSync daemon failed to start; see ${DATA_DIR}/daemon.out" > "${TRIM_TEMP_LOGFILE:-/dev/stderr}" + exit 1 + ;; +stop) + if daemon_alive; then + pid="$(cat "$PIDFILE")" + kill "$pid" 2>/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + kill -9 "$pid" 2>/dev/null + echo "GiteaHubSync daemon stopped (pid $pid)" + else + echo "GiteaHubSync daemon not running" + fi + rm -f "$PIDFILE" + exit 0 + ;; +status) + if daemon_alive; then + exit 0 + fi + exit 3 + ;; +*) + exit 1 + ;; +esac diff --git a/GiteaHubSync/cmd/uninstall_callback b/GiteaHubSync/cmd/uninstall_callback new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/GiteaHubSync/cmd/uninstall_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/GiteaHubSync/cmd/uninstall_init b/GiteaHubSync/cmd/uninstall_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/GiteaHubSync/cmd/uninstall_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/GiteaHubSync/cmd/upgrade_callback b/GiteaHubSync/cmd/upgrade_callback new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/GiteaHubSync/cmd/upgrade_callback @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/GiteaHubSync/cmd/upgrade_init b/GiteaHubSync/cmd/upgrade_init new file mode 100644 index 0000000..06bd986 --- /dev/null +++ b/GiteaHubSync/cmd/upgrade_init @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0 diff --git a/GiteaHubSync/config/privilege b/GiteaHubSync/config/privilege new file mode 100644 index 0000000..e1bca87 --- /dev/null +++ b/GiteaHubSync/config/privilege @@ -0,0 +1,8 @@ +{ + "defaults": + { + "run-as": "package" + }, + "username": "giteahubsync", + "groupname": "giteahubsync" +} diff --git a/GiteaHubSync/config/resource b/GiteaHubSync/config/resource new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/GiteaHubSync/config/resource @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/GiteaHubSync/manifest b/GiteaHubSync/manifest new file mode 100644 index 0000000..4b991ed --- /dev/null +++ b/GiteaHubSync/manifest @@ -0,0 +1,16 @@ +appname = GiteaHubSync +version = 1.0.0 +display_name = Gitea→GitHub 同步助手 +desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。 +platform = all +source = thirdparty +maintainer = fn-deepseek-harness +maintainer_url = https://club.fnnas.com +distributor = fn-deepseek-harness +distributor_url = https://club.fnnas.com +os_min_version = 1.2.0 +install_dep_apps = python312 +desktop_uidir = ui +desktop_applaunchname = GiteaHubSync.Application +checkport = false +disable_authorization_path = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..430da40 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# GiteaHubSync — Gitea → GitHub 镜像同步(飞牛原生应用) + +每次推送到飞牛 Gitea(3000 端口)的代码,**10 秒内自动镜像到 GitHub 的 7 个私有仓库** +(SMB_Android / DCU_APP / GD32E50X_BOOT / DCU_DesktopController / Motor_v2 / DCU_ECAT_Master / GD32_BOOT)。 + +和「Agent 签到助手」一样,是标准的飞牛 `.fpk` 原生应用:**不进 Docker**,依赖已安装的 +`python312` 运行时,安装后出现在应用中心已安装列表和桌面启动台,可 启动/停止/重启/卸载, +NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓库状态、同步日志和 +「打开 Gitea(3000 端口)」按钮。 + +## 安装(三选一) + +### 方式一:飞牛网页手动安装(不需要给任何密码) + +1. 把 NAS 上的 `fnos-gitea-sync/GiteaHubSync/GiteaHubSync.fpk` 下载到电脑 + (通过飞牛文件管理/SMB,目录:`/vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync/GiteaHubSync/`) +2. 飞牛网页 → **应用中心** → 右上角 **手动安装** → 选择该 fpk +3. 等待安装完成(依赖 python312 已满足),应用自动启动,约 1 分钟内完成 7 个仓库的首次同步 + +### 方式二:命令行自动安装(和签到系统相同的方式) + +```bash +cd /vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync +FNOS_HOST=192.168.31.10 FNOS_USER=<管理员账号> FNOS_PASS=<密码> \ + /var/apps/nodejs_v24/target/bin/node fnos-install.js +``` + +### 方式三:先用着原生版 + +在安装应用前,面板已以原生方式在 `http://192.168.31.10:8899` 运行,功能一致。 +安装应用时安装脚本会自动停掉原生版,无缝交接。 + +## 安装后 + +- **桌面图标**:「Gitea→GitHub 同步」,点击打开控制面板 +- **或直接访问**:`http://192.168.31.10:8899` +- **应用中心管理**:已安装 → GiteaHubSync → 启动/停止/重启/卸载 +- **数据目录**:`/vol1/@appdata/GiteaHubSync`(镜像仓库、状态、日志;卸载应用不丢 GitHub 上的代码) + +## 重新构建 + +```bash +cd /vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync +./build.sh +``` + +## 说明 + +- GitHub 推送走 SSH 22 端口直连(HTTPS 443 在本地网络被断),私钥随应用打包安装在 NAS 本地 +- 真镜像语义:Gitea 删分支/标签,GitHub 同步删 +- 推送失败下一轮自动重试;开关关掉后完全停止同步 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..bc0340b --- /dev/null +++ b/build.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# 构建 GiteaHubSync.fpk +set -e +cd "$(dirname "$0")" +echo "== 生成图标 ==" +python3 genicon.py +echo "== 清理缓存 ==" +find GiteaHubSync -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true +echo "== 打包 ==" +cd GiteaHubSync +fnpack build +cd .. +ls -la GiteaHubSync/GiteaHubSync.fpk +echo "== 校验 ==" +tar tzf GiteaHubSync/GiteaHubSync.fpk | grep -E "manifest|main$|panel.py|index.cgi|id_ed25519" diff --git a/fnos-install.js b/fnos-install.js new file mode 100644 index 0000000..7c22d82 --- /dev/null +++ b/fnos-install.js @@ -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); }); diff --git a/genicon.py b/genicon.py new file mode 100644 index 0000000..1dc483a --- /dev/null +++ b/genicon.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Generate GiteaHubSync app icons (pure stdlib, no PIL). +Design: rounded gradient tile (blue -> teal) + white double sync arrows.""" +import os +import struct +import zlib + +BASE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "GiteaHubSync") + + +class Canvas: + def __init__(self, w, h, scale=4): + self.w, self.h, self.s = w, h, scale + self.W, self.H = w * scale, h * scale + self.px = [(0, 0, 0, 0)] * (self.W * self.H) + + def _set(self, x, y, rgba): + if 0 <= x < self.W and 0 <= y < self.H: + i = y * self.W + x + r, g, b, a = rgba + orr, og, ob, oa = self.px[i] + na = a / 255.0 + oa / 255.0 * (1 - a / 255.0) + if na == 0: + self.px[i] = (0, 0, 0, 0) + else: + nr = (r * a / 255.0 + orr * oa / 255.0 * (1 - a / 255.0)) / na + ng = (g * a / 255.0 + og * ob / 255.0 * (1 - a / 255.0)) / na + nb = (b * a / 255.0 + ob * oa / 255.0 * (1 - a / 255.0)) / na + self.px[i] = (int(nr), int(ng), int(nb), int(na * 255)) + + def line(self, x1, y1, x2, y2, thick, rgba): + steps = int(max(abs(x2 - x1), abs(y2 - y1)) * 2) + 1 + for i in range(steps + 1): + t = i / steps + cx, cy = x1 + (x2 - x1) * t, y1 + (y2 - y1) * t + r = thick / 2.0 + for yy in range(int(cy - r) - 1, int(cy + r) + 2): + for xx in range(int(cx - r) - 1, int(cx + r) + 2): + if (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r: + self._set(xx, yy, rgba) + + def triangle(self, pts, rgba): + ys = [p[1] for p in pts] + for y in range(int(min(ys)), int(max(ys)) + 1): + xints = [] + for i in range(3): + x1, y1 = pts[i] + x2, y2 = pts[(i + 1) % 3] + if y1 == y2: + continue + if min(y1, y2) <= y < max(y1, y2): + t = (y - y1) / (y2 - y1) + xints.append(x1 + t * (x2 - x1)) + if len(xints) >= 2: + xints.sort() + for x in range(int(xints[0]), int(xints[-1]) + 1): + self._set(x, y, rgba) + + def gradient_fill(self, x, y, w, h, rad, c1, c2): + for yy in range(y, y + h): + t = (yy - y) / max(h - 1, 1) + r = int(c1[0] + (c2[0] - c1[0]) * t) + g = int(c1[1] + (c2[1] - c1[1]) * t) + b = int(c1[2] + (c2[2] - c1[2]) * t) + for xx in range(x, x + w): + dx = max(x + rad - xx, xx - (x + w - 1 - rad), 0) + dy = max(y + rad - yy, yy - (y + h - 1 - rad), 0) + if dx * dx + dy * dy <= rad * rad: + self._set(xx, yy, (r, g, b, 255)) + + def save(self, path): + s = self.s + rows = [] + for y in range(self.h): + row = bytearray([0]) + for x in range(self.w): + rs = gs = bs = as_ = 0 + cnt = 0 + for dy in range(s): + for dx in range(s): + p = self.px[(y * s + dy) * self.W + (x * s + dx)] + rs += p[0]; gs += p[1]; bs += p[2]; as_ += p[3] + cnt += 1 + row += bytes((rs // cnt, gs // cnt, bs // cnt, as_ // cnt)) + rows.append(bytes(row)) + raw = b"".join(rows) + + def chunk(tag, data): + c = struct.pack(">I", len(data)) + tag + data + return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + png = b"\x89PNG\r\n\x1a\n" + png += chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)) + png += chunk(b"IDAT", zlib.compress(raw, 9)) + png += chunk(b"IEND", b"") + with open(path, "wb") as f: + f.write(png) + + +def draw_icon(size): + c = Canvas(size, size) + S = c.s + W = c.W + m = int(size * 0.05) * S + # 渐变圆角瓷砖:蓝 -> 青 + c.gradient_fill(m, m, W - 2 * m, c.H - 2 * m, int(size * 0.22) * S, + (37, 99, 235), (13, 148, 136)) + white = (255, 255, 255, 255) + t = int(size * 0.055) * S # 箭杆粗细 + ah = int(size * 0.11) * S # 箭头大小 + y1 = int(size * 0.42) * S # 上箭头中心线 + y2 = int(size * 0.58) * S # 下箭头中心线 + # 上箭头:向左的杆 + 左箭头 + c.line(W * 0.40, y1, W * 0.68, y1, t, white) + c.triangle([(W * 0.70, y1), (W * 0.70 - ah, y1 - ah * 0.62), (W * 0.70 - ah, y1 + ah * 0.62)], white) + # 下箭头:向右的杆 + 右箭头 + c.line(W * 0.32, y2, W * 0.60, y2, t, white) + c.triangle([(W * 0.30, y2), (W * 0.30 + ah, y2 - ah * 0.62), (W * 0.30 + ah, y2 + ah * 0.62)], white) + return c + + +def main(): + for size, rel in [(256, "ICON_256.PNG"), (64, "ICON.PNG")]: + draw_icon(size).save(os.path.join(BASE, rel)) + print("built", rel) + img = os.path.join(BASE, "app", "ui", "images") + os.makedirs(img, exist_ok=True) + for size, name in [(256, "icon_256.png"), (64, "icon_64.png")]: + draw_icon(size).save(os.path.join(img, name)) + print("built", name) + + +if __name__ == "__main__": + main()