Forráskód Böngészése

增加自更新功能

NGS 3 hete
szülő
commit
41fe7777b7

+ 5 - 1
CMakeLists.txt

@@ -28,6 +28,7 @@ add_executable(ngs
     src/ssl/acme.cpp
     src/auth/auth.cpp
     src/weblog/weblog.cpp
+    src/selfupdate/selfupdate.cpp
 )
 
 target_include_directories(ngs PRIVATE
@@ -36,7 +37,10 @@ target_include_directories(ngs PRIVATE
 )
 
 target_compile_options(ngs PRIVATE -Wall -Wextra)
-target_compile_definitions(ngs PRIVATE NGS_SOURCE_DIR="${CMAKE_SOURCE_DIR}")
+target_compile_definitions(ngs PRIVATE
+    NGS_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
+    NGS_BINARY_DIR="${CMAKE_BINARY_DIR}"
+)
 
 list(APPEND CMAKE_LIBRARY_PATH /usr/local/lib /usr/lib/x86_64-linux-gnu /usr/lib)
 

+ 7 - 0
data/www/assets/app.css

@@ -63,6 +63,13 @@ html, body {
 .rail-foot {
   margin-top: auto;
 }
+.self-update .btn {
+  font-size: 0.75rem;
+  padding: 0.2rem 0.45rem;
+}
+#self-version.is-update {
+  color: #fbbf24 !important;
+}
 
 .live .pulse {
   width: 8px;

+ 134 - 1
data/www/assets/app.js

@@ -58,6 +58,7 @@
     nginxConfigModal: null,
     redisCfgEditor: null,
     nginxCfgEditor: null,
+    selfInfo: null,
     siteLogKind: "access",
     siteLogIp: "",
     siteLogQuery: "",
@@ -1154,7 +1155,101 @@
   function renderConsole() {
     const s = state.status || {};
     $("#overview-sites").textContent = `网站 ${s.sites ?? 0} · API 15665`;
-    $("#top-meta").textContent = `站点 ${s.sites ?? 0}`;
+    const ver = state.selfInfo?.commit_short;
+    $("#top-meta").textContent = ver
+      ? `站点 ${s.sites ?? 0} · ${ver}`
+      : `站点 ${s.sites ?? 0}`;
+  }
+
+  function renderSelfVersion(info) {
+    state.selfInfo = info || null;
+    const el = $("#self-version");
+    const btn = $("#btn-self-update");
+    if (!el) return;
+    if (!info || !info.has_repo) {
+      el.textContent = "版本 —";
+      el.classList.remove("is-update");
+      el.title = (info && info.error) || "无法读取 Git 版本";
+      btn?.classList.add("d-none");
+      return;
+    }
+    const parts = [];
+    if (info.branch) parts.push(info.branch);
+    parts.push(info.commit_short || info.commit || "—");
+    if (info.update_available) parts.push(`落后${info.behind}`);
+    el.textContent = parts.join(" · ");
+    el.classList.toggle("is-update", !!info.update_available);
+    el.title = [
+      info.subject || "",
+      info.commit_date || "",
+      info.remote ? `${info.remote} ${info.remote_commit_short || ""}`.trim() : "",
+      info.in_tmux ? `tmux ${info.tmux_pane || ""}`.trim() : "未在 tmux 中运行",
+      info.dirty ? "工作区有未提交改动" : "",
+    ]
+      .filter(Boolean)
+      .join("\n");
+    btn?.classList.toggle("d-none", !info.update_available);
+    if ($("#top-meta")) {
+      const s = state.status || {};
+      $("#top-meta").textContent = `站点 ${s.sites ?? 0} · ${info.commit_short || ""}`;
+    }
+  }
+
+  async function loadSelfVersion({ fetchRemote = false } = {}) {
+    const info = fetchRemote
+      ? await api("/api/self/check", { method: "POST", body: {} })
+      : await api("/api/self/version");
+    renderSelfVersion(info);
+    return info;
+  }
+
+  function sleep(ms) {
+    return new Promise((r) => setTimeout(r, ms));
+  }
+
+  async function waitForPanelRestart(taskId) {
+    const t0 = Date.now();
+    let sawDown = false;
+    let successAt = 0;
+    while (Date.now() - t0 < 300000) {
+      await sleep(1500);
+      try {
+        if (taskId && !sawDown) {
+          const data = await api("/api/tasks");
+          const tasks = (data && data.tasks) || [];
+          const t = tasks.find((x) => x.id === taskId);
+          if (t && t.status === "failed") {
+            toast(t.message || "更新失败", "err");
+            return;
+          }
+          if (t && t.status === "success") {
+            if (!successAt) successAt = Date.now();
+            if (Date.now() - successAt > 8000 && !sawDown) {
+              toast("已是最新,无需重启");
+              await loadSelfVersion({ fetchRemote: false });
+              return;
+            }
+          }
+        }
+        const res = await fetch("/api/ping", { credentials: "same-origin" });
+        if (!res.ok) {
+          sawDown = true;
+          continue;
+        }
+        const json = await res.json().catch(() => null);
+        if (json && Number(json.code) === 200) {
+          if (sawDown) {
+            location.reload();
+            return;
+          }
+        } else {
+          sawDown = true;
+        }
+      } catch (_) {
+        sawDown = true;
+      }
+    }
+    toast("面板仍在重启,请稍后手动刷新", "err");
   }
 
   function renderSoftware() {
@@ -4567,6 +4662,37 @@
       withBusy(async () => openIpAccessDetail(state.analyticsSite, ip));
     });
 
+    $("#btn-self-check")?.addEventListener("click", (e) => {
+      withBusy(async () => {
+        const info = await loadSelfVersion({ fetchRemote: true });
+        if (info?.update_available) {
+          toast(`发现 ${info.behind} 个新提交`);
+        } else if (info?.error) {
+          toast(info.error, "err");
+        } else {
+          toast("已是最新");
+        }
+      }, e.currentTarget);
+    });
+    $("#btn-self-update")?.addEventListener("click", (e) => {
+      withBusy(async () => {
+        const ok = await confirmDialog({
+          title: "更新面板",
+          message:
+            "将从 Git 拉取最新代码、编译并重启面板。更新期间页面会短暂中断;若当前在 tmux 中运行,会在同一窗口里拉起。",
+          okText: "开始更新",
+        });
+        if (!ok) return;
+        const data = await api("/api/self/update", { method: "POST", body: {} });
+        toast("已开始更新,完成后会自动重启");
+        if (data && data.task_id) {
+          await refreshTasks();
+          openTaskPanel(data.task_id);
+          await waitForPanelRestart(data.task_id);
+        }
+      }, e.currentTarget);
+    });
+
     $("#btn-logout")?.addEventListener("click", async () => {
       const ok = await confirmDialog({
         title: "退出登录",
@@ -5264,6 +5390,13 @@
     setView(titles[hash] ? hash : "console");
     refresh();
     refreshTasks();
+    loadSelfVersion().catch(() => {});
+    setTimeout(() => {
+      loadSelfVersion({ fetchRemote: true }).catch(() => {});
+    }, 1500);
+    setInterval(() => {
+      loadSelfVersion({ fetchRemote: true }).catch(() => {});
+    }, 15 * 60 * 1000);
     setInterval(refresh, 15000);
     setInterval(refreshTasks, 2000);
   }

+ 7 - 0
data/www/index.html

@@ -37,6 +37,13 @@
         <button type="button" class="nav-link text-start nav-item" data-view="plans"><i class="bi bi-calendar2-check me-2"></i>计划</button>
       </nav>
       <div class="rail-foot px-2 pb-2">
+        <div class="self-update px-1 pb-2" id="self-update-box">
+          <div class="small text-white-50 text-truncate" id="self-version" title="当前版本">版本 —</div>
+          <div class="d-flex gap-1 mt-1">
+            <button type="button" class="btn btn-sm btn-outline-light flex-grow-1" id="btn-self-check">检查更新</button>
+            <button type="button" class="btn btn-sm btn-warning d-none" id="btn-self-update">更新</button>
+          </div>
+        </div>
         <button type="button" class="nav-link text-start nav-item nav-logout w-100" id="btn-logout">
           <i class="bi bi-box-arrow-right me-2"></i>退出
         </button>

+ 0 - 913
index.html

@@ -1,913 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-
-<head>
-  <title data-i18n="home.title">首页</title>
-  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
-<meta http-equiv="X-UA-Compatible" content="IE=edge">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<meta name="theme-color" content="#ff4c3b" />
-<meta name="apple-mobile-web-app-capable" content="yes">
-<meta name="apple-mobile-web-app-status-bar-style" content="black">
-<meta name="apple-mobile-web-app-title" content="">
-<meta name="msapplication-TileImage" content="#">
-<meta name="msapplication-TileColor" content="#FFFFFF">
-<meta http-equiv="X-UA-Compatible" content="IE=edge" /> 
-
-
-<link rel="icon" href="#" type="image/x-icon" />
-<link rel="apple-touch-icon" href="#">
-
-<link rel="stylesheet" id="rtl-link" type="text/css" href="https://txbj.assets.youyanen.com/student/browsers/assets/css/vendors/bootstrap.css">
-<link rel="stylesheet" type="text/css" href="https://txbj.assets.youyanen.com/student/browsers/assets/css/vendors/slick-theme.css">
-<link rel="stylesheet" type="text/css" href="https://txbj.assets.youyanen.com/student/browsers/assets/css/vendors/slick.css">
-<link rel="stylesheet" type="text/css" href="https://txbj.assets.youyanen.com/student/browsers/assets/css/vendors/iconly.css">
-<link rel="stylesheet" id="change-link" type="text/css" href="https://txbj.assets.youyanen.com/student/browsers/assets/css/style.css">
-<link rel="stylesheet" href="https://txbj.assets.youyanen.com/student/browsers/assets/css/font-awesome.min.css">
-<link rel="stylesheet" href="https://txbj.assets.youyanen.com/student/browsers/assets/css/cropper.min.css">
-<link rel="stylesheet" href="https://txbj.assets.youyanen.com/browser/css/bootstrap-icons.min.css">
-
-  <style>
-    body.home2-page {
-      background: #f5f5f5;
-      padding-bottom: 72px;
-    }
-
-    body.home2-page header {
-      position: relative;
-      left: 0 !important;
-      top: 0;
-      transform: none !important;
-      display: flex;
-      align-items: center;
-      justify-content: flex-start;
-      width: 100%;
-      max-width: 100% !important;
-      margin: 0;
-      padding: 12px 15px 0;
-      background: #f5f5f5;
-      box-shadow: none;
-      z-index: 1;
-    }
-
-    body.home2-page header .brand-logo {
-      display: block;
-      line-height: 0;
-    }
-
-    body.home2-page header .brand-logo img {
-      width: auto;
-      max-width: 140px;
-      max-height: 36px;
-      height: auto;
-      object-fit: contain;
-      display: block;
-    }
-
-    .home2-page .loader {
-      display: none !important;
-    }
-
-    .home2-wrap {
-      background: #f5f5f5;
-    }
-
-  /* Nav icons */
-    .home2-nav {
-      display: flex;
-      justify-content: space-between;
-      padding: 18px 16px 8px;
-      background: #f5f5f5;
-    }
-
-    .home2-nav-item {
-      flex: 1;
-      text-align: center;
-      text-decoration: none;
-      color: #444;
-    }
-
-    .home2-nav-icon {
-      width: 52px;
-      height: 52px;
-      margin: 0 auto 8px;
-      border-radius: 16px;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      color: #fff;
-      font-size: 22px;
-      box-shadow: 0 4px 10px rgba(0, 0, 0, .08);
-    }
-
-    .home2-nav-icon.orange { background: #ff4c3b; }
-    .home2-nav-icon.purple { background: #9b59b6; }
-    .home2-nav-icon.green { background: #2ecc71; }
-    .home2-nav-icon.blue { background: #3498db; }
-    .home2-nav-icon.pink { background: #ff6b9d; }
-
-    .home2-nav-label {
-      font-size: 12px;
-      font-weight: 500;
-    }
-
-  /* Promo banner */
-    .home2-promo {
-      margin: 8px 15px 0;
-      border-radius: 16px;
-      overflow: hidden;
-      background: linear-gradient(135deg, #ff7a6a 0%, #ff4c3b 55%, #e53e30 100%);
-      min-height: 96px;
-      display: flex;
-      align-items: center;
-      text-decoration: none;
-      color: #fff;
-      box-shadow: 0 6px 18px rgba(255, 143, 0, .28);
-    }
-
-    .home2-promo-img {
-      width: 110px;
-      height: 96px;
-      object-fit: contain;
-      flex-shrink: 0;
-      margin-left: 4px;
-    }
-
-    .home2-promo-text {
-      flex: 1;
-      padding: 12px 14px 12px 0;
-    }
-
-    .home2-promo-title {
-      margin: 0 0 6px;
-      font-size: 22px;
-      font-weight: 800;
-      letter-spacing: 1px;
-    }
-
-    .home2-promo-sub {
-      margin: 0;
-      font-size: 13px;
-      font-weight: 600;
-      opacity: .95;
-    }
-
-    .home2-promo-sub .yellow {
-      color: #ffe066;
-    }
-
-  /* Section */
-    .home2-section {
-      padding: 22px 15px 0;
-    }
-
-    .home2-section-head {
-      display: flex;
-      align-items: flex-start;
-      justify-content: space-between;
-      gap: 12px;
-      margin-bottom: 14px;
-    }
-
-    .home2-section-title {
-      margin: 0;
-      font-size: 20px;
-      font-weight: 800;
-      color: #222;
-      line-height: 1.3;
-    }
-
-    .home2-section-sub {
-      margin: 4px 0 0;
-      font-size: 13px;
-      color: #999;
-      font-weight: 400;
-    }
-
-    .home2-more-btn {
-      flex-shrink: 0;
-      padding: 4px 12px;
-      border: 1px solid #ddd;
-      border-radius: 999px;
-      background: #fff;
-      color: #666;
-      font-size: 12px;
-      text-decoration: none;
-      white-space: nowrap;
-      line-height: 1.6;
-    }
-
-  /* Video scroll */
-    .home2-video-scroll {
-      display: flex;
-      gap: 12px;
-      overflow-x: auto;
-      padding-bottom: 6px;
-      scroll-snap-type: x mandatory;
-      -webkit-overflow-scrolling: touch;
-    }
-
-    .home2-video-scroll::-webkit-scrollbar {
-      display: none;
-    }
-
-    .home2-video-card {
-      flex: 0 0 68%;
-      max-width: 280px;
-      scroll-snap-align: start;
-      border-radius: 14px;
-      overflow: hidden;
-      background: #fff;
-      box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
-      text-decoration: none;
-      color: inherit;
-      position: relative;
-    }
-
-    .home2-video-thumb {
-      position: relative;
-      width: 100%;
-      aspect-ratio: 16 / 10;
-      background: linear-gradient(135deg, #e8f4fc 0%, #d4e8f7 100%);
-      overflow: hidden;
-    }
-
-    .home2-video-thumb img,
-    .home2-video-preview {
-      width: 100%;
-      height: 100%;
-      object-fit: cover;
-    }
-
-    .home2-video-preview {
-      display: block;
-      pointer-events: none;
-    }
-
-    .home2-video-thumb img {
-      width: 100%;
-      height: 100%;
-      object-fit: cover;
-    }
-
-    .home2-video-play {
-      position: absolute;
-      inset: 0;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      background: rgba(0, 0, 0, .12);
-    }
-
-    .home2-video-play span {
-      width: 44px;
-      height: 44px;
-      border-radius: 50%;
-      background: rgba(255, 255, 255, .88);
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      color: #ff4c3b;
-      font-size: 18px;
-      padding-left: 3px;
-      box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
-    }
-
-    .home2-video-avatar {
-      position: absolute;
-      right: 8px;
-      bottom: 8px;
-      width: 36px;
-      height: 36px;
-      border-radius: 50%;
-      border: 2px solid #fff;
-      object-fit: cover;
-      box-shadow: 0 2px 6px rgba(0, 0, 0, .2);
-    }
-
-    .home2-video-empty {
-      padding: 28px 16px;
-      text-align: center;
-      color: #999;
-      font-size: 14px;
-      background: #fff;
-      border-radius: 14px;
-    }
-
-    .home2-nav-item:hover {
-      color: #444;
-    }
-
-    .home2-promo:hover {
-      color: #fff;
-    }
-
-  /* Teacher recommend */
-    .home2-teacher-list {
-      display: flex;
-      flex-direction: column;
-      gap: 12px;
-    }
-
-    .home2-tr-card {
-      position: relative;
-      background: #fff;
-      border-radius: 14px;
-      box-shadow: 0 2px 10px rgba(0, 0, 0, .06);
-      overflow: hidden;
-      cursor: pointer;
-    }
-
-    .home2-tr-card-link {
-      position: absolute;
-      inset: 0;
-      z-index: 1;
-    }
-
-    .home2-tr-card-link:focus {
-      outline: none;
-    }
-
-    .home2-tr-card-link:focus-visible {
-      outline: 2px solid #ff4c3b;
-      outline-offset: 2px;
-    }
-
-    .home2-tr-hot {
-      position: absolute;
-      top: 0;
-      left: 0;
-      z-index: 1;
-      display: inline-flex;
-      align-items: center;
-      gap: 4px;
-      padding: 5px 8px 5px 10px;
-      background: linear-gradient(135deg, #ffd54f 0%, #ffb300 100%);
-      color: #7a4f00;
-      font-size: 11px;
-      font-weight: 700;
-      border-radius: 14px 0 12px 0;
-      line-height: 1.2;
-    }
-
-    .home2-tr-inner {
-      display: flex;
-      gap: 12px;
-      padding: 12px;
-      align-items: stretch;
-    }
-
-    .home2-tr-thumb {
-      position: relative;
-      flex: 0 0 118px;
-      width: 118px;
-      height: 118px;
-      border-radius: 12px;
-      overflow: hidden;
-      display: block;
-      background: #eee;
-    }
-
-    .home2-tr-thumb img,
-    .home2-tr-thumb video {
-      width: 100%;
-      height: 100%;
-      object-fit: cover;
-      display: block;
-    }
-
-    .home2-tr-thumb video {
-      pointer-events: none;
-    }
-
-    .home2-tr-play {
-      position: absolute;
-      left: 50%;
-      top: 50%;
-      transform: translate(-50%, -50%);
-      width: 36px;
-      height: 36px;
-      border-radius: 50%;
-      background: rgba(255, 255, 255, .92);
-      color: #ff4c3b;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      font-size: 14px;
-      padding-left: 2px;
-      box-shadow: 0 2px 8px rgba(0, 0, 0, .18);
-    }
-
-    .home2-tr-thumb-label {
-      position: absolute;
-      left: 0;
-      right: 0;
-      bottom: 0;
-      padding: 18px 6px 6px;
-      background: linear-gradient(transparent, rgba(0, 0, 0, .62));
-      color: #fff;
-      font-size: 10px;
-      line-height: 1.35;
-      word-break: break-word;
-    }
-
-    .home2-tr-info {
-      flex: 1;
-      min-width: 0;
-      display: flex;
-      flex-direction: column;
-      justify-content: space-between;
-      padding-top: 2px;
-    }
-
-    .home2-tr-name-row {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      gap: 8px;
-      margin-bottom: 8px;
-    }
-
-    .home2-tr-name-main {
-      flex: 1;
-      min-width: 0;
-      display: flex;
-      align-items: baseline;
-      flex-wrap: wrap;
-      gap: 6px;
-    }
-
-    .home2-tr-software {
-      display: flex;
-      flex-wrap: wrap;
-      align-items: center;
-      justify-content: flex-end;
-      gap: 6px;
-      flex-shrink: 0;
-    }
-
-    .home2-tr-software-icon {
-      width: 20px;
-      height: 20px;
-      object-fit: contain;
-      display: block;
-    }
-
-    .home2-tr-name {
-      font-family: Georgia, "Times New Roman", "Songti SC", serif;
-      font-size: 20px;
-      font-weight: 700;
-      color: #111;
-      line-height: 1.1;
-    }
-
-    .home2-tr-loc {
-      font-size: 13px;
-      color: #999;
-    }
-
-    .home2-tr-tags {
-      display: flex;
-      flex-wrap: wrap;
-      gap: 6px;
-      margin-bottom: 10px;
-    }
-
-    .home2-tr-tag {
-      display: inline-block;
-      padding: 3px 10px;
-      border-radius: 999px;
-      background: #fff5f4;
-      color: #ff4c3b;
-      border: 1px solid rgba(255, 76, 59, .35);
-      font-size: 12px;
-      font-weight: 600;
-      line-height: 1.4;
-      white-space: nowrap;
-    }
-
-    .home2-tr-tag-primary {
-      background: linear-gradient(135deg, #ff6a5a 0%, #ff4c3b 100%);
-      color: #fff;
-      border-color: transparent;
-      box-shadow: 0 2px 6px rgba(255, 76, 59, .28);
-    }
-
-    .home2-tr-bottom {
-      display: flex;
-      align-items: center;
-      justify-content: flex-end;
-      gap: 8px;
-    }
-
-    .home2-tr-book-btn {
-      position: relative;
-      z-index: 1;
-      flex-shrink: 0;
-      padding: 6px 14px;
-      border: 1px solid #ff4c3b;
-      border-radius: 999px;
-      color: #ff4c3b;
-      font-size: 14px;
-      font-weight: 600;
-      text-decoration: none;
-      background: #fff;
-      white-space: nowrap;
-    }
-
-    .home2-tr-book-btn:hover {
-      color: #ff4c3b;
-      background: #fff5f4;
-    }
-
-    .home2-teacher-empty {
-      padding: 28px 16px;
-      text-align: center;
-      color: #999;
-      font-size: 14px;
-      background: #fff;
-      border-radius: 14px;
-    }
-
-    @media (max-width: 360px) {
-      .home2-nav-icon { width: 46px; height: 46px; font-size: 19px; }
-      .home2-nav-label { font-size: 11px; }
-      .home2-tr-thumb { flex-basis: 104px; width: 104px; height: 104px; }
-      .home2-tr-name { font-size: 18px; }
-    }
-  </style>
-</head>
-
-<body class="home2-page ">
-
-  <!-- loader strat -->
-<div class="loader">
-    <span></span>
-    <span></span>
-</div>
-<!-- loader end -->
-
-<style>
-header .brand-logo img.brand-logo-img {
-    border: none;
-    outline: none;
-    box-shadow: none;
-    background: transparent;
-    opacity: 0;
-    transition: opacity .12s ease;
-}
-
-header .brand-logo img.brand-logo-img.is-ready {
-    opacity: 1;
-}
-</style>
-
-<header style="background-color: transparent;">
-    <a href="/public/open/index.html" class="brand-logo">
-        <img class="img-fluid brand-logo-img" alt="" id="img-logo-dark" width="179" height="34">
-    </a>
-</header>
-
- 
-  <div class="home2-wrap">
-
-    <!-- Nav icons -->
-    <nav class="home2-nav">
-      <a href="/public/open/teacher.html" class="home2-nav-item">
-        <div class="home2-nav-icon orange"><i class="fa fa-users"></i></div>
-        <div class="home2-nav-label" data-i18n="home.nav_teacher">教师列表</div>
-      </a>
-      <a href="/public/open/book.html" class="home2-nav-item">
-        <div class="home2-nav-icon purple"><i class="fa fa-book"></i></div>
-        <div class="home2-nav-label" data-i18n="home.nav_book">教材库</div>
-      </a>
-      <a href="/public/user/course.html" class="home2-nav-item">
-        <div class="home2-nav-icon green"><i class="fa fa-calendar"></i></div>
-        <div class="home2-nav-label" data-i18n="home.nav_course">我的课程</div>
-      </a>
-      <a href="/public/open/teacher.html" class="home2-nav-item">
-        <div class="home2-nav-icon pink"><i class="fa fa-play-circle"></i></div>
-        <div class="home2-nav-label" data-i18n="home.nav_free">免费课</div>
-      </a>
-    </nav>
-
-    <!-- Promo banner -->
-    <!-- <a href="/public/open/book.html" class="home2-promo">
-      <img class="home2-promo-img" src="/public/assets/images/index2/promo-boy.svg" alt="">
-      <div class="home2-promo-text">
-        <h2 class="home2-promo-title" data-i18n="home.promo_title">经典教材介绍</h2>
-        <p class="home2-promo-sub"><span class="yellow">KID's BOX</span> / POWER UP</p>
-      </div>
-    </a> -->
-
-
-    <!-- Video clips -->
-    <section class="home2-section" style="display:none;padding-bottom: 24px;">
-      <div class="home2-section-head">
-        <div>
-          <h2 class="home2-section-title" data-i18n="home.clips_title">来看精彩切片吧</h2>
-          <p class="home2-section-sub" data-i18n="home.clips_sub">外教课堂切片</p>
-        </div>
-        <a href="/public/open/teacher.html" class="home2-more-btn" data-i18n="home.more">更多 &gt;</a>
-      </div>
-      <div class="home2-video-scroll" id="home2_video_list"></div>
-    </section>
-
-    <!-- Teacher recommend -->
-    <section class="home2-section" style="padding-bottom: 24px;">
-      <div class="home2-section-head">
-        <div>
-          <h2 class="home2-section-title" data-i18n="home.teacher_title">外教推荐</h2>
-          <p class="home2-section-sub" data-i18n="home.teacher_sub">启蒙 / 绘本 / 儿歌 / 自然拼读</p>
-        </div>
-        <a href="/public/open/teacher.html" class="home2-more-btn" data-i18n="home.all">全部 &gt;</a>
-      </div>
-      <div class="home2-teacher-list" id="home2_teacher_list"></div>
-    </section>
-
-  </div>
-
-  <div class="bottom-panel">
-    <ul>
-    <li>
-        <a href="/public/open/index.html">
-            <div class="icon">
-                <i class="iconly-Home icli"></i>
-                <i class="iconly-Home icbo"></i>
-            </div>
-            <span data-i18n="nav.home">首页</span>
-        </a>
-    </li>
-    <!-- <li>
-        <a href="/public/open/teacher.html">
-            <div class="icon">
-                <i class="iconly-Category icli"></i>
-                <i class="iconly-Category icbo"></i>
-            </div>
-            <span data-i18n="nav.teacher">教师</span>
-        </a>
-    </li>
-
-    <li>
-        <a href="/public/open/book.html">
-            <div class="icon">
-                <i class="iconly-Document icli"></i>
-                <i class="iconly-Document icbo"></i>
-            </div>
-            <span data-i18n="nav.book">教材库</span>
-        </a>
-    </li> -->
-    <li>
-        <a href="/public/user/course.html">
-            <div class="icon">
-                <i class="iconly-Calendar icli"></i>
-                <i class="iconly-Calendar icbo"></i>
-                <span class="bottom-homework-badge" id="bottom_homework_pending" hidden></span>
-            </div>
-            <span data-i18n="nav.course">课程</span>
-        </a>
-    </li>
-    <li>
-        <a href="/public/user/chatlist.html">
-            <div class="icon">
-                <i class="iconly-Chat icli"></i>
-                <i class="iconly-Chat icbo"></i>
-                <span class="bottom-im-badge" id="bottom_im_unread" hidden></span>
-            </div>
-            <span data-i18n="nav.message">消息</span>
-        </a>
-    </li>
-    <li>
-        <a href="/public/user/profile.html">
-            <div class="icon">
-                <i class="iconly-Profile icli"></i>
-                <i class="iconly-Profile icbo"></i>
-            </div>
-            <span data-i18n="nav.profile">个人中心</span>
-        </a>
-    </li>
-</ul>
-<style>
-  .bottom-panel ul li .icon {
-    position: relative;
-  }
-
-  /* 覆盖 .bottom-panel ul li a span 的菜单文字样式,避免角标把图标撑开 */
-  .bottom-panel ul li a .icon .bottom-im-badge,
-  .bottom-panel ul li a .icon .bottom-homework-badge {
-    position: absolute;
-    top: -4px;
-    right: -10px;
-    min-width: 16px;
-    height: 16px;
-    margin: 0;
-    padding: 0 4px;
-    border-radius: 999px;
-    background: #ff4c3b;
-    color: #fff;
-    font-size: 10px;
-    font-weight: 700;
-    line-height: 16px;
-    text-align: center;
-    text-transform: none;
-    display: block;
-    box-sizing: border-box;
-    pointer-events: none;
-    white-space: nowrap;
-    z-index: 1;
-  }
-
-  .bottom-panel ul li a .icon .bottom-homework-badge {
-    right: -28px;
-  }
-
-  .bottom-panel ul li a .icon .bottom-im-badge[hidden],
-  .bottom-panel ul li a .icon .bottom-homework-badge[hidden] {
-    display: none !important;
-  }
-</style>
-<script src="/public/assets/js/im-unread-badge.js"></script>
-<script src="/public/assets/js/homework-pending-badge.js"></script>
-<script>
-    document.addEventListener("DOMContentLoaded", function() {
-  if (typeof I18n !== 'undefined') I18n.apply();
-
-  // 基础路径(/public)请确保与页面中使用的一致
-  const basePath = "/public";
-  // 当前页面路径(不含域名)
-  const currentPath = window.location.pathname;
-  
-  // 仅选择 .bottom-panel 内的导航链接
-  const navLinks = document.querySelectorAll(".bottom-panel ul li a");
-  
-  navLinks.forEach(function(link) {
-    const href = link.getAttribute("href");
-    // 如果链接以 basePath 开头,则取其后面的部分
-    const relativePath = href.startsWith(basePath) ? href.substring(basePath.length) : href;
-    
-    // 判断当前路径中是否包含该相对路径
-    if (currentPath.includes(relativePath)) {
-      link.parentElement.classList.add("active");
-    } else {
-      link.parentElement.classList.remove("active");
-    }
-  });
-});
-
-</script>
-  </div>
-
-  <script src="https://txbj.assets.youyanen.com/student/browsers/assets/js/jquery-3.3.1.min.js"></script>
-<script src="https://txbj.assets.youyanen.com/student/browsers/assets/js/bootstrap.bundle.min.js"></script>
-<script src="https://txbj.assets.youyanen.com/student/browsers/assets/js/slick.js"></script>
-<script src="https://txbj.assets.youyanen.com/student/browsers/assets/js/filter.js"></script>
-<script src="/public/assets/js/script.js"></script>
-<script src="https://txbj.assets.youyanen.com/student/browsers/assets/js/sweetalert2.js"></script>
-<script src="/public/assets/js/i18n.js"></script>
-<script src="/public/assets/js/user.js"></script>
-<script src="/public/assets/js/customer_service.js"></script>
-<script src="/public/assets/js/utils.js"></script>
-  <script src="/public/assets/js/home2_teacher.js"></script>
-
-  <script>
-    $(document).ready(function () {
-      I18n.apply();
-      loadHome2TeacherList();
-      initCustomerServiceFab();
-    });
-
-    function getTeacherCountryLabel(country) {
-      if (String(country) === '3') return I18n.t('home.country_ph');
-      if (String(country) === '2') return I18n.t('home.country_us');
-      if (String(country) === '1') return I18n.t('home.country_cn');
-      return '';
-    }
-
-    function parseTeachingObjects(teacher) {
-      try {
-        return JSON.parse(teacher.teaching_objects || '[]');
-      } catch (e) {
-        return [];
-      }
-    }
-
-    function buildTeacherThumbLabel(teacher) {
-      var objects = parseTeachingObjects(teacher);
-      var parts = [];
-      for (var i = 0; i < objects.length; i++) {
-        if (String(objects[i]) === '1') parts.push(I18n.t('home.object_kids'));
-        if (String(objects[i]) === '2') parts.push(I18n.t('home.object_adult'));
-        if (String(objects[i]) === '3') parts.push(I18n.t('home.object_ielts'));
-      }
-      if (parts.length) return parts.join(' / ');
-      return I18n.t('home.teacher_intro');
-    }
-
-    function renderHome2TeacherCard(teacher, index) {
-      var detailUrl = '/public/open/teacher_details.html?id=' + teacher.id;
-      var avatarUrl = teacher.avatar
-        ? 'https://txbj.assets.youyanen.com/' + teacher.avatar
-        : '/public/assets/images/avatar/default.png';
-      var thumbMedia = '<img src="' + avatarUrl + '" alt="' + teacher.nickname + '" loading="lazy">';
-      var hotHtml = Number(teacher.level) === 2
-        ? '<span class="home2-tr-hot">' + I18n.t('home.gold') + '</span>'
-        : '';
-      var cardClass = hotHtml ? 'home2-tr-card has-hot' : 'home2-tr-card';
-      var tagsHtml = Home2TeacherEva.buildCardTagsHtml(teacher.id, teacher);
-
-      return '' +
-        '<div class="' + cardClass + '">' +
-          hotHtml +
-          '<a class="home2-tr-card-link" href="' + detailUrl + '" aria-label="' + I18n.t('home.view_teacher', {name: teacher.nickname}) + '"></a>' +
-          '<div class="home2-tr-inner">' +
-            '<div class="home2-tr-thumb">' +
-              thumbMedia +
-              '<span class="home2-tr-thumb-label">' + buildTeacherThumbLabel(teacher) + '</span>' +
-            '</div>' +
-            '<div class="home2-tr-info">' +
-              '<div class="home2-tr-name-row">' +
-                '<div class="home2-tr-name-main">' +
-                  '<span class="home2-tr-name">' + teacher.nickname + '</span>' +
-                  '<span class="home2-tr-loc">' + getTeacherCountryLabel(teacher.country) + '</span>' +
-                '</div>' +
-                Home2TeacherEva.buildTeachingSoftwareHtml(teacher) +
-              '</div>' +
-              tagsHtml +
-              '<div class="home2-tr-bottom">' +
-                '<a class="home2-tr-book-btn" href="' + detailUrl + '">' + I18n.t('home.book') + '</a>' +
-              '</div>' +
-            '</div>' +
-          '</div>' +
-        '</div>';
-    }
-
-    function loadHome2TeacherList() {
-      user.newTeacherList(function (code, msg, data) {
-        var $list = $('#home2_teacher_list');
-        $list.empty();
-        if (code !== 200 || !data || !data.data || !data.data.length) {
-          $list.html('<div class="home2-teacher-empty">' + I18n.t('home.no_teacher') + '</div>');
-          return;
-        }
-        var teachers = data.data;
-        for (var i = 0; i < teachers.length; i++) {
-          $list.append(renderHome2TeacherCard(teachers[i], i));
-        }
-      });
-    }
-
-    function loadHome2VideoClips() {
-      var teachers = user.getTeacherList({});
-      var $list = $('#home2_video_list');
-      $list.empty();
-
-      var clips = [];
-      if (teachers && teachers.length) {
-        for (var i = 0; i < teachers.length; i++) {
-          var teacher = teachers[i];
-          if (!teacher.course_video) continue;
-          var videos = [];
-          try {
-            videos = JSON.parse(teacher.course_video);
-          } catch (e) {
-            continue;
-          }
-          for (var j = 0; j < videos.length; j++) {
-            clips.push({
-              teacher: teacher,
-              video: videos[j]
-            });
-            if (clips.length >= 6) break;
-          }
-          if (clips.length >= 6) break;
-        }
-      }
-
-      if (!clips.length) {
-        $list.html('<div class="home2-video-empty">' + I18n.t('home.no_clips') + '</div>');
-        return;
-      }
-
-      for (var k = 0; k < clips.length; k++) {
-        var item = clips[k];
-        var teacher = item.teacher;
-        var videoUrl = 'https://txbj.assets.youyanen.com/' + item.video;
-        var avatar = teacher.avatar
-          ? 'https://txbj.assets.youyanen.com/' + teacher.avatar
-          : '/public/assets/images/avatar/default.png';
-        var cardHtml =
-          '<a class="home2-video-card" href="/public/open/teacher_details.html?id=' + teacher.id + '">' +
-            '<div class="home2-video-thumb">' +
-              '<video class="home2-video-preview" muted playsinline preload="metadata" src="' + videoUrl + '"></video>' +
-              '<div class="home2-video-play"><span><i class="fa fa-play"></i></span></div>' +
-              '<img class="home2-video-avatar" src="' + avatar + '" alt="">' +
-            '</div>' +
-          '</a>';
-        $list.append(cardHtml);
-      }
-    }
-
-  </script>
-</body>
-
-</html>

+ 82 - 0
src/api/api_server.cpp

@@ -13,6 +13,7 @@
 #include "../system/process.h"
 #include "../system/shell.h"
 #include "../schedule/schedule.h"
+#include "../selfupdate/selfupdate.h"
 #include "../tasks/tasks.h"
 #include "../utils.h"
 #include "../website/website.h"
@@ -715,6 +716,83 @@ void h_status(request* req, response* resp) {
     reply_ok(resp, data);
 }
 
+ylib::json self_info_json(const selfupdate::Info& info) {
+    ylib::json data;
+    data["source_dir"] = info.source_dir;
+    data["binary_dir"] = info.binary_dir;
+    data["branch"] = info.branch;
+    data["commit"] = info.commit;
+    data["commit_short"] = info.commit_short;
+    data["commit_date"] = info.commit_date;
+    data["subject"] = info.subject;
+    data["remote"] = info.remote;
+    data["remote_url"] = info.remote_url;
+    data["remote_commit"] = info.remote_commit;
+    data["remote_commit_short"] = info.remote_commit_short;
+    data["ahead"] = info.ahead;
+    data["behind"] = info.behind;
+    data["dirty"] = info.dirty;
+    data["has_repo"] = info.has_repo;
+    data["has_remote"] = info.has_remote;
+    data["update_available"] = info.update_available;
+    data["in_tmux"] = info.in_tmux;
+    data["tmux_pane"] = info.tmux_pane;
+    data["error"] = info.error;
+    return data;
+}
+
+void h_self_version(request* req, response* resp) {
+    if (!require_method(req, resp, "GET")) {
+        return;
+    }
+    selfupdate::Info info;
+    std::string err;
+    selfupdate::inspect(info, false, &err);
+    reply_ok(resp, self_info_json(info));
+}
+
+void h_self_check(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    selfupdate::Info info;
+    std::string err;
+    if (!selfupdate::inspect(info, true, &err) && !info.has_repo) {
+        reply_err(resp, err.empty() ? "检查更新失败" : err);
+        return;
+    }
+    ylib::json data = self_info_json(info);
+    const std::string msg = info.update_available
+                                ? ("发现 " + std::to_string(info.behind) + " 个新提交")
+                                : (info.error.empty() ? "已是最新" : info.error);
+    reply_ok(resp, data, msg);
+}
+
+void h_self_update(request* req, response* resp) {
+    if (!require_method(req, resp, "POST")) {
+        return;
+    }
+    std::string err;
+    const std::string id = tasks::enqueue(
+        "self.update", "更新面板",
+        []() {
+            std::string e;
+            if (!selfupdate::apply_update(&e)) {
+                if (!e.empty()) {
+                    log_error(e);
+                }
+                return false;
+            }
+            return true;
+        },
+        err);
+    if (id.empty()) {
+        reply_err(resp, err.empty() ? "enqueue failed" : err);
+        return;
+    }
+    reply_task_queued(resp, id);
+}
+
 void h_nginx_status(request* req, response* resp) {
     if (!require_method(req, resp, "GET")) {
         return;
@@ -2420,6 +2498,9 @@ void register_routes(ylib::network::http::router* router) {
     reg(router, "/api/auth/me", h_auth_me, false);
 
     reg(router, "/api/status", h_status);
+    reg(router, "/api/self/version", h_self_version);
+    reg(router, "/api/self/check", h_self_check);
+    reg(router, "/api/self/update", h_self_update);
     reg(router, "/api/system/metrics", h_system_metrics);
     reg(router, "/api/system/metrics/history", h_system_metrics_history);
     reg(router, "/api/system/processes", h_system_processes);
@@ -2596,6 +2677,7 @@ bool run(const std::string& listen_addr, uint16_t listen_port) {
     weblog::start();
     system::history_start();
     schedule::start();
+    selfupdate::init(listen_addr, listen_port);
     log_info("ngs apiserver starting listen=" + listen_addr + ":" +
              std::to_string(listen_port));
 

+ 427 - 0
src/selfupdate/selfupdate.cpp

@@ -0,0 +1,427 @@
+#include "selfupdate.h"
+
+#include "../utils.h"
+
+#include <array>
+#include <chrono>
+#include <csignal>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <dirent.h>
+#include <fstream>
+#include <sstream>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <thread>
+#include <unistd.h>
+#include <vector>
+
+#ifndef NGS_SOURCE_DIR
+#define NGS_SOURCE_DIR ""
+#endif
+#ifndef NGS_BINARY_DIR
+#define NGS_BINARY_DIR ""
+#endif
+
+namespace ngs {
+namespace selfupdate {
+
+namespace {
+
+struct Runtime {
+    std::string listen;
+    uint16_t port = 0;
+    std::string cwd;
+    std::string exe;
+    std::string tmux_pane;
+    std::string tmux_socket;
+    bool in_tmux = false;
+    pid_t pid = 0;
+};
+
+Runtime& rt() {
+    static Runtime r;
+    return r;
+}
+
+std::string trim_copy(std::string s) {
+    while (!s.empty() && (s.back() == '\n' || s.back() == '\r' ||
+                          s.back() == ' ' || s.back() == '\t')) {
+        s.pop_back();
+    }
+    size_t i = 0;
+    while (i < s.size() &&
+           (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) {
+        ++i;
+    }
+    return s.substr(i);
+}
+
+std::string sh_quote(const std::string& s) {
+    std::string out = "'";
+    for (char c : s) {
+        if (c == '\'') {
+            out += "'\\''";
+        } else {
+            out.push_back(c);
+        }
+    }
+    out += "'";
+    return out;
+}
+
+std::string source_dir() {
+    if (NGS_SOURCE_DIR[0]) {
+        return NGS_SOURCE_DIR;
+    }
+    if (!rt().cwd.empty()) {
+        if (path_exists(join_path(rt().cwd, "CMakeLists.txt"))) {
+            return rt().cwd;
+        }
+        if (path_exists(join_path(rt().cwd, "../CMakeLists.txt"))) {
+            return join_path(rt().cwd, "..");
+        }
+    }
+    return rt().cwd;
+}
+
+std::string binary_dir() {
+    if (NGS_BINARY_DIR[0]) {
+        return NGS_BINARY_DIR;
+    }
+    if (!rt().exe.empty()) {
+        const auto slash = rt().exe.find_last_of('/');
+        if (slash != std::string::npos) {
+            return rt().exe.substr(0, slash);
+        }
+    }
+    return join_path(source_dir(), "build");
+}
+
+std::string git_prefix() {
+    return "env GIT_TERMINAL_PROMPT=0 git -C " + sh_quote(source_dir()) + " ";
+}
+
+int capture(const std::string& cmd, std::string& out) {
+    out.clear();
+    FILE* fp = popen((cmd + " 2>&1").c_str(), "r");
+    if (!fp) {
+        return -1;
+    }
+    std::array<char, 512> buf{};
+    while (fgets(buf.data(), static_cast<int>(buf.size()), fp) != nullptr) {
+        out.append(buf.data());
+    }
+    const int status = pclose(fp);
+    if (status == -1) {
+        return -1;
+    }
+    if (WIFEXITED(status)) {
+        return WEXITSTATUS(status);
+    }
+    if (WIFSIGNALED(status)) {
+        return 128 + WTERMSIG(status);
+    }
+    return -1;
+}
+
+std::string git_one(const std::string& args) {
+    std::string out;
+    if (capture(git_prefix() + args, out) != 0) {
+        return "";
+    }
+    return trim_copy(out);
+}
+
+bool git_ok(const std::string& args) {
+    std::string out;
+    return capture(git_prefix() + args, out) == 0;
+}
+
+void close_extra_fds() {
+    if (DIR* dir = opendir("/proc/self/fd")) {
+        std::vector<int> fds;
+        while (dirent* ent = readdir(dir)) {
+            if (!ent->d_name[0] || ent->d_name[0] < '0' ||
+                ent->d_name[0] > '9') {
+                continue;
+            }
+            const int fd = std::atoi(ent->d_name);
+            if (fd > STDERR_FILENO) {
+                fds.push_back(fd);
+            }
+        }
+        closedir(dir);
+        for (int fd : fds) {
+            close(fd);
+        }
+    }
+}
+
+std::string start_command() {
+    std::string cmd = "./ngs";
+    const std::string bin = binary_dir();
+    if (rt().cwd != bin && !rt().exe.empty()) {
+        cmd = sh_quote(rt().exe);
+    }
+    if (!rt().listen.empty() && rt().listen != "0.0.0.0") {
+        cmd += " --listen " + sh_quote(rt().listen);
+    }
+    if (rt().port != 0 && rt().port != 15665) {
+        cmd += " --port " + std::to_string(rt().port);
+    }
+    return "cd " + sh_quote(rt().cwd.empty() ? bin : rt().cwd) + " && " + cmd;
+}
+
+bool write_restart_script(const std::string& path) {
+    std::ofstream out(path, std::ios::trunc);
+    if (!out) {
+        return false;
+    }
+    const std::string pane = rt().tmux_pane;
+    const std::string sock = rt().tmux_socket;
+    const std::string tmux = sock.empty() ? "tmux" : ("tmux -S " + sh_quote(sock));
+    const std::string cmd = start_command();
+    const std::string work = rt().cwd.empty() ? binary_dir() : rt().cwd;
+    const std::string exe =
+        rt().exe.empty() ? join_path(binary_dir(), "ngs") : rt().exe;
+    out << "#!/bin/bash\n"
+        << "PID=" << rt().pid << "\n"
+        << "for i in $(seq 1 120); do\n"
+        << "  kill -0 \"$PID\" 2>/dev/null || break\n"
+        << "  sleep 0.25\n"
+        << "done\n"
+        << "if kill -0 \"$PID\" 2>/dev/null; then\n"
+        << "  kill -KILL \"$PID\" 2>/dev/null || true\n"
+        << "  sleep 0.4\n"
+        << "fi\n"
+        << "sleep 0.4\n";
+    if (rt().in_tmux && !pane.empty()) {
+        out << "if " << tmux << " list-panes -t " << sh_quote(pane)
+            << " >/dev/null 2>&1; then\n"
+            << "  " << tmux << " send-keys -t " << sh_quote(pane) << " C-u\n"
+            << "  " << tmux << " send-keys -t " << sh_quote(pane) << " "
+            << sh_quote(cmd) << " Enter\n"
+            << "  exit 0\n"
+            << "fi\n"
+            << "SESS=$(" << tmux << " display-message -p -t " << sh_quote(pane)
+            << " '#S' 2>/dev/null || true)\n"
+            << "if [ -n \"$SESS\" ]; then\n"
+            << "  " << tmux << " new-window -t \"$SESS\" -c " << sh_quote(work)
+            << " -- bash -lc " << sh_quote(cmd) << "\n"
+            << "  exit 0\n"
+            << "fi\n";
+    }
+    out << "cd " << sh_quote(work) << "\n"
+        << "nohup " << sh_quote(exe);
+    if (!rt().listen.empty() && rt().listen != "0.0.0.0") {
+        out << " --listen " << sh_quote(rt().listen);
+    }
+    if (rt().port != 0 && rt().port != 15665) {
+        out << " --port " << rt().port;
+    }
+    out << " >/dev/null 2>&1 &\n"
+        << "disown || true\n";
+    out.close();
+    ::chmod(path.c_str(), 0700);
+    return path_exists(path);
+}
+
+bool spawn_restart_helper() {
+    const std::string path = join_path(conf_root(), "ngs-self-restart.sh");
+    if (!write_restart_script(path)) {
+        return false;
+    }
+    const pid_t pid = fork();
+    if (pid < 0) {
+        return false;
+    }
+    if (pid == 0) {
+        setsid();
+        close_extra_fds();
+        execl("/bin/bash", "bash", path.c_str(), static_cast<char*>(nullptr));
+        _exit(127);
+    }
+    return true;
+}
+
+void fill_local(Info& out) {
+    out.source_dir = source_dir();
+    out.binary_dir = binary_dir();
+    out.in_tmux = rt().in_tmux;
+    out.tmux_pane = rt().tmux_pane;
+    out.has_repo = git_ok("rev-parse --is-inside-work-tree");
+    if (!out.has_repo) {
+        out.error = "源码目录不是 git 仓库";
+        return;
+    }
+    out.commit = git_one("rev-parse HEAD");
+    out.commit_short = git_one("rev-parse --short HEAD");
+    out.branch = git_one("rev-parse --abbrev-ref HEAD");
+    out.commit_date = git_one("log -1 --format=%ci");
+    out.subject = git_one("log -1 --format=%s");
+    out.dirty = !git_one("status --porcelain").empty();
+    out.remote = git_one("rev-parse --abbrev-ref '@{upstream}'");
+    out.has_remote = !out.remote.empty();
+    if (out.remote.size() > 6 && out.remote.compare(0, 7, "origin/") == 0) {
+        out.remote_url = git_one("remote get-url origin");
+    } else if (!out.remote.empty()) {
+        const auto slash = out.remote.find('/');
+        if (slash != std::string::npos) {
+            out.remote_url =
+                git_one("remote get-url " + out.remote.substr(0, slash));
+        }
+    }
+    if (out.has_remote) {
+        out.remote_commit = git_one("rev-parse '@{upstream}'");
+        out.remote_commit_short = git_one("rev-parse --short '@{upstream}'");
+        const std::string lr =
+            git_one("rev-list --left-right --count 'HEAD...@{upstream}'");
+        std::istringstream iss(lr);
+        iss >> out.ahead >> out.behind;
+        out.update_available = out.behind > 0;
+    }
+}
+
+}  // namespace
+
+void init(const std::string& listen_addr, uint16_t listen_port) {
+    rt().listen = listen_addr;
+    rt().port = listen_port;
+    rt().pid = ::getpid();
+    std::array<char, 4096> buf{};
+    if (::getcwd(buf.data(), buf.size())) {
+        rt().cwd = buf.data();
+    }
+    const ssize_t n = ::readlink("/proc/self/exe", buf.data(), buf.size() - 1);
+    if (n > 0) {
+        buf[static_cast<size_t>(n)] = '\0';
+        rt().exe = buf.data();
+    }
+    if (const char* pane = std::getenv("TMUX_PANE")) {
+        rt().tmux_pane = pane;
+    }
+    if (const char* tmux = std::getenv("TMUX")) {
+        rt().in_tmux = true;
+        const std::string v = tmux;
+        const auto comma = v.find(',');
+        rt().tmux_socket = comma == std::string::npos ? v : v.substr(0, comma);
+    }
+}
+
+bool inspect(Info& out, bool fetch, std::string* err) {
+    out = Info{};
+    if (fetch) {
+        if (!git_ok("rev-parse --is-inside-work-tree")) {
+            if (err) {
+                *err = "源码目录不是 git 仓库: " + source_dir();
+            }
+            out.error = "源码目录不是 git 仓库";
+            out.source_dir = source_dir();
+            return false;
+        }
+        std::string fetch_out;
+        const int rc =
+            capture("timeout 60 " + git_prefix() + "fetch --quiet origin",
+                    fetch_out);
+        if (rc != 0) {
+            fill_local(out);
+            std::string msg = trim_copy(fetch_out);
+            if (msg.empty()) {
+                msg = "git fetch 失败 (exit=" + std::to_string(rc) + ")";
+            }
+            out.error = msg;
+            if (err) {
+                *err = msg;
+            }
+            return false;
+        }
+    }
+    fill_local(out);
+    if (!out.has_repo) {
+        if (err) {
+            *err = out.error;
+        }
+        return false;
+    }
+    return true;
+}
+
+bool apply_update(std::string* err) {
+    Info info;
+    std::string e;
+    if (!inspect(info, false, &e)) {
+        if (err) {
+            *err = e.empty() ? "检查更新失败" : e;
+        }
+        return false;
+    }
+    log_info("selfupdate: fetching origin");
+    if (run_cmd("timeout 60 " + git_prefix() + "fetch origin", true) != 0) {
+        if (err) {
+            *err = "git fetch 失败";
+        }
+        return false;
+    }
+    if (!inspect(info, false, &e)) {
+        if (err) {
+            *err = e.empty() ? "读取版本失败" : e;
+        }
+        return false;
+    }
+    if (!info.update_available) {
+        log_info("selfupdate: already up to date commit=" + info.commit_short);
+        return true;
+    }
+
+    log_info("selfupdate: pulling " + info.remote + " behind=" +
+             std::to_string(info.behind));
+    if (run_cmd(git_prefix() + "pull --ff-only", true) != 0) {
+        if (err) {
+            *err = "git pull 失败(需要可快进合并)。如有本地改动请先处理后再更新。";
+        }
+        return false;
+    }
+
+    const std::string src = source_dir();
+    const std::string bdir = binary_dir();
+    if (!path_exists(join_path(bdir, "CMakeCache.txt")) &&
+        !path_exists(join_path(bdir, "Makefile"))) {
+        if (run_cmd("cmake -S " + sh_quote(src) + " -B " + sh_quote(bdir),
+                    true) != 0) {
+            if (err) {
+                *err = "cmake 配置失败";
+            }
+            return false;
+        }
+    }
+    unsigned jobs = std::thread::hardware_concurrency();
+    if (jobs < 1) {
+        jobs = 2;
+    }
+    if (run_cmd("cmake --build " + sh_quote(bdir) + " -j" + std::to_string(jobs),
+                true) != 0) {
+        if (err) {
+            *err = "编译失败";
+        }
+        return false;
+    }
+
+    if (!spawn_restart_helper()) {
+        if (err) {
+            *err = "无法安排重启脚本";
+        }
+        return false;
+    }
+    log_info(std::string("selfupdate: restart scheduled") +
+             (rt().in_tmux ? (" tmux_pane=" + rt().tmux_pane) : " daemon"));
+    std::thread([] {
+        std::this_thread::sleep_for(std::chrono::seconds(2));
+        std::raise(SIGTERM);
+    }).detach();
+    return true;
+}
+
+}  // namespace selfupdate
+}  // namespace ngs

+ 44 - 0
src/selfupdate/selfupdate.h

@@ -0,0 +1,44 @@
+#ifndef NGS_SELFUPDATE_H
+#define NGS_SELFUPDATE_H
+
+#include <cstdint>
+#include <string>
+
+namespace ngs {
+namespace selfupdate {
+
+struct Info {
+    std::string source_dir;
+    std::string binary_dir;
+    std::string branch;
+    std::string commit;
+    std::string commit_short;
+    std::string commit_date;
+    std::string subject;
+    std::string remote;
+    std::string remote_url;
+    std::string remote_commit;
+    std::string remote_commit_short;
+    int ahead = 0;
+    int behind = 0;
+    bool dirty = false;
+    bool has_repo = false;
+    bool has_remote = false;
+    bool update_available = false;
+    bool in_tmux = false;
+    std::string tmux_pane;
+    std::string error;
+};
+
+void init(const std::string& listen_addr, uint16_t listen_port);
+
+// Local git state. If fetch is true, talks to origin first (may take seconds).
+bool inspect(Info& out, bool fetch, std::string* err = nullptr);
+
+// git pull --ff-only, cmake build, then restart (same tmux pane when possible).
+bool apply_update(std::string* err = nullptr);
+
+}  // namespace selfupdate
+}  // namespace ngs
+
+#endif