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:
+17
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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 '<html><body style="font-family:sans-serif;padding:40px"><h3>同步服务未运行</h3><p>请到飞牛 应用中心 → 已安装 → GiteaHubSync 中启动应用。</p></body></html>\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*)
|
||||
# 注入 <base>,让页面里的相对路径 API 请求落在本 CGI 前缀下
|
||||
sed "s|<head>|<head><base href=\"/cgi/ThirdParty/${APP_SLUG}/index.cgi/\">|" "$BODY"
|
||||
;;
|
||||
*)
|
||||
cat "$BODY"
|
||||
;;
|
||||
esac
|
||||
rm -f "$HDR" "$BODY"
|
||||
exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"defaults":
|
||||
{
|
||||
"run-as": "package"
|
||||
},
|
||||
"username": "giteahubsync",
|
||||
"groupname": "giteahubsync"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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
|
||||
@@ -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 同步删
|
||||
- 推送失败下一轮自动重试;开关关掉后完全停止同步
|
||||
@@ -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"
|
||||
+226
@@ -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); });
|
||||
+134
@@ -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()
|
||||
Reference in New Issue
Block a user