启动器:接入 Gitea Releases 自动检查更新 + 更新日志页

- server.py: 新增 /api/update/check、/api/update/changelog 接口,
  用标准库 urllib 请求自托管仓库 Lingbao/90CNServer 的 Releases,
  带 5 分钟缓存与 8 秒超时;本地版本号写死 v2026.11.04
- shell.js: 版本卡片改为动态读取远程最新 tag,发现新版本绿色可点击
  跳转;新增 loadChangelog 渲染发布记录
- index.html/shell.css: 更新日志页改为动态容器,补充卡片样式
This commit is contained in:
hanfuit committed 2026-11-04 19:16:05 +08:00
1 parent d31a34e9aa
commit f2debaf9bf
4 files changed
+181 -3

No files matched your search

+1 -1
View File
@@ -202,7 +202,7 @@
</section> </section>
<section class="shell-page" id="page-changelog"> <section class="shell-page" id="page-changelog">
<div class="page-pad"><h2>更新日志</h2><p class="muted">启动器与工具集成记录。</p><div class="empty-box">2.1.0 · 单窗口整合启动器、GM、一键换装、服务端工具</div></div> <div class="page-pad"><h2>更新日志</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
</section> </section>
<section class="shell-page" id="page-gm"> <section class="shell-page" id="page-gm">
+93
View File
@@ -26,6 +26,7 @@ import uuid
import webbrowser import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.request import Request, urlopen
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
from db import DNFDatabase, set_gm_config from db import DNFDatabase, set_gm_config
@@ -40,6 +41,11 @@ DETACHED_PROCESS = 0x00000008
DEFAULT_PORT = 19527 # 启动器本地端口 DEFAULT_PORT = 19527 # 启动器本地端口
# 更新检查配置:Gitea 自托管仓库的 Releases 地址
UPDATE_REPO_API = "https://git.hanfuit.com/api/v1/repos/Lingbao/90CNServer/releases"
UPDATE_REPO_WEB = "https://git.hanfuit.com/Lingbao/90CNServer/releases"
CURRENT_VERSION = "v2026.11.04" # 本地当前版本号
def base_dir(): def base_dir():
"""程序所在目录(源码运行时 = 脚本目录,冻结运行时 = exe 目录)。""" """程序所在目录(源码运行时 = 脚本目录,冻结运行时 = exe 目录)。"""
@@ -274,6 +280,51 @@ def log(msg):
_logq.put(str(msg)) _logq.put(str(msg))
# ------------------------------------------------------------------ 更新检查
_update_cache = {"ts": 0.0, "releases": None, "error": None}
_update_lock = threading.Lock()
def _fetch_releases(force=False):
"""拉取 Gitea Releases 列表(带 5 分钟缓存)。"""
now = time.time()
with _update_lock:
if not force and _update_cache["ts"] and now - _update_cache["ts"] < 300:
return _update_cache["releases"], _update_cache["error"]
try:
req = Request(UPDATE_REPO_API, headers={
"User-Agent": "DNF90Launcher/2.0",
"Accept": "application/json",
})
with urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode("utf-8"))
releases = data if isinstance(data, list) else []
with _update_lock:
_update_cache.update(ts=now, releases=releases, error=None)
return releases, None
except Exception as e:
with _update_lock:
_update_cache.update(ts=now, releases=None, error=str(e))
return None, str(e)
def _release_summary(rel):
"""把一个 release 对象规整成前端需要的字段。"""
return {
"tag": rel.get("tag_name") or rel.get("name") or "",
"name": rel.get("name") or rel.get("tag_name") or "",
"body": rel.get("body") or "",
"published_at": rel.get("published_at") or "",
"prerelease": bool(rel.get("prerelease")),
"html_url": rel.get("html_url") or UPDATE_REPO_WEB,
"assets": [
{"name": a.get("name"), "size": a.get("size"),
"download_url": a.get("browser_download_url")}
for a in (rel.get("assets") or [])
],
}
def flow_start_game(account_username=None): def flow_start_game(account_username=None):
"""一键开始游戏;account_username 为空时使用默认账号。""" """一键开始游戏;account_username 为空时使用默认账号。"""
log("== 开始游戏 ==") log("== 开始游戏 ==")
@@ -515,6 +566,10 @@ class Handler(BaseHTTPRequestHandler):
self._api_logs() self._api_logs()
elif path == "/api/gm/health": elif path == "/api/gm/health":
self._api_gm_health() self._api_gm_health()
elif path == "/api/update/check":
self._api_update_check()
elif path == "/api/update/changelog":
self._api_update_changelog(qs)
elif path == "/api/accounts-config": elif path == "/api/accounts-config":
self._api_accounts_config() self._api_accounts_config()
# GM 只读接口 # GM 只读接口
@@ -685,6 +740,44 @@ class Handler(BaseHTTPRequestHandler):
"native_available": NATIVE is not None, "native_available": NATIVE is not None,
}) })
def _api_update_check(self):
"""检查最新发布版本(对比本地 CURRENT_VERSION)。"""
releases, err = _fetch_releases()
if err:
self._json({
"ok": True,
"current": CURRENT_VERSION,
"latest": None,
"has_update": False,
"error": err,
})
return
latest = releases[0] if releases else None
latest_tag = (latest.get("tag_name") or "").lstrip("v") if latest else ""
current = CURRENT_VERSION.lstrip("v")
self._json({
"ok": True,
"current": CURRENT_VERSION,
"latest": latest.get("tag_name") if latest else None,
"has_update": bool(latest_tag) and latest_tag != current,
"latest_url": latest.get("html_url") if latest else UPDATE_REPO_WEB,
"published_at": latest.get("published_at") if latest else None,
})
def _api_update_changelog(self, qs):
"""获取所有发布的更新日志。"""
force = qs.get("force", ["0"])[0] == "1"
releases, err = _fetch_releases(force=force)
if err:
self._json({"ok": False, "error": err, "releases": []})
return
self._json({
"ok": True,
"current": CURRENT_VERSION,
"releases": [_release_summary(r) for r in (releases or [])],
"web_url": UPDATE_REPO_WEB,
})
def _api_logs(self): def _api_logs(self):
logs = [] logs = []
while True: while True:
+14
View File
@@ -126,6 +126,20 @@ button,input,select{font:inherit}
.change-row b{font-weight:400;color:#bdd5c8;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .change-row b{font-weight:400;color:#bdd5c8;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.change-row em{font-style:normal;color:#5e7e6f;width:70px;text-align:right} .change-row em{font-style:normal;color:#5e7e6f;width:70px;text-align:right}
/* 更新日志(从 Gitea Releases 拉取) */
#changelogBody{margin-top:14px;display:flex;flex-direction:column;gap:12px}
.changelog-item{border:1px solid #1b3529;background:#0b1712;border-radius:8px;padding:14px 16px}
.changelog-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
.changelog-tag{font-family:Consolas,monospace;font-size:12px;color:#4c9c76;background:#12241c;padding:2px 8px;border-radius:4px;border:1px solid #1e3a2e}
.changelog-name{font-size:13px;font-weight:600;color:#d1e6dc}
.changelog-time{font-size:11px;color:#607f70;margin-left:auto}
.changelog-body{font-size:12px;color:#8eada0;line-height:1.9}
.changelog-assets{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.changelog-asset{font-size:11px;color:#5fc08d;text-decoration:none;border:1px solid #1e3a2e;padding:3px 10px;border-radius:4px;background:#0e1d17}
.changelog-asset:hover{background:#132a20}
.changelog-link{display:inline-block;margin-top:10px;font-size:11px;color:#5f8272;text-decoration:none}
.changelog-link:hover{color:#8fc9ae}
/* buttons/input */ /* buttons/input */
.shell-btn{background:#11271e;border:1px solid #2b5542;color:#b8d8c8;padding:6px 12px;cursor:pointer;border-radius:2px} .shell-btn{background:#11271e;border:1px solid #2b5542;color:#b8d8c8;padding:6px 12px;cursor:pointer;border-radius:2px}
.shell-btn:hover{background:#19392b;color:#fff} .shell-btn:hover{background:#19392b;color:#fff}
+73 -2
View File
@@ -25,6 +25,7 @@
if (page === 'companion') loadCompanion(); if (page === 'companion') loadCompanion();
if (page === 'servertool') loadServerTool(); if (page === 'servertool') loadServerTool();
if (page === 'settings') loadSettings(); if (page === 'settings') loadSettings();
if (page === 'changelog') loadChangelog();
if (page === 'gm') { if (page === 'gm') {
// 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区 // 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区
document.body.classList.add('gm-fullscreen'); document.body.classList.add('gm-fullscreen');
@@ -165,12 +166,11 @@
$('statApiPort').textContent = location.port || '19527'; $('statApiPort').textContent = location.port || '19527';
$('shellBottomPort').textContent = running ? '18111 / 7001' : '—'; $('shellBottomPort').textContent = running ? '18111 / 7001' : '—';
$('statState').textContent = s.control_exists && s.client_dir_exists ? '已就绪' : '不完整'; $('statState').textContent = s.control_exists && s.client_dir_exists ? '已就绪' : '不完整';
$('statVersion').textContent = 'v2026.11.03';
$('statRemote').textContent = '当前版本';
$('statClient').textContent = s.client_dir_exists ? (running ? '运行中' : '已就绪') : '缺失'; $('statClient').textContent = s.client_dir_exists ? (running ? '运行中' : '已就绪') : '缺失';
$('shellClock').textContent = '检查于 ' + new Date().toLocaleTimeString('zh-CN', {hour12:false}); $('shellClock').textContent = '检查于 ' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
$('statResource').textContent = s.admin ? '管理员' : '普通权限'; $('statResource').textContent = s.admin ? '管理员' : '普通权限';
loadAccountLabel(); loadAccountLabel();
loadUpdateStatus();
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
@@ -189,6 +189,77 @@
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
// 检查更新:填充「版本」卡片里的「本地 / 远程最新 / 状态」
async function loadUpdateStatus() {
const remoteEl = $('statRemote');
const versionEl = $('statVersion');
try {
const r = await api('/api/update/check');
if (versionEl) versionEl.textContent = r.current || '—';
if (!remoteEl) return;
if (r.error) {
remoteEl.textContent = '检查失败';
remoteEl.className = '';
remoteEl.title = '无法连接更新服务器:' + r.error;
} else if (r.has_update && r.latest) {
remoteEl.textContent = r.latest;
remoteEl.className = 'green';
remoteEl.title = '发现新版本,点击查看';
remoteEl.style.cursor = 'pointer';
remoteEl.onclick = () => { window.open(r.latest_url, '_blank'); };
} else if (r.latest) {
remoteEl.textContent = r.latest;
remoteEl.className = 'green';
remoteEl.title = '已是最新版本';
} else {
remoteEl.textContent = '暂无发布';
remoteEl.className = '';
}
} catch (e) {
if (remoteEl) { remoteEl.textContent = '检查失败'; remoteEl.className = ''; }
}
}
// 更新日志页面:拉取所有 Release 的 changelog
async function loadChangelog() {
const box = $('changelogBody');
if (!box) return;
box.innerHTML = '<div class="empty-box">正在获取更新日志…</div>';
try {
const r = await api('/api/update/changelog');
if (!r.ok) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
return;
}
const rels = r.releases || [];
if (!rels.length) {
box.innerHTML = '<div class="empty-box">暂无发布记录。<br>在 Gitea 仓库「发布 Release」并填写更新说明后,这里会自动显示。</div>';
return;
}
box.innerHTML = rels.map(rel => {
const badge = rel.prerelease ? '<span class="gm-tag gm-tag-gray">预发布</span>' : '';
const body = (rel.body || '').replace(/\r?\n/g, '<br>');
const assets = (rel.assets || []).map(a =>
`<a class="changelog-asset" href="${escapeHtml(a.download_url)}" target="_blank">⬇ ${escapeHtml(a.name)} (${fmt(a.size)} B)</a>`
).join('');
const when = rel.published_at ? rel.published_at.replace('T', ' ').slice(0, 16) : '';
return `<div class="changelog-item">
<div class="changelog-head">
<span class="changelog-tag">${escapeHtml(rel.tag || rel.name || '')}</span>
<span class="changelog-name">${escapeHtml(rel.name || '')}</span>
${badge}
<span class="changelog-time">${escapeHtml(when)}</span>
</div>
<div class="changelog-body">${body}</div>
${assets ? '<div class="changelog-assets">' + assets + '</div>' : ''}
<a class="changelog-link" href="${escapeHtml(rel.html_url)}" target="_blank">在浏览器中查看 ↗</a>
</div>`;
}).join('');
} catch (e) {
box.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
}
}
$('shellAccountSelect').addEventListener('change', async () => { $('shellAccountSelect').addEventListener('change', async () => {
const username = $('shellAccountSelect').value; const username = $('shellAccountSelect').value;
try { try {