- 更新日志
启动器与工具集成记录。
2.1.0 · 单窗口整合启动器、GM、一键换装、服务端工具
+ 更新日志
从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。
diff --git a/launcher/server.py b/launcher/server.py
index 207471e..82b6679 100644
--- a/launcher/server.py
+++ b/launcher/server.py
@@ -26,6 +26,7 @@ import uuid
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.request import Request, urlopen
from urllib.parse import urlparse, parse_qs
from db import DNFDatabase, set_gm_config
@@ -40,6 +41,11 @@ DETACHED_PROCESS = 0x00000008
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():
"""程序所在目录(源码运行时 = 脚本目录,冻结运行时 = exe 目录)。"""
@@ -274,6 +280,51 @@ def log(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):
"""一键开始游戏;account_username 为空时使用默认账号。"""
log("== 开始游戏 ==")
@@ -515,6 +566,10 @@ class Handler(BaseHTTPRequestHandler):
self._api_logs()
elif path == "/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":
self._api_accounts_config()
# GM 只读接口
@@ -685,6 +740,44 @@ class Handler(BaseHTTPRequestHandler):
"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):
logs = []
while True:
diff --git a/launcher/shell.css b/launcher/shell.css
index b0d0d95..15c7e68 100644
--- a/launcher/shell.css
+++ b/launcher/shell.css
@@ -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 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 */
.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}
diff --git a/launcher/shell.js b/launcher/shell.js
index 238b758..5e0a066 100644
--- a/launcher/shell.js
+++ b/launcher/shell.js
@@ -25,6 +25,7 @@
if (page === 'companion') loadCompanion();
if (page === 'servertool') loadServerTool();
if (page === 'settings') loadSettings();
+ if (page === 'changelog') loadChangelog();
if (page === 'gm') {
// 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区
document.body.classList.add('gm-fullscreen');
@@ -165,12 +166,11 @@
$('statApiPort').textContent = location.port || '19527';
$('shellBottomPort').textContent = running ? '18111 / 7001' : '—';
$('statState').textContent = s.control_exists && s.client_dir_exists ? '已就绪' : '不完整';
- $('statVersion').textContent = 'v2026.11.03';
- $('statRemote').textContent = '当前版本';
$('statClient').textContent = s.client_dir_exists ? (running ? '运行中' : '已就绪') : '缺失';
$('shellClock').textContent = '检查于 ' + new Date().toLocaleTimeString('zh-CN', {hour12:false});
$('statResource').textContent = s.admin ? '管理员' : '普通权限';
loadAccountLabel();
+ loadUpdateStatus();
} catch (e) { /* ignore */ }
}
@@ -189,6 +189,77 @@
} 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 = '正在获取更新日志…
';
+ try {
+ const r = await api('/api/update/changelog');
+ if (!r.ok) {
+ box.innerHTML = '获取失败:' + escapeHtml(r.error || '未知错误') + '
';
+ return;
+ }
+ const rels = r.releases || [];
+ if (!rels.length) {
+ box.innerHTML = '暂无发布记录。
在 Gitea 仓库「发布 Release」并填写更新说明后,这里会自动显示。
';
+ return;
+ }
+ box.innerHTML = rels.map(rel => {
+ const badge = rel.prerelease ? '预发布' : '';
+ const body = (rel.body || '').replace(/\r?\n/g, '
');
+ const assets = (rel.assets || []).map(a =>
+ `⬇ ${escapeHtml(a.name)} (${fmt(a.size)} B)`
+ ).join('');
+ const when = rel.published_at ? rel.published_at.replace('T', ' ').slice(0, 16) : '';
+ return `
+
+ ${escapeHtml(rel.tag || rel.name || '')}
+ ${escapeHtml(rel.name || '')}
+ ${badge}
+ ${escapeHtml(when)}
+
+
${body}
+ ${assets ? '
' + assets + '
' : ''}
+
在浏览器中查看 ↗
+
`;
+ }).join('');
+ } catch (e) {
+ box.innerHTML = '获取失败:' + escapeHtml(e && e.message ? e.message : e) + '
';
+ }
+ }
+
$('shellAccountSelect').addEventListener('change', async () => {
const username = $('shellAccountSelect').value;
try {