diff --git a/.changeset/daemon-resubscribe-logs.md b/.changeset/daemon-resubscribe-logs.md new file mode 100644 index 00000000..c8ba9b07 --- /dev/null +++ b/.changeset/daemon-resubscribe-logs.md @@ -0,0 +1,5 @@ +--- +"@sfmc-bds/cli": patch +--- + +修复平台升级或后台连接中断后,桌面状态查询恢复但实时日志停止的问题:重连时恢复日志与状态订阅,并复用并发请求的建连过程。日志订阅完成后才向桌面确认成功。 diff --git a/.changeset/desktop-0-1-2.md b/.changeset/desktop-0-1-2.md new file mode 100644 index 00000000..4913b3e5 --- /dev/null +++ b/.changeset/desktop-0-1-2.md @@ -0,0 +1,5 @@ +--- +"@sfmc-bds/desktop": patch +--- + +显示平台和桌面发行日志,并在关于入口提示桌面更新。抽屉避开系统标题栏,服务总览支持重启全部服务,修复任务通知进度条和任务时间线显示。 diff --git a/.changeset/desktop-update-release-notes.md b/.changeset/desktop-update-release-notes.md new file mode 100644 index 00000000..5c866948 --- /dev/null +++ b/.changeset/desktop-update-release-notes.md @@ -0,0 +1,6 @@ +--- +"@sfmc-bds/management": minor +"@sfmc-bds/cli": patch +--- + +平台更新检查与接入计划返回对应固定版本的发行日志,桌面更新页和确认框展示更新内容。桌面端的便携版、预览版也能查看发行日志;检测到软件更新时在关于入口显示红点。更新确认携带目标版本,防止执行时切换到未经确认的新版本。 diff --git a/README.md b/README.md index 445305e4..ab4d797d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ SFMC 希望通过模块化架构补充基岩版的原生开发体验。 ### 桌面版(Windows x64) -[下载桌面正式版](https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/desktop-v0.1.1) · [使用文档](./docs/zh/guide/desktop.md) +[下载桌面正式版](https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/desktop-v0.1.2) · [使用文档](./docs/zh/guide/desktop.md) SFMC Desktop 支持本机与 SSH 实例,提供服务启停、日志控制台、模块与资源包、配置编辑、玩家管理和运行指标。下载 `.exe` 安装,或完整解压 `.zip` 后启动;内置 Node.js、pnpm 与平台代码。 diff --git a/docs/releases/desktop.md b/docs/releases/desktop.md index cd830f45..41f098e3 100644 --- a/docs/releases/desktop.md +++ b/docs/releases/desktop.md @@ -1,6 +1,17 @@ -SFMC Desktop **0.1.1**,首个 Windows x64 正式版。 +SFMC Desktop **0.1.2**,Windows x64 正式版。 -支持本机与 SSH 实例、服务启停、控制台、模块与资源包、配置、玩家、维护任务和运行指标。内置平台 **0.2.9**、Node.js 与 pnpm。本机 Windows 与 SSH Windows 的数据服务/BDS、真实指标和守护进程重连已验收。 +内置平台 **0.2.11**、Node.js 与 pnpm。 + +### 本次更新 + +- 关于页显示桌面端更新日志;检测到新桌面版本时,侧栏关于入口显示红点。 +- 平台更新页与接入升级确认框显示目标版本的发行日志。执行时核对已确认的版本,版本变化后要求重新检查。 +- 服务总览右上角菜单增加“重启全部服务”。确认后按依赖顺序停止与启动托管服务。 +- 右侧抽屉避开系统标题栏,复制与关闭按钮完整显示。 +- 修复后台重连后实时日志订阅丢失,以及任务通知进度条、最近任务时间线的显示。 +- 保留 pnpm 真实路径解析修复,避免远程接入升级准备时找不到 CLI 依赖。 + +本发行使用 GitHub 构建证明和 SHA256 校验,Windows 程序未做 Authenticode 签名。客户端仍通过发行页手动下载安装更新。 ### 下载与使用 @@ -16,13 +27,13 @@ SFMC Desktop **0.1.1**,首个 Windows x64 正式版。 发行页下方公示各文件 SHA256,`SHA256SUMS.txt` 提供可下载的校验清单。下载后在 PowerShell 核对: ```powershell -Get-FileHash -LiteralPath '.\SFMC-Desktop-0.1.1-x64.exe' -Algorithm SHA256 +Get-FileHash -LiteralPath '.\SFMC-Desktop-0.1.2-x64.exe' -Algorithm SHA256 ``` 使用 GitHub CLI 验证构建证明及来源标签: ```powershell -gh attestation verify '.\SFMC-Desktop-0.1.1-x64.exe' --repo DogeLakeDev/ScriptsForMinecraftServer --signer-workflow DogeLakeDev/ScriptsForMinecraftServer/.github/workflows/desktop-release.yml --source-ref refs/tags/desktop-v0.1.1 --deny-self-hosted-runners +gh attestation verify '.\SFMC-Desktop-0.1.2-x64.exe' --repo DogeLakeDev/ScriptsForMinecraftServer --signer-workflow DogeLakeDev/ScriptsForMinecraftServer/.github/workflows/desktop-release.yml --source-ref refs/tags/desktop-v0.1.2 --deny-self-hosted-runners ``` ZIP 使用相同命令替换文件名即可。`artifact-attestation.sigstore.json` 是可下载的证明材料,`release-manifest.json` 记录源码提交、平台版本和内置运行时信息。 diff --git a/docs/zh/guide/desktop.md b/docs/zh/guide/desktop.md index 3af09f6d..62ef0571 100644 --- a/docs/zh/guide/desktop.md +++ b/docs/zh/guide/desktop.md @@ -2,14 +2,14 @@ SFMC Desktop 是 Windows x64 桌面客户端,可以管理本机 SFMC 部署,也可以通过 SSH 连接 Linux 或 Windows 服务器。服务启停、控制台、模块、资源包、配置、玩家与维护任务都集中在同一个工作区。 -当前提供 **0.1.1 正式版**,内置平台 `0.2.9`。客户端通过发行页手动下载安装更新。 +当前提供 **0.1.2 正式版**,内置平台 `0.2.11`。客户端通过发行页手动下载安装更新。 ## 下载与启动 -从 [桌面版发行页](https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/desktop-v0.1.1) 下载: +从 [桌面版发行页](https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/desktop-v0.1.2) 下载: -- `SFMC-Desktop-0.1.1-x64.exe`:安装向导,可以选择安装目录。 -- `SFMC-Desktop-0.1.1-x64.zip`:完整解压到一个目录,再运行 `SFMC Desktop.exe`。保留全部文件。 +- `SFMC-Desktop-0.1.2-x64.exe`:安装向导,可以选择安装目录。 +- `SFMC-Desktop-0.1.2-x64.zip`:完整解压到一个目录,再运行 `SFMC Desktop.exe`。保留全部文件。 - `SHA256SUMS.txt`:下载校验值;`release-manifest.json`:源代码提交与内置运行时版本。 - `artifact-attestation.sigstore.json`:GitHub Artifact Attestations 构建证明。 @@ -18,13 +18,13 @@ SFMC Desktop 是 Windows x64 桌面客户端,可以管理本机 SFMC 部署, 发行页同时公示 SHA256。下载后核对文件摘要: ```powershell -Get-FileHash -LiteralPath '.\SFMC-Desktop-0.1.1-x64.exe' -Algorithm SHA256 +Get-FileHash -LiteralPath '.\SFMC-Desktop-0.1.2-x64.exe' -Algorithm SHA256 ``` 安装 [GitHub CLI](https://cli.github.com/) 后,可核对构建证明、源码仓库、工作流和标签: ```powershell -gh attestation verify '.\SFMC-Desktop-0.1.1-x64.exe' --repo DogeLakeDev/ScriptsForMinecraftServer --signer-workflow DogeLakeDev/ScriptsForMinecraftServer/.github/workflows/desktop-release.yml --source-ref refs/tags/desktop-v0.1.1 --deny-self-hosted-runners +gh attestation verify '.\SFMC-Desktop-0.1.2-x64.exe' --repo DogeLakeDev/ScriptsForMinecraftServer --signer-workflow DogeLakeDev/ScriptsForMinecraftServer/.github/workflows/desktop-release.yml --source-ref refs/tags/desktop-v0.1.2 --deny-self-hosted-runners ``` 验证 ZIP 时替换文件名即可;添加 `--bundle '.\artifact-attestation.sigstore.json'` 可以验证下载的证明文件。需要固定到具体提交时,再加入 `--source-digest` 和 `release-manifest.json` 中的 `sourceCommit`。构建证明用于核对文件来源与摘要,不是 Windows 发布者证书。 diff --git a/packages/cli/src/daemon/client.ts b/packages/cli/src/daemon/client.ts index 9e98f8c5..44833c66 100644 --- a/packages/cli/src/daemon/client.ts +++ b/packages/cli/src/daemon/client.ts @@ -42,7 +42,7 @@ export function onDaemonManagementEvent(callback: (event: ManagementEvent) => vo remoteStateBus.on("management", callback); return () => { remoteStateBus.off("management", callback); }; } -export function disconnectDaemonClient(): void { shared?.disconnect(); shared = null; } +export function disconnectDaemonClient(): void { subscriptionCount = 0; shared?.disconnect(); shared = null; } /** 单次 RPC 默认超时(毫秒) */ const CALL_TIMEOUT_MS = 120_000; @@ -53,6 +53,10 @@ const READY_POLL_MS = 150; /** 共享连接(同一 CLI 进程复用) */ let shared: DaemonConnection | null = null; +/** 重连期间复用同一次建连,避免并行状态查询覆盖已订阅的连接。 */ +let connecting: Promise | null = null; +/** 订阅意图属于客户端会话,不能随着旧 socket 关闭而丢失。 */ +let subscriptionCount = 0; /** 本地状态事件总线:把 daemon 推送的 state 转给 CLI 侧 onServiceStateChange 订阅者 */ const remoteStateBus = new EventEmitter(); @@ -78,6 +82,7 @@ class DaemonConnection { { resolve: (r: DaemonResult) => void; reject: (e: Error) => void; timer: ReturnType } >(); private subscribed = false; + private subscribing: Promise | undefined; closed = false; constructor(sock: net.Socket, meta: DaemonMeta) { @@ -167,8 +172,12 @@ class DaemonConnection { /** 订阅日志与状态事件(同一连接上继续发 RPC) */ async subscribe(): Promise { if (this.subscribed) return; - await this.call("subscribe"); - this.subscribed = true; + if (!this.subscribing) { + this.subscribing = this.call("subscribe").then(() => { + this.subscribed = true; + }).finally(() => { this.subscribing = undefined; }); + } + await this.subscribing; } /** 断开连接(不停守护进程) */ @@ -254,6 +263,18 @@ function spawnDaemonProcess(): void { * 守护进程进程内调用会抛错(应直接走本地 cmd*)。 */ export async function ensureDaemon(): Promise { + if (isDaemonServer()) throw new Error("ensureDaemon must not run inside daemon server"); + let conn = shared && !shared.closed ? shared : null; + if (!conn) { + connecting ??= connectDaemon().finally(() => { connecting = null; }); + conn = await connecting; + } + // 管理请求重连成功后,先恢复日志/状态订阅,再返回新连接。 + if (subscriptionCount > 0) await conn.subscribe(); + return conn; +} + +async function connectDaemon(): Promise { if (isDaemonServer()) { throw new Error("ensureDaemon must not run inside daemon server"); } @@ -309,10 +330,15 @@ export async function callDaemon(method: DaemonMethod, params?: DaemonParams): P * 使用场景:REPL 启动时挂上实时日志流,退出时只 disconnect。 */ export async function ensureDaemonSubscription(): Promise<() => void> { - const conn = await ensureDaemon(); - await conn.subscribe(); + subscriptionCount++; + try { await ensureDaemon(); } + catch (error) { subscriptionCount = Math.max(0, subscriptionCount - 1); throw error; } + let disposed = false; return () => { - conn.disconnect(); + if (disposed) return; + disposed = true; + subscriptionCount = Math.max(0, subscriptionCount - 1); + if (subscriptionCount === 0) shared?.disconnect(); }; } diff --git a/packages/cli/src/management/maintenance.ts b/packages/cli/src/management/maintenance.ts index e04d9997..b768b34c 100644 --- a/packages/cli/src/management/maintenance.ts +++ b/packages/cli/src/management/maintenance.ts @@ -11,6 +11,7 @@ import { readJson, runProcess } from "./domain.js"; import type { AttachmentPlan } from "@sfmc-bds/management"; import { isDaemonServer } from "../daemon/role.js"; import { callDaemon } from "../daemon/client.js"; +import { platformReleaseNotes } from "@sfmc-bds/management/node"; async function runtimeRows() { if (isDaemonServer()) return queryServicesRuntimeLocal(); @@ -30,7 +31,7 @@ export function platformVersion(): string { try { return (require("@sfmc-bds/sfmc/package.json") as { version: string }).version; } catch { return (readJson<{ version: string }>(path.join(fileURLToPath(new URL("../../..", import.meta.url)), "package.json"), { version: "unknown" })).version; } } -export async function attachmentPlan(): Promise { +export async function attachmentPlan(includeReleaseNotes = true): Promise { const development = isMonorepoLayout(ROOT) || isSourceCheckout(); const currentVersion = platformVersion(); let targetVersion = currentVersion; @@ -39,15 +40,10 @@ export async function attachmentPlan(): Promise { if (!response.ok) throw new Error(`平台版本检查失败 (${response.status})`); const metadata = await response.json() as { version?: string }; if (!metadata.version || !/^\d+\.\d+\.\d+$/.test(metadata.version)) throw new Error("latest 没有指向有效的稳定平台版本"); - // 独立预览包可能比 npm latest 更新;接入不能把它降级到旧稳定版。 - const currentBase = /^(\d+)\.(\d+)\.(\d+)(?:-[\w.-]+)?$/.exec(currentVersion); - const latest = metadata.version.split(".").map(Number); - const current = currentBase?.slice(1).map(Number); - const difference = current && latest.map((value, index) => value - current[index]!).find(value => value !== 0); - if (!current || difference === undefined || difference >= 0) targetVersion = metadata.version; + targetVersion = metadata.version; } - const rows = await runtimeRows(); - return { currentVersion, targetVersion, development, upgradeRequired: !development && targetVersion !== currentVersion, externalServices: rows.filter(row => row.running && row.ownership === "external").map(row => row.name), steps: development ? ["保留源码工作区,连接当前开发部署"] : ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] }; + const [rows, releaseNotes] = await Promise.all([runtimeRows(), includeReleaseNotes && !development ? platformReleaseNotes(targetVersion) : undefined]); + return { currentVersion, targetVersion, development, upgradeRequired: !development && targetVersion !== currentVersion, externalServices: rows.filter(row => row.running && row.ownership === "external").map(row => row.name), steps: development ? ["保留源码工作区,连接当前开发部署"] : ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"], ...(releaseNotes ? { releaseNotes } : {}) }; } export async function stopForMaintenance(names: ServiceName[], phase: TaskContext["phase"]): Promise { const before = await queryServicesRuntimeLocal(); diff --git a/packages/cli/src/management/server.ts b/packages/cli/src/management/server.ts index 4f322e22..d3ddd284 100644 --- a/packages/cli/src/management/server.ts +++ b/packages/cli/src/management/server.ts @@ -158,7 +158,8 @@ export async function dispatchManagement(request: ManagementRequest): Promise withStoppedServices(context, [...START_ORDER], async () => savePlayerPermissions(string(p.kind, "权限类型"), p.entries))); case "attachment.plan": return attachmentPlan(); case "attachment.apply": return store.submit(request.method, async context => { - const plan = await attachmentPlan(); + const plan = await attachmentPlan(false); + if (p.targetVersion !== undefined && p.targetVersion !== plan.targetVersion) throw Object.assign(new Error("目标版本已变更,请重新检查更新日志后确认"), { code: "conflict" }); if (plan.externalServices.length) throw new Error("无法可靠迁移外部进程;请先通过原管理器优雅停服"); if (plan.upgradeRequired) return launchPlatformUpdate(store, context, plan.targetVersion); return { attached: true, version: plan.currentVersion, development: plan.development }; @@ -168,7 +169,7 @@ export async function dispatchManagement(request: ManagementRequest): Promise [["platform", "modules", "bds"][index], result.status === "fulfilled" ? result.value : { error: String(result.reason) }])); } case "updates.run": { - if (p.kind === "platform") return store.submit(request.method, async context => { const plan = await attachmentPlan(); return plan.upgradeRequired ? launchPlatformUpdate(store, context, plan.targetVersion) : { upToDate: true, version: plan.currentVersion }; }); + if (p.kind === "platform") return store.submit(request.method, async context => { const plan = await attachmentPlan(false); if (p.targetVersion !== undefined && p.targetVersion !== plan.targetVersion) throw Object.assign(new Error("目标版本已变更,请重新检查更新日志后确认"), { code: "conflict" }); return plan.upgradeRequired ? launchPlatformUpdate(store, context, plan.targetVersion) : { upToDate: true, version: plan.currentVersion }; }); if (p.kind === "modules") return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], async () => { const result = await applyModulePlan(Array.isArray(p.ids) ? p.ids.map(identifier) : []); await rebuildModules(); return result; }, true, "modules")); if (p.kind === "bds") return store.submit(request.method, context => withStoppedServices(context, [...START_ORDER], () => bdsUpdate(context), true, "bds")); throw new Error("未知更新类型"); diff --git a/packages/cli/src/management/stdio.ts b/packages/cli/src/management/stdio.ts index 5857cb6a..09b46f69 100644 --- a/packages/cli/src/management/stdio.ts +++ b/packages/cli/src/management/stdio.ts @@ -33,8 +33,8 @@ export async function runManagementStdio() { try { const result = await client.callDaemon("management", { request }); if (result.kind !== "management") throw new Error("旧守护进程没有管理入口"); + if (request.method === "events.subscribe" && result.response.ok && !dispose) dispose = await client.ensureDaemonSubscription(); output(JSON.stringify(result.response) + "\n"); - if (request.method === "events.subscribe" && !dispose) dispose = await client.ensureDaemonSubscription(); return; } catch (error) { if (request.method !== "handshake" || !/unknown method|管理入口/.test(error instanceof Error ? error.message : String(error))) throw error; diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index 3e13367b..d1ad285a 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -8,6 +8,7 @@ import { Session } from "./sessions.js"; import { profiles, profile, saveProfile, removeProfile, credentials, rememberSession } from "./profiles.js"; import type { AppearanceMode, Credentials, InstanceProfile } from "../shared/api.js"; import type { ManagementMethod } from "@sfmc-bds/management"; +import { desktopReleaseNotes } from "@sfmc-bds/management/node"; const sessions = new Map(); const changedHosts = new Map>(); @@ -90,28 +91,31 @@ void app.whenReady().then(async () => { handle("uploadPack", async id => { const session = sessions.get(String(id)); if (!session) throw new Error("实例尚未连接"); const result = await dialog.showOpenDialog(window, { properties: ["openFile"], filters: [{ name: "Minecraft 世界包", extensions: ["mcpack", "mcaddon", "zip"] }] }); if (result.canceled || !result.filePaths[0]) return null; return session.uploadPack(result.filePaths[0]); }); autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; autoUpdater.channel = "desktop"; autoUpdater.on("error", error => { if (!window.isDestroyed()) window.webContents.send("sfmc:connection", "", `客户端更新失败: ${error.message}`); }); - handle("openLink", kind => { + handle("openLink", (kind, target) => { + if (kind === "release") { + const url = new URL(String(target)); + if (url.origin !== "https://github.com" || !url.pathname.startsWith("/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/")) throw new Error("非法发行页面"); + return shell.openExternal(url.href); + } const urls = { eula: "https://www.minecraft.net/eula", "desktop-release": "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases" }; if (!(String(kind) in urls)) throw new Error("未知外部链接"); return shell.openExternal(urls[kind as keyof typeof urls]); }); handle("update", async action => { - if (app.getVersion().includes("-") || (app.isPackaged && releaseManifest().windowsCodeSigned !== true)) return { manual: true, downloadUrl: "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases" }; - if (app.isPackaged && !fs.existsSync(path.join(path.dirname(process.execPath), "installed.json"))) return { portable: true, downloadUrl: "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases" }; if (!app.isPackaged) return { development: true }; + const portable = !fs.existsSync(path.join(path.dirname(process.execPath), "installed.json")); + const preview = app.getVersion().includes("-"); + const manual = portable || preview || releaseManifest().windowsCodeSigned !== true; if (action === "check") { - let release: { tag_name: string; body: string } | undefined; - for (let page = 1; page <= 5 && !release; page++) { - const response = await fetch(`https://api.github.com/repos/DogeLakeDev/ScriptsForMinecraftServer/releases?per_page=100&page=${page}`, { signal: AbortSignal.timeout(20_000) }); - if (!response.ok) throw new Error(`桌面发行检查失败 (${response.status})`); - const rows = await response.json() as { tag_name: string; body: string; draft: boolean; prerelease: boolean }[]; - release = rows.find(row => !row.draft && !row.prerelease && /^desktop-v\d+\.\d+\.\d+$/.test(row.tag_name)); - if (rows.length < 100) break; - } - if (!release) throw new Error("尚无稳定桌面发行版"); - autoUpdater.setFeedURL({ provider: "generic", url: `https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/download/${release.tag_name}`, channel: "desktop" }); - const result = await autoUpdater.checkForUpdates(); return { ...result?.updateInfo, releaseNotes: release.body, available: Boolean(result?.isUpdateAvailable) }; + const notes = await desktopReleaseNotes(preview); + if (!notes) return { portable, manual: true, available: false, noRelease: true }; + const info = { portable, manual: manual || Boolean(notes.prerelease), available: autoUpdater.currentVersion.compare(notes.version) < 0, version: notes.version, releaseNotes: notes }; + if (info.manual || !info.available) return info; + const tag = decodeURIComponent(new URL(notes.url).pathname.split("/").at(-1)!); + autoUpdater.setFeedURL({ provider: "generic", url: `https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/download/${tag}`, channel: "desktop" }); + const result = await autoUpdater.checkForUpdates(); return { ...info, available: Boolean(result?.isUpdateAvailable) }; } + if (manual) return { portable, manual: true }; if (action === "download") return autoUpdater.downloadUpdate(); if (action === "install") { autoUpdater.quitAndInstall(); return { installing: true }; } throw new Error("未知更新操作"); diff --git a/packages/desktop/src/main/preload.ts b/packages/desktop/src/main/preload.ts index 37308d9c..6db0d478 100644 --- a/packages/desktop/src/main/preload.ts +++ b/packages/desktop/src/main/preload.ts @@ -11,7 +11,7 @@ const api: DesktopApi = { choose: kind => ipcRenderer.invoke("sfmc:choose", kind), uploadPack: id => ipcRenderer.invoke("sfmc:uploadPack", id), update: action => ipcRenderer.invoke("sfmc:update", action), - openLink: kind => ipcRenderer.invoke("sfmc:openLink", kind), + openLink: (kind, url) => ipcRenderer.invoke("sfmc:openLink", kind, url), appearance: mode => ipcRenderer.invoke("sfmc:appearance", mode), appInfo: () => ipcRenderer.invoke("sfmc:appInfo"), onEvent: callback => { const listener = (_event: unknown, id: string, event: Parameters[0]>[1]) => callback(id, event); ipcRenderer.on("sfmc:event", listener); return () => ipcRenderer.removeListener("sfmc:event", listener); }, diff --git a/packages/desktop/src/renderer/app/desktop-update.ts b/packages/desktop/src/renderer/app/desktop-update.ts new file mode 100644 index 00000000..8c1d6f46 --- /dev/null +++ b/packages/desktop/src/renderer/app/desktop-update.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { DesktopUpdateInfo } from "../../shared/api.js"; +import { errorText } from "../lib/format.js"; + +export interface DesktopUpdateState { info: DesktopUpdateInfo | undefined; checking: boolean; error: string; check: () => Promise } +/** 软件更新状态独立于实例连接;启动、每小时及打开关于时检查。 */ +export function useDesktopUpdate(): DesktopUpdateState { + const [info, setInfo] = useState(); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(""); + const pending = useRef | undefined>(undefined); + const check = useCallback(() => { + if (pending.current) return pending.current; + setChecking(true); setError(""); + const action = window.sfmc.update("check").then(value => setInfo(value as DesktopUpdateInfo)).catch(reason => setError(errorText(reason))).finally(() => { pending.current = undefined; setChecking(false); }); + pending.current = action; + return action; + }, []); + useEffect(() => { + void check(); + const timer = setInterval(() => void check(), 60 * 60_000); + return () => clearInterval(timer); + }, [check]); + return useMemo(() => ({ info, checking, error, check }), [info, checking, error, check]); +} diff --git a/packages/desktop/src/renderer/app/desktop.tsx b/packages/desktop/src/renderer/app/desktop.tsx index 5f6e4ff6..8ef0b792 100644 --- a/packages/desktop/src/renderer/app/desktop.tsx +++ b/packages/desktop/src/renderer/app/desktop.tsx @@ -22,6 +22,7 @@ import type { AppInfo, Credentials, InstanceProfile } from "../../shared/api.js" import { toast, useConfirm } from "../components/feedback.js"; import { errorText, isTaskDone, phaseLabel } from "../lib/format.js"; import type { PageKey } from "./nav.js"; +import { useDesktopUpdate, type DesktopUpdateState } from "./desktop-update.js"; /** 单个实例在客户端内的完整视图模型(与原实现字段一致,新增 connecting / connectError 供连接门展示) */ export interface Model { @@ -108,6 +109,7 @@ interface DesktopValue { saveProfile: (values: InstanceProfile & Credentials, editing: InstanceProfile | null) => Promise; removeProfile: (profile: InstanceProfile) => Promise; appInfo: AppInfo | undefined; + desktopUpdate: DesktopUpdateState; } /** 实例设置对话框状态:editing 为空表示新建 */ @@ -151,6 +153,7 @@ export function DesktopProvider({ children }: { children: ReactNode }) { const [flow, setFlow] = useState(null); const [profileDialog, setProfileDialog] = useState({ open: false, editing: null }); const [appInfo, setAppInfo] = useState(); + const desktopUpdate = useDesktopUpdate(); const modelsRef = useRef(models); modelsRef.current = models; const profilesRef = useRef(profiles); @@ -462,7 +465,7 @@ export function DesktopProvider({ children }: { children: ReactNode }) { const applyAttachment = useCallback( async (profile: InstanceProfile, plan: AttachmentPlan) => { - const accepted = await window.sfmc.request(profile.id, "attachment.apply"); + const accepted = await window.sfmc.request(profile.id, "attachment.apply", { targetVersion: plan.targetVersion }); if (plan.upgradeRequired) { toast.info("升级任务已提交", "完成后重新连接查看结果"); track(profile.id, accepted.operationId, `升级平台至 ${plan.targetVersion}`); @@ -544,8 +547,9 @@ export function DesktopProvider({ children }: { children: ReactNode }) { saveProfile, removeProfile, appInfo, + desktopUpdate, }), - [ready, profiles, current, setCurrent, selected, models, model, page, busyCount, guarded, patch, refresh, request, submit, editable, connect, disconnect, startAttach, createDeployment, applyAttachment, confirmHost, flow, profileDialog, saveProfile, removeProfile, appInfo] + [ready, profiles, current, setCurrent, selected, models, model, page, busyCount, guarded, patch, refresh, request, submit, editable, connect, disconnect, startAttach, createDeployment, applyAttachment, confirmHost, flow, profileDialog, saveProfile, removeProfile, appInfo, desktopUpdate] ); return {children}; } diff --git a/packages/desktop/src/renderer/components/ReleaseNotes.tsx b/packages/desktop/src/renderer/components/ReleaseNotes.tsx new file mode 100644 index 00000000..641f9342 --- /dev/null +++ b/packages/desktop/src/renderer/components/ReleaseNotes.tsx @@ -0,0 +1,55 @@ +import type { ReleaseNotes as ReleaseNotesInfo } from "@sfmc-bds/management"; +import { Fragment, type ReactNode } from "react"; +import { Button } from "./controls.js"; +import { fullTime } from "../lib/format.js"; + +/** 以 React 文本节点展示发行说明,不执行发行正文里的 HTML。 */ +function inline(text: string): ReactNode { + return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g).map((part, index) => { + if (part.startsWith("**") && part.endsWith("**")) return {part.slice(2, -2)}; + if (part.startsWith("`") && part.endsWith("`")) return {part.slice(1, -1)}; + const link = /^\[([^\]]+)\]\([^)]+\)$/.exec(part); + return {link ? link[1] : part}; + }); +} +function Body({ text }: { text: string }) { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const blocks: ReactNode[] = []; + for (let index = 0; index < lines.length;) { + const line = lines[index]!; + if (!line.trim()) { index++; continue; } + if (/^```/.test(line)) { + const code: string[] = []; index++; + while (index < lines.length && !/^```/.test(lines[index]!)) code.push(lines[index++]!); + index++; blocks.push(
{code.join("\n")}
); continue; + } + const heading = /^#{1,6}\s+(.+)$/.exec(line); + if (heading) { blocks.push(

{inline(heading[1]!)}

); index++; continue; } + const list = /^\s*(?:[-*]|\d+\.)\s+(.+)$/.exec(line); + if (list) { + const items: ReactNode[] = []; const ordered = /^\s*\d+\./.test(line); + const pattern = ordered ? /^\s*\d+\.\s+(.+)$/ : /^\s*[-*]\s+(.+)$/; + while (index < lines.length) { + const item = pattern.exec(lines[index]!); + if (!item) break; + items.push(
  • {inline(item[1]!)}
  • ); index++; + } + blocks.push(ordered ?
      {items}
    :
      {items}
    ); continue; + } + const paragraph = [line]; index++; + while (index < lines.length && lines[index]!.trim() && !/^(?:#{1,6}\s|\s*[-*]\s|\s*\d+\.\s|```)/.test(lines[index]!)) paragraph.push(lines[index++]!); + blocks.push(

    {inline(paragraph.join("\n"))}

    ); + } + return blocks; +} + +export function ReleaseNotes({ notes, version, collapsible = false, title = "更新日志" }: { notes?: ReleaseNotesInfo; version: string; collapsible?: boolean; title?: string }) { + const content = <> +
    v{version}{notes?.publishedAt && {fullTime(notes.publishedAt)}}
    +
    + {notes?.status === "available" ? :

    {notes?.status === "empty" ? "此版本暂无更新日志" : notes?.message || "暂未获取到此版本的更新日志"}

    } +
    + {notes && } + ; + return collapsible ?
    {title} v{version}{content}
    :

    {title}

    {content}
    ; +} diff --git a/packages/desktop/src/renderer/components/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar.tsx index a0373d5e..b73da7a2 100644 --- a/packages/desktop/src/renderer/components/Sidebar.tsx +++ b/packages/desktop/src/renderer/components/Sidebar.tsx @@ -60,7 +60,8 @@ function InstanceList({ collapsed }: { collapsed: boolean }) { /** 左侧栏 */ export function Sidebar({ collapsed, onToggleSidebar, onOpenAbout }: { collapsed: boolean; onToggleSidebar: () => void; onOpenAbout: () => void }) { - const { model, page, setPage } = useDesktop(); + const { model, page, setPage, desktopUpdate } = useDesktop(); + const hasDesktopUpdate = desktopUpdate.info?.available === true; const running = activeTasks(model).length; const groups = [...new Set(NAV.map((item) => item.group))]; const disabled = (item: NavItem) => Boolean(model.handshake && !model.handshake.capabilities.includes(item.capability)); @@ -97,9 +98,10 @@ export function Sidebar({ collapsed, onToggleSidebar, onOpenAbout }: { collapsed })} {group === "维护" && ( - )} diff --git a/packages/desktop/src/renderer/components/feedback.tsx b/packages/desktop/src/renderer/components/feedback.tsx index 25a053ea..38f8d20f 100644 --- a/packages/desktop/src/renderer/components/feedback.tsx +++ b/packages/desktop/src/renderer/components/feedback.tsx @@ -82,7 +82,7 @@ function ToastList() { {item.data?.meta &&
    {item.data.meta}
    } - {tone === "progress" && } + {tone === "progress" && } {item.actionProps && } diff --git a/packages/desktop/src/renderer/dev/mock-bridge.ts b/packages/desktop/src/renderer/dev/mock-bridge.ts index 8df408bf..1763f06f 100644 --- a/packages/desktop/src/renderer/dev/mock-bridge.ts +++ b/packages/desktop/src/renderer/dev/mock-bridge.ts @@ -250,13 +250,19 @@ export function installMockBridge() { return { operationId: record.id }; }; const maintenance = ["stop-services", "backup", "execute", "verify", "restore"]; + const platformNotes = { + version: "0.2.4", title: "SFMC 0.2.4", status: "available" as const, + body: "## 新增\n- 平台更新前可查看该版本的发行日志。\n\n## 修复\n- 重连后台时恢复实时日志订阅。", + url: "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/%40sfmc-bds%2Fsfmc%400.2.4", + publishedAt: "2026-10-03T03:00:00.000Z", + }; const handshake = (id: string): Handshake => ({ protocolVersion: 1, platformVersion: id === "local-main" ? "0.2.4" : "0.2.3", host: id === "local-main" ? { os: "windows", arch: "x64", release: "10.0.26200" } : { os: "linux", arch: "x64", release: "6.8.0-45-generic" }, root: profiles.find((row) => row.id === id)?.root ?? "", - capabilities: ["services", "logs", "modules", "config", "packs", "players", "updates", "operations", "metrics"], + capabilities: ["services", "logs", "modules", "config", "packs", "players", "updates", "operations"], daemonPid: id === "local-main" ? 15532 : 2231, daemonStartedAt: minutesAgo(id === "local-main" ? 372 : 4380), initialized: true, @@ -275,12 +281,6 @@ export function installMockBridge() { } }; switch (method) { - case "metrics.read": { - // 仅开发预览使用的演示采样;生产运行从管理协议读取真实指标。 - const now = Date.now(); - const history = Array.from({ length: 91 }, (_, index) => ({ recordedAt: now - (90 - index) * 10_000, bootId: "demo", tps: 19.7 + Math.sin(index / 8) * 0.25, onlineCount: 3, entities: { "minecraft:overworld": 286, "minecraft:nether": 42, "minecraft:the_end": 12 }, entitiesUpdatedAt: now, chunkEstimate: 768 })); - return { fresh: true, updatedAt: now, current: history.at(-1), history, host: { memory: { totalMb: 16384, usedMb: 6144, usedPercent: 37.5 }, cpu: { cores: 8, model: "演示处理器" } }, processes: { bds: { pid: 18244, running: true, memoryMb: 2048, cpuSeconds: 1856.4 }, db: { pid: 17728, running: true, memoryMb: 128, cpuSeconds: 42.1 } }, resourcesUpdatedAt: now }; - } case "services.list": return { rows: structuredClone(instance.services) }; case "operations.list": return { operations: structuredClone(instance.tasks) }; case "operations.get": return { operation: structuredClone(instance.tasks.find((row) => row.id === params.operationId)) }; @@ -290,8 +290,8 @@ export function installMockBridge() { case "logs.tail": return { entries: instance.logs.slice(-Number(params.limit ?? 1000)) }; case "attachment.plan": return id === "local-main" - ? { currentVersion: "0.2.4", targetVersion: "0.2.4", development: false, upgradeRequired: false, externalServices: ["llbot"], steps: ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] } - : { currentVersion: "0.2.3", targetVersion: "0.2.4", development: false, upgradeRequired: true, externalServices: [], steps: ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] }; + ? { currentVersion: "0.2.4", targetVersion: "0.2.4", releaseNotes: platformNotes, development: false, upgradeRequired: false, externalServices: ["llbot"], steps: ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] } + : { currentVersion: "0.2.3", targetVersion: "0.2.4", releaseNotes: platformNotes, development: false, upgradeRequired: true, externalServices: [], steps: ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] }; case "attachment.apply": return runTask(id, "attachment.apply", ["prepare", "verify"]); case "services.start": return runTask(id, method, ["execute", "verify"], () => setService(String(params.name), true)); case "services.stop": return runTask(id, method, ["execute"], () => setService(String(params.name), false)); @@ -347,8 +347,8 @@ export function installMockBridge() { await sleep(700); return { platform: id === "local-main" - ? { currentVersion: "0.2.4", targetVersion: "0.2.4", development: false, upgradeRequired: false, externalServices: ["llbot"], steps: [] } - : { currentVersion: "0.2.3", targetVersion: "0.2.4", development: false, upgradeRequired: true, externalServices: [], steps: ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] }, + ? { currentVersion: "0.2.4", targetVersion: "0.2.4", releaseNotes: platformNotes, development: false, upgradeRequired: false, externalServices: ["llbot"], steps: [] } + : { currentVersion: "0.2.3", targetVersion: "0.2.4", releaseNotes: platformNotes, development: false, upgradeRequired: true, externalServices: [], steps: ["下载固定版本的平台依赖组合", "停止受影响服务并确认进程退出", "备份配置、模块、数据库与世界", "切换平台并验证", "恢复此前运行的服务"] }, modules: { failMode: "continue", applyOnStart: false, upgrades: [{ id: "sfmc-module-land", fromVersion: "1.4.2", toVersion: "1.5.0", spec: "npm:@sfmc-bds/module-land@1.5.0", requires: [] }], skipped: [{ id: "sfmc-module-activity-log", reason: "dev-link", fromVersion: "0.2.0-dev", toVersion: null, detail: "" }, { id: "sfmc-module-economy", reason: "up-to-date", fromVersion: "2.0.1", toVersion: "2.0.1", detail: "" }] }, bds: { checked: true, updated: false, currentVersion: "1.26.51.01", latestVersion: "1.26.60.02", result: "check-only" }, }; @@ -391,7 +391,7 @@ export function installMockBridge() { request: (id, method, params) => request(id, method, (params ?? {}) as Record) as never, choose: async (kind) => (kind === "directory" ? "D:\\SFMC\\new-server" : "C:\\Users\\shiro\\.ssh\\id_ed25519"), uploadPack: async () => ({ filename: `${Date.now()}-Lucky_Blocks.mcaddon` }), - update: async () => ({ development: true }), + update: async () => ({ portable: true, manual: true, available: true, version: "0.1.1", releaseNotes: { version: "0.1.1", title: "SFMC Desktop 0.1.1", status: "available", url: "https://github.com/DogeLakeDev/ScriptsForMinecraftServer/releases/tag/desktop-v0.1.1", publishedAt: "2026-10-03T03:00:00Z", body: "### 新增\n\n- 平台与桌面端更新前展示发行日志。\n- 关于入口显示桌面更新提醒。\n\n### 修复\n\n- 后台重连后自动恢复实时日志订阅。" } }), openLink: async (kind) => { console.info("[mock] openLink", kind); }, appearance: async (mode: AppearanceMode) => { console.info("[mock] appearance", mode); }, appInfo: async () => ({ version: "0.1.0", platform: "win32", packaged: false }), diff --git a/packages/desktop/src/renderer/dialogs/AboutDialog.tsx b/packages/desktop/src/renderer/dialogs/AboutDialog.tsx index 7193344d..da8767f4 100644 --- a/packages/desktop/src/renderer/dialogs/AboutDialog.tsx +++ b/packages/desktop/src/renderer/dialogs/AboutDialog.tsx @@ -2,10 +2,10 @@ * AboutDialog.tsx — 关于与客户端更新 * * 使用场景:侧栏页脚"关于与更新"、命令面板"关于与检查更新"。 - * 打开时自动执行一次 update("check"): - * - 手动更新或便携版:提供发行页下载入口; + * 打开时自动执行一次 update("check"),三种形态的处理与原实现一致: + * - 便携版与预览版:展示桌面端更新日志,提供"打开发行页面"; * - 开发模式:不执行客户端安装升级; - * - 支持自动更新的安装版:下载完成后可"安装并重启客户端"。 + * - 安装版:有新版本时可"下载更新",下载完成后才可"安装并重启客户端"。 * 同时列出界面所用的设计资源与开源字体/图标/组件的许可声明。 */ import { useEffect, useState } from "react"; @@ -16,15 +16,7 @@ import { LogoMark } from "../components/icons.js"; import { Modal } from "../components/overlays.js"; import { Badge, Callout } from "../components/ui.js"; import { errorText } from "../lib/format.js"; - -/** update("check") 的返回形状(与原实现的收窄一致) */ -interface UpdateInfo { - manual?: boolean; - portable?: boolean; - development?: boolean; - available?: boolean; - releaseNotes?: string; -} +import { ReleaseNotes } from "../components/ReleaseNotes.js"; /** 第三方资源声明 */ const CREDITS: { name: string; by: string; license: string }[] = [ @@ -38,33 +30,23 @@ const CREDITS: { name: string; by: string; license: string }[] = [ ]; export function AboutDialog({ open, onClose }: { open: boolean; onClose: () => void }) { - const { appInfo } = useDesktop(); - const [info, setInfo] = useState(); - const [checking, setChecking] = useState(false); - const [checkError, setCheckError] = useState(""); + const { appInfo, desktopUpdate } = useDesktop(); + const { info, checking, error: checkError, check } = desktopUpdate; const [downloading, setDownloading] = useState(false); - const [downloaded, setDownloaded] = useState(false); + const [downloadedVersion, setDownloadedVersion] = useState(); + const downloaded = Boolean(info?.version && downloadedVersion === info.version); const [installing, setInstalling] = useState(false); - const check = () => { - setChecking(true); - setCheckError(""); - window.sfmc - .update("check") - .then((value) => setInfo(value as UpdateInfo)) - .catch((error) => setCheckError(errorText(error))) - .finally(() => setChecking(false)); - }; useEffect(() => { - if (open) check(); - }, [open]); + if (open) void check(); + }, [open, check]); const download = () => { setDownloading(true); window.sfmc .update("download") .then(() => { - setDownloaded(true); + setDownloadedVersion(info?.version); toast.success("更新已下载", "可安装并重启客户端"); }) .catch((error) => toast.error("下载更新失败", errorText(error))) @@ -92,67 +74,31 @@ export function AboutDialog({ open, onClose }: { open: boolean; onClose: () => v v{appInfo?.version ?? "—"} - {appInfo?.packaged ? "正式版" : "开发版"} + {appInfo?.packaged ? appInfo.version.includes("-") ? "预览版" : "正式版" : "开发版"} {appInfo?.platform ?? "—"}

    客户端更新

    - +
    {checking && !info ? ( ) : checkError ? ( - - {checkError} - - ) : info?.manual || info?.portable ? ( - <> -

    - 请从发行页下载安装包,或下载 ZIP 后退出客户端并替换应用文件。实例和后台继续运行。 -

    -
    - -
    - + {checkError} ) : info?.development ? (

    开发模式不执行客户端安装升级。

    ) : info ? ( <>

    - {info.available ? ( - - 有新的桌面版本 - - ) : ( - - 已是最新桌面版本 - - )} + {info.noRelease ? 暂无桌面发行版本 : info.available ? 有新的桌面版本 · {info.version} : 已是最新桌面版本}

    - {info.releaseNotes &&
    {info.releaseNotes}
    } + {!info.noRelease && }
    - - + {info.manual || info.portable || info.noRelease ? : <> + + + }
    ) : null} diff --git a/packages/desktop/src/renderer/dialogs/FlowDialogs.tsx b/packages/desktop/src/renderer/dialogs/FlowDialogs.tsx index 8bc7ea81..c68addb3 100644 --- a/packages/desktop/src/renderer/dialogs/FlowDialogs.tsx +++ b/packages/desktop/src/renderer/dialogs/FlowDialogs.tsx @@ -18,6 +18,7 @@ import { OperationSteps } from "../components/OperationSteps.js"; import { Modal } from "../components/overlays.js"; import { Callout } from "../components/ui.js"; import { errorText } from "../lib/format.js"; +import { ReleaseNotes } from "../components/ReleaseNotes.js"; /** SSH 登录:凭据仅用于本次连接(是否记住由实例设置中的选项决定) */ function LoginDialog({ flow }: { flow: Extract }) { @@ -185,6 +186,7 @@ function AttachDialog({ flow }: { flow: Extract }) { >
    + {plan.upgradeRequired && } {plan.steps.length > 0 && (
      {plan.steps.map((step, index) => ( @@ -201,13 +203,13 @@ function AttachDialog({ flow }: { flow: Extract }) { ); } -/** 版本对比:当前版本 → 目标版本 */ +/** 版本对比:当前版本 → 最新稳定版 */ function PlanVersions({ plan }: { plan: AttachmentPlan }) { return (
      当前版本{plan.currentVersion || "—"}
      -
      目标版本{plan.targetVersion}
      +
      最新稳定版{plan.targetVersion}
      ); } diff --git a/packages/desktop/src/renderer/dialogs/StartupDrawer.tsx b/packages/desktop/src/renderer/dialogs/StartupDrawer.tsx index c6431d99..67e98641 100644 --- a/packages/desktop/src/renderer/dialogs/StartupDrawer.tsx +++ b/packages/desktop/src/renderer/dialogs/StartupDrawer.tsx @@ -52,7 +52,6 @@ export function StartupDrawer({ open, onClose }: { open: boolean; onClose: () => {windows ? "在宿主机上以管理员身份打开 PowerShell,粘贴并运行此脚本,将 SFMC 注册为 Windows 系统服务。" : "在宿主机上以 root 运行此脚本,将 SFMC 注册为 systemd 服务。"} - 管理员权限不足时,将此脚本交给管理员安装。 {error && {error}} {!plan && !error &&
      } diff --git a/packages/desktop/src/renderer/pages/OverviewPage.tsx b/packages/desktop/src/renderer/pages/OverviewPage.tsx index 94f54d45..38691e68 100644 --- a/packages/desktop/src/renderer/pages/OverviewPage.tsx +++ b/packages/desktop/src/renderer/pages/OverviewPage.tsx @@ -159,6 +159,17 @@ export function OverviewPage() { } items={[ + { + key: "restart", + disabled: !editable, + icon: "restart", + label: "重启全部服务", + onSelect: () => + void submit("services.restart", { name: "all" }, "重启全部服务", { + okText: "重启", + description: "将按依赖顺序停止所有托管服务,再依次启动。玩家连接会暂时中断。", + }), + }, { key: "stop", danger: true, diff --git a/packages/desktop/src/renderer/pages/UpdatesPage.tsx b/packages/desktop/src/renderer/pages/UpdatesPage.tsx index 8453a658..4c3305f0 100644 --- a/packages/desktop/src/renderer/pages/UpdatesPage.tsx +++ b/packages/desktop/src/renderer/pages/UpdatesPage.tsx @@ -14,6 +14,7 @@ import { Icon, type IconName } from "../components/icons.js"; import { Tooltip } from "../components/tooltip.js"; import { Badge, CopyText, EmptyState, IconTile, PageHeader, Surface } from "../components/ui.js"; import { errorText, fullTime, relativeTime, taskLabel, type Tone } from "../lib/format.js"; +import { ReleaseNotes } from "../components/ReleaseNotes.js"; /** 检查失败时守护进程返回的形状 */ type Failed = { error: string }; @@ -106,7 +107,10 @@ export function UpdatesPage() { void request("backups.list").then((value) => setBackups(value.backups)).catch(() => {}); }, [model.tasks.length]); - const run = (kind: "platform" | "modules" | "bds", name: string) => void submit("updates.run", { kind }, `更新 ${name}`, { description: "将停服备份,更新后恢复运行。失败时回退程序;世界和数据库需从备份手动恢复。", okText: "开始更新" }); + const run = (kind: "platform" | "modules" | "bds", name: string) => { + const plan = kind === "platform" && result?.platform && !failed(result.platform) ? result.platform : undefined; + return void submit("updates.run", { kind, ...(plan ? { targetVersion: plan.targetVersion } : {}) }, `更新 ${name}`, { description:

      将停服备份,更新后恢复运行。失败时回退程序;世界和数据库需从备份手动恢复。

      {plan && }
      , okText: "开始更新" }); + }; const runButton = (kind: "platform" | "modules" | "bds", name: string, available: boolean, label: string) => (