Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/daemon-resubscribe-logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sfmc-bds/cli": patch
---

修复平台升级或后台连接中断后,桌面状态查询恢复但实时日志停止的问题:重连时恢复日志与状态订阅,并复用并发请求的建连过程。日志订阅完成后才向桌面确认成功。
5 changes: 5 additions & 0 deletions .changeset/desktop-0-1-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sfmc-bds/desktop": patch
---

显示平台和桌面发行日志,并在关于入口提示桌面更新。抽屉避开系统标题栏,服务总览支持重启全部服务,修复任务通知进度条和任务时间线显示。
6 changes: 6 additions & 0 deletions .changeset/desktop-update-release-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@sfmc-bds/management": minor
"@sfmc-bds/cli": patch
---

平台更新检查与接入计划返回对应固定版本的发行日志,桌面更新页和确认框展示更新内容。桌面端的便携版、预览版也能查看发行日志;检测到软件更新时在关于入口显示红点。更新确认携带目标版本,防止执行时切换到未经确认的新版本。
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 与平台代码。

Expand Down
19 changes: 15 additions & 4 deletions docs/releases/desktop.md
Original file line number Diff line number Diff line change
@@ -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 签名。客户端仍通过发行页手动下载安装更新。

### 下载与使用

Expand All @@ -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` 记录源码提交、平台版本和内置运行时信息。
Expand Down
12 changes: 6 additions & 6 deletions docs/zh/guide/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 构建证明。

Expand All @@ -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 发布者证书。
Expand Down
38 changes: 32 additions & 6 deletions packages/cli/src/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,6 +53,10 @@ const READY_POLL_MS = 150;

/** 共享连接(同一 CLI 进程复用) */
let shared: DaemonConnection | null = null;
/** 重连期间复用同一次建连,避免并行状态查询覆盖已订阅的连接。 */
let connecting: Promise<DaemonConnection> | null = null;
/** 订阅意图属于客户端会话,不能随着旧 socket 关闭而丢失。 */
let subscriptionCount = 0;

/** 本地状态事件总线:把 daemon 推送的 state 转给 CLI 侧 onServiceStateChange 订阅者 */
const remoteStateBus = new EventEmitter();
Expand All @@ -78,6 +82,7 @@ class DaemonConnection {
{ resolve: (r: DaemonResult) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
>();
private subscribed = false;
private subscribing: Promise<void> | undefined;
closed = false;

constructor(sock: net.Socket, meta: DaemonMeta) {
Expand Down Expand Up @@ -167,8 +172,12 @@ class DaemonConnection {
/** 订阅日志与状态事件(同一连接上继续发 RPC) */
async subscribe(): Promise<void> {
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;
}

/** 断开连接(不停守护进程) */
Expand Down Expand Up @@ -254,6 +263,18 @@ function spawnDaemonProcess(): void {
* 守护进程进程内调用会抛错(应直接走本地 cmd*)。
*/
export async function ensureDaemon(): Promise<DaemonConnection> {
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<DaemonConnection> {
if (isDaemonServer()) {
throw new Error("ensureDaemon must not run inside daemon server");
}
Expand Down Expand Up @@ -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();
};
}

Expand Down
14 changes: 5 additions & 9 deletions packages/cli/src/management/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<AttachmentPlan> {
export async function attachmentPlan(includeReleaseNotes = true): Promise<AttachmentPlan> {
const development = isMonorepoLayout(ROOT) || isSourceCheckout();
const currentVersion = platformVersion();
let targetVersion = currentVersion;
Expand All @@ -39,15 +40,10 @@ export async function attachmentPlan(): Promise<AttachmentPlan> {
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<ServiceName[]> {
const before = await queryServicesRuntimeLocal();
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/management/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ export async function dispatchManagement(request: ManagementRequest): Promise<un
case "players.apply": return store.submit(request.method, context => 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 };
Expand All @@ -168,7 +169,7 @@ export async function dispatchManagement(request: ManagementRequest): Promise<un
return Object.fromEntries(results.map((result, index) => [["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("未知更新类型");
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/management/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading