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
+786
View File
@@ -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 = """<nav>
<a href="./" __A__>🎛 控制面板</a>
<a href="status" __B__>📊 项目状态</a>
<a href="manage" __C__>🧩 项目管理</a>
</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"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} · GiteaHubSync</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔄</text></svg>">
<style>{CSS}</style>
</head>
<body>
<div class="wrap">
<div class="top"><h1>🔄 {title}</h1><span id="pill" class="pill on">运行中</span></div>
{nav}
{body}
</div>
</body>
</html>
"""
PAGE_HOME = """
<div class="card">
<div class="row">
<div class="switchbox">
<label class="switch"><input type="checkbox" id="sw" onchange="toggle()"><span class="slider"></span></label>
<div>
<div class="swlabel" id="swlabel">自动同步:开</div>
<div class="swsub">开启后每 10 秒检查一次 Gitea,有新提交自动推送到 GitHub</div>
</div>
</div>
<div class="btns">
<button class="b-sync" id="btnSync" onclick="syncNow()">⚡ 立即同步一次</button>
<a class="btn b-gitea" href="__GITEA__" target="_blank">🗂️ 打开 Gitea(3000端口)</a>
<a class="btn b-gh" href="__GH__" target="_blank">🐙 GitHub 仓库列表</a>
</div>
</div>
</div>
<div class="card">
<div class="stats">
<div class="stat"><div class="k">自动同步</div><div class="v" id="sEnabled">—</div></div>
<div class="stat"><div class="k">同步项目</div><div class="v" id="sRepos">—</div></div>
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
<div class="stat"><div class="k">最近实际同步</div><div class="v" id="sLast">—</div></div>
</div>
</div>
<div class="card">
<h2>同步日志(最近 80 行)</h2>
<pre id="log">加载中…</pre>
<div class="tip">日志文件:__LOGPATH__ · 面板每 3 秒自动刷新</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
async function refresh(){
try{
const s=await jget('api/state');
document.getElementById('sw').checked=s.enabled;
document.getElementById('swlabel').textContent='自动同步:'+(s.enabled?'开':'关');
const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sEnabled').textContent=s.enabled?'开启(10秒/次)':'已停止';
document.getElementById('sRepos').textContent=s.repo_count+' 个(已同步 '+s.synced_count+')';
document.getElementById('sUptime').textContent=s.uptime;
document.getElementById('sLast').textContent=s.last_real;
const l=await jget('api/log');
document.getElementById('log').textContent=l.lines.join('\\n')||'(暂无日志)';
}catch(e){document.getElementById('log').textContent='刷新失败: '+e}
}
async function toggle(){await fetch('api/toggle',{method:'POST'});refresh();}
async function syncNow(){
const b=document.getElementById('btnSync');b.disabled=true;b.textContent='⏳ 同步中…';
try{const r=await (await fetch('api/sync',{method:'POST'})).json();
if(!r.ok)alert('同步结果: '+(r.msg||JSON.stringify(r)));
}catch(e){alert('同步请求失败: '+e)}
finally{b.disabled=false;b.textContent='⚡ 立即同步一次';refresh();}
}
refresh();setInterval(refresh,3000);
</script>
"""
PAGE_STATUS = """
<div class="card">
<div class="stats">
<div class="stat"><div class="k">同步项目</div><div class="v" id="sCount">—</div></div>
<div class="stat"><div class="k">已同步</div><div class="v" id="sSynced">—</div></div>
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
<div class="stat"><div class="k">距下次检查</div><div class="v" id="sNext">—</div></div>
<div class="stat"><div class="k">累计检查轮数</div><div class="v" id="sChecks">—</div></div>
<div class="stat"><div class="k">累计实际同步</div><div class="v" id="sPushes">—</div></div>
<div class="stat"><div class="k">累计失败次数</div><div class="v" id="sFails">—</div></div>
<div class="stat"><div class="k">镜像总占用</div><div class="v" id="sSize">—</div></div>
</div>
</div>
<div class="card">
<div class="row">
<h2 style="margin:0">项目明细</h2>
<span class="tip" id="sGitea"></span>
</div>
<div style="overflow-x:auto">
<table class="resp">
<thead><tr><th>项目</th><th>状态</th><th>默认分支</th><th>分支/标签</th><th>最近推送同步</th><th>最新提交</th><th>大小</th><th></th></tr></thead>
<tbody id="tb"></tbody>
</table>
</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
async function refresh(){
try{
const s=await jget('api/status');
const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sCount').textContent=s.repo_count+' 个';
document.getElementById('sSynced').textContent=s.synced_count+' / '+s.repo_count;
document.getElementById('sUptime').textContent=s.uptime;
document.getElementById('sNext').textContent=s.enabled?(s.next_in==null?'—':s.next_in+' 秒'):'已暂停';
document.getElementById('sChecks').textContent=s.stats.total_checks;
document.getElementById('sPushes').textContent=s.stats.total_pushes;
document.getElementById('sFails').textContent=s.stats.total_failures;
document.getElementById('sSize').textContent=s.total_size_mb+' MB';
document.getElementById('sGitea').textContent='Gitea '+(s.gitea_online?'在线 ✓':'离线 ✗');
document.getElementById('tb').innerHTML=s.repos.map(r=>{
const st=r.status==='已同步'?'<td class="ok">✓ 已同步</td>':'<td class="warn">⏳ 未同步</td>';
const c=r.last_commit?`<div class="commit">${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}</div>`:'<div class="commit">—</div>';
return `<tr><td data-label="项目"><b>${esc(r.name)}</b></td><td data-label="状态" class="${r.status==='已同步'?'ok':'warn'}">${r.status==='已同步'?'✓ 已同步':'⏳ 未同步'}</td><td data-label="默认分支">${esc(r.head||'—')}</td><td data-label="分支/标签">${r.branches} / ${r.tags}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="最新提交">${c}</td><td data-label="大小">${r.size_mb} MB</td><td data-label="操作"><button class="b-mini" onclick="syncOne('${esc(r.name)}')">同步</button></td></tr>`;
}).join('')||'<tr><td colspan="8" style="color:#646a73">还没有同步项目,去<a href="manage">项目管理</a>添加</td></tr>';
}catch(e){document.getElementById('tb').innerHTML='<tr><td colspan="8">刷新失败: '+e+'</td></tr>'}
}
async function syncOne(n){
const r=await (await fetch('api/repos/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
if(!r.ok)alert(n+': '+(r.msg||JSON.stringify(r)));
refresh();
}
refresh();setInterval(refresh,5000);
</script>
"""
PAGE_MANAGE = """
<div class="card">
<h2>快捷添加项目</h2>
<div class="addrow">
<input id="repoName" placeholder="输入 Gitea 上的仓库名,如 ESP32_COMM" onkeydown="if(event.key==='Enter')addRepo()">
<button class="b-add" id="btnAdd" onclick="addRepo()">➕ 添加并同步</button>
</div>
<div class="tip">下面可直接点击你 Gitea 上还未同步的仓库(需 GitHub 端已存在同名仓库):</div>
<div class="avail" id="avail"></div>
<div class="msg" id="msg"></div>
</div>
<div class="card">
<h2>当前同步项目(<span id="cnt">0</span>)</h2>
<table class="resp">
<thead><tr><th>项目</th><th>状态</th><th>最近推送同步</th><th>操作</th></tr></thead>
<tbody id="tb"></tbody>
</table>
<div class="tip">移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function showMsg(t,ok){const m=document.getElementById('msg');m.textContent=t;m.className='msg '+(ok?'ok':'err');}
async function refresh(){
try{
const s=await jget('api/status');
document.getElementById('cnt').textContent=s.repo_count;
document.getElementById('avail').innerHTML=(s.available||[]).map(n=>
`<span class="chip" onclick="addRepo('${esc(n)}')">+ ${esc(n)}</span>`).join('')||'<span class="tip">Gitea 上的仓库都已加入同步</span>';
document.getElementById('tb').innerHTML=s.repos.map(r=>
`<tr><td data-label="项目"><b>${esc(r.name)}</b></td><td data-label="状态" class="${r.status==='已同步'?'ok':'warn'}">${r.status}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="操作"><button class="b-del" onclick="delRepo('${esc(r.name)}')">移除</button></td></tr>`).join('')
||'<tr><td colspan="4" style="color:#646a73">暂无项目</td></tr>';
}catch(e){showMsg('刷新失败: '+e,false)}
}
async function addRepo(n){
const name=(n||document.getElementById('repoName').value||'').trim();
if(!name){showMsg('请输入仓库名',false);return;}
const b=document.getElementById('btnAdd');b.disabled=true;b.textContent='⏳ 处理中…';
try{
const r=await (await fetch('api/repos/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})).json();
showMsg(r.msg||JSON.stringify(r),!!r.ok);
if(r.ok)document.getElementById('repoName').value='';
}catch(e){showMsg('请求失败: '+e,false)}
finally{b.disabled=false;b.textContent='➕ 添加并同步';refresh();}
}
async function delRepo(n){
if(!confirm('确定把 '+n+' 从同步列表移除?\\n(GitHub 仓库和本地数据保留)'))return;
const r=await (await fetch('api/repos/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
showMsg(r.msg||JSON.stringify(r),!!r.ok);
refresh();
}
refresh();setInterval(refresh,5000);
</script>
"""
# ---------------- 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()