From 17dda4f8d6d4d56b3aec8d97fae2aec43405830d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=BE=B7=E6=A3=AE?= Date: Wed, 21 Oct 2026 22:50:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E6=98=BE=E7=A4=BA=E5=AE=98=E7=BD=91?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=8E=A5=E5=8F=A3=E7=9A=84=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/sync-website-release.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-website-release.yml b/.github/workflows/sync-website-release.yml index b843dea..4200a85 100644 --- a/.github/workflows/sync-website-release.yml +++ b/.github/workflows/sync-website-release.yml @@ -53,7 +53,11 @@ jobs: headers, _, body = result.stdout.partition("\n\n") if missing_ok and re.match(r"HTTP/\S+ 404(?:\s|$)", headers): return None - result.check_returncode() + if result.returncode: + request_method = method if payload is not None else "GET" + status = headers.splitlines()[0] if headers else "未收到 HTTP 响应" + detail = body.strip() or result.stderr.strip() or "GitHub 未返回错误详情" + raise RuntimeError(f"GitHub API {request_method} {path} 失败:{status}\n{detail}") return json.loads(body) tag_pattern = r"desktop-v((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))" From dc2b6f532bd72ab696877ad214cc26fac81d47aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=BE=B7=E6=A3=AE?= Date: Thu, 22 Oct 2026 17:17:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E6=A8=A1=E7=BB=84?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E6=AF=94=E4=BE=8B=E5=B9=B6=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=A4=A7=E5=9B=BE=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/renderer/src/features/modules/ModuleCardList.tsx | 4 ++-- .../renderer/src/features/sessions/HistoryAdventureList.tsx | 2 +- .../src/renderer/src/features/sessions/ReadyLobby.tsx | 2 +- .../desktop/src/renderer/src/features/sessions/RoomList.tsx | 6 +++--- "docs/\347\225\214\351\235\242\350\256\276\350\256\241.md" | 2 ++ 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/src/features/modules/ModuleCardList.tsx b/apps/desktop/src/renderer/src/features/modules/ModuleCardList.tsx index f6fdff1..24ebad5 100644 --- a/apps/desktop/src/renderer/src/features/modules/ModuleCardList.tsx +++ b/apps/desktop/src/renderer/src/features/modules/ModuleCardList.tsx @@ -84,7 +84,7 @@ function ModuleCard({ module, opening }: { module: ModuleSummary; opening: boole aria-busy={opening} className={`group relative flex h-full flex-col transition-opacity ${opening ? "opacity-60" : ""}`} > -
+
{Array.from({ length: 3 }, (_, index) => (
-
+
))} diff --git a/apps/desktop/src/renderer/src/features/sessions/HistoryAdventureList.tsx b/apps/desktop/src/renderer/src/features/sessions/HistoryAdventureList.tsx index 39fd015..4dc035f 100644 --- a/apps/desktop/src/renderer/src/features/sessions/HistoryAdventureList.tsx +++ b/apps/desktop/src/renderer/src/features/sessions/HistoryAdventureList.tsx @@ -100,7 +100,7 @@ function HistoryAdventureRow({

diff --git a/apps/desktop/src/renderer/src/features/sessions/ReadyLobby.tsx b/apps/desktop/src/renderer/src/features/sessions/ReadyLobby.tsx index 013610d..5801d00 100644 --- a/apps/desktop/src/renderer/src/features/sessions/ReadyLobby.tsx +++ b/apps/desktop/src/renderer/src/features/sessions/ReadyLobby.tsx @@ -50,7 +50,7 @@ export function ReadyLobby({ className={`relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto ${styles.stage}`} aria-label="准备开始" > -
+

{room.moduleName} @@ -65,7 +65,7 @@ function FeaturedAdventure({

{room.name}

- + 继续冒险 @@ -92,7 +92,7 @@ function AdventureCard({

diff --git "a/docs/\347\225\214\351\235\242\350\256\276\350\256\241.md" "b/docs/\347\225\214\351\235\242\350\256\276\350\256\241.md" index e583c43..0239dee 100644 --- "a/docs/\347\225\214\351\235\242\350\256\276\350\256\241.md" +++ "b/docs/\347\225\214\351\235\242\350\256\276\350\256\241.md" @@ -20,6 +20,8 @@ ## 色彩与控件形态 +模组封面素材采用横向 16:9,模组列表与冒险缩略图保持此比例。首页冒险主视觉使用 240px 高的横幅,准备大厅封面高度随视口变化并限制在 180–320px,避免全宽图片撑高页面。图片居中等比填满容器,超出部分裁切;加载占位、缺图与加载失败时的回退保留容器尺寸。 + 亮暗主题共用 `styles.css` 中的语义令牌。背景、面板、边框和主操作使用黑白灰,通过明度区分层级;危险操作保留红色,文档链接保留独立颜色。新增基础组件使用这些令牌,不单独写一套亮暗色值。 主按钮、次按钮和描边按钮使用胶囊形态,图标按钮为圆形;普通文字工具按钮采用小圆角。普通单行和多行输入统一使用 8px 小圆角、浅灰填充与内边距,默认隐藏边框。只有聊天 composer 的输入容器使用 16px 大圆角,内部文本区域保持透明,不额外形成圆角输入框。键盘聚焦时只显示细边界,不使用外扩光圈;错误状态保留红色边界。按钮键盘聚焦使用带间距的轮廓,禁用态不能只靠颜色差异表达。 From 983e528083796c6c77e3bf7cf45b261cd5017083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=BE=B7=E6=A3=AE?= Date: Thu, 22 Oct 2026 18:02:00 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=AE=98?= =?UTF-8?q?=E7=BD=91=E4=B8=80=E9=94=AE=E5=AF=BC=E5=85=A5=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20Git=20=E6=9C=80=E6=96=B0=E7=89=88=E6=9C=AC=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/electrobun.config.ts | 1 + apps/desktop/scripts/desktop.ts | 21 ++- .../desktop/src/main/desktop-instance.test.ts | 36 +++- apps/desktop/src/main/desktop-instance.ts | 22 ++- apps/desktop/src/main/desktop-rpc-context.ts | 2 + apps/desktop/src/main/desktop.ts | 85 +++++++-- apps/desktop/src/main/import-protocol.ts | 29 ++++ apps/desktop/src/main/index.ts | 44 ++--- .../src/main/modules/import-git-module.ts | 107 ++++++++++-- .../src/main/modules/import-service.test.ts | 111 ++++++++++++ .../src/main/modules/import-service.ts | 119 +++++++++++++ .../src/main/modules/list-modules.test.ts | 138 +++++++++++++-- .../desktop/src/main/modules/module-import.ts | 29 +++- .../desktop/src/main/modules/module-router.ts | 43 +++-- .../features/modules/ImportModuleButton.tsx | 31 ++-- .../features/modules/ModuleImportNotice.tsx | 161 ++++++++++++++++++ apps/desktop/src/renderer/src/router.tsx | 2 + apps/desktop/src/shared/module-import.ts | 76 +++++++++ ...07\346\241\243\345\256\236\347\216\260.md" | 12 +- 19 files changed, 940 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/main/import-protocol.ts create mode 100644 apps/desktop/src/main/modules/import-service.test.ts create mode 100644 apps/desktop/src/main/modules/import-service.ts create mode 100644 apps/desktop/src/renderer/src/features/modules/ModuleImportNotice.tsx create mode 100644 apps/desktop/src/shared/module-import.ts diff --git a/apps/desktop/electrobun.config.ts b/apps/desktop/electrobun.config.ts index 6a841a2..b3d7f3b 100644 --- a/apps/desktop/electrobun.config.ts +++ b/apps/desktop/electrobun.config.ts @@ -9,6 +9,7 @@ export default { identifier: appId, version: manifest.version, description: manifest.description, + urlSchemes: process.env.TARP_BUILD_CHANNEL === "stable" ? ["tarp"] : [], }, release: { baseUrl: "https://github.com/joinTARP/TARP/releases/latest/download" }, build: { diff --git a/apps/desktop/scripts/desktop.ts b/apps/desktop/scripts/desktop.ts index 6dc9804..968c56c 100644 --- a/apps/desktop/scripts/desktop.ts +++ b/apps/desktop/scripts/desktop.ts @@ -94,13 +94,16 @@ if (mode === "dev") { vite.kill(); } } else { - await run([ - "bun", - "--bun", - "x", - "--no-install", - "electrobun", - "build", - `--env=${mode === "dist" ? "stable" : "dev"}`, - ]); + await run( + [ + "bun", + "--bun", + "x", + "--no-install", + "electrobun", + "build", + `--env=${mode === "dist" ? "stable" : "dev"}`, + ], + { ...process.env, TARP_BUILD_CHANNEL: mode === "dist" ? "stable" : "dev" }, + ); } diff --git a/apps/desktop/src/main/desktop-instance.test.ts b/apps/desktop/src/main/desktop-instance.test.ts index 76a66e7..2b21013 100644 --- a/apps/desktop/src/main/desktop-instance.test.ts +++ b/apps/desktop/src/main/desktop-instance.test.ts @@ -1,29 +1,53 @@ import { test, onTestFinished } from "bun:test"; import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; +import { connect } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { acquireDesktopInstance } from "./desktop-instance"; +import { acquireDesktopInstance, type DesktopActivation } from "./desktop-instance"; test("重复启动只唤醒原实例并传递预览路径,关闭后允许重新启动", async () => { const directory = await mkdtemp(join(tmpdir(), "tarp-instance-")); onTestFinished(() => rm(directory, { recursive: true, force: true })); - const opened: (string | undefined)[] = []; - const first = await acquireDesktopInstance(directory, undefined, (path) => opened.push(path)); + const opened: DesktopActivation[] = []; + const first = await acquireDesktopInstance(directory, { kind: "activate" }, (request) => + opened.push(request), + ); assert.ok(first); try { assert.equal( - await acquireDesktopInstance(directory, "/tmp/中文 ?# 模组", () => + await acquireDesktopInstance(directory, { kind: "preview", path: "/tmp/中文 ?# 模组" }, () => assert.fail("不应创建第二个宿主"), ), null, ); - assert.deepEqual(opened, ["/tmp/中文 ?# 模组"]); + const request = { kind: "import" as const, url: "tarp://import?repository=author%2Frepo" }; + assert.equal( + await acquireDesktopInstance(directory, request, () => assert.fail("不应创建第二实例")), + null, + ); + assert.deepEqual(opened, [{ kind: "preview", path: "/tmp/中文 ?# 模组" }, request]); + const identifier = createHash("sha256").update(directory).digest("hex").slice(0, 32); + const endpoint = + process.platform === "win32" + ? `\\\\.\\pipe\\tarp-${identifier}` + : Buffer.byteLength(join(directory, "desktop.sock")) < 104 + ? join(directory, "desktop.sock") + : join(tmpdir(), `tarp-${identifier.slice(0, 16)}.sock`); + await new Promise((resolve, reject) => { + const client = connect(endpoint); + client.on("error", reject); + client.on("connect", () => client.end('{"kind":"import","url":42}\n')); + client.on("data", () => reject(new Error("畸形消息不应获得成功确认"))); + client.on("close", () => resolve()); + }); + assert.equal(opened.length, 2); } finally { await first.close(); } - const reopened = await acquireDesktopInstance(directory, undefined, () => {}); + const reopened = await acquireDesktopInstance(directory, { kind: "activate" }, () => {}); assert.ok(reopened); await reopened.close(); }); diff --git a/apps/desktop/src/main/desktop-instance.ts b/apps/desktop/src/main/desktop-instance.ts index 8a6d906..b7c4eea 100644 --- a/apps/desktop/src/main/desktop-instance.ts +++ b/apps/desktop/src/main/desktop-instance.ts @@ -4,11 +4,20 @@ import { connect, createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; -/** 配置目录隔离实例;第二次启动仅唤醒现有窗口,模组预览路径仍交给原宿主。 */ +import { z } from "zod"; + +const activationSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("activate") }), + z.strictObject({ kind: z.literal("preview"), path: z.string().min(1).max(8192) }), + z.strictObject({ kind: z.literal("import"), url: z.string().min(1).max(2048) }), +]); +export type DesktopActivation = z.infer; + +/** 配置目录隔离实例;第二次启动将唤醒、预览或导入请求交给原宿主。 */ export async function acquireDesktopInstance( directory: string, - modulePath: string | undefined, - activate: (path?: string) => void, + activation: DesktopActivation, + activate: (request: DesktopActivation) => void, ) { await mkdir(directory, { recursive: true, mode: 0o700 }); const identifier = createHash("sha256").update(directory).digest("hex").slice(0, 32); @@ -24,7 +33,7 @@ export async function acquireDesktopInstance( const client = connect(endpoint); client.setTimeout(3000); client.once("connect", () => { - client.end(`${JSON.stringify({ modulePath })}\n`); + client.end(`${JSON.stringify(activation)}\n`); }); client.once("data", (data) => { client.destroy(); @@ -52,9 +61,8 @@ export async function acquireDesktopInstance( } if (!input.includes("\n")) return; try { - const value = JSON.parse(input.trim()); - const path = typeof value.modulePath === "string" ? value.modulePath : undefined; - activate(path); + const value = activationSchema.parse(JSON.parse(input.trim())); + activate(value); socket.end("ok"); } catch { socket.destroy(); diff --git a/apps/desktop/src/main/desktop-rpc-context.ts b/apps/desktop/src/main/desktop-rpc-context.ts index 390761c..f674aa7 100644 --- a/apps/desktop/src/main/desktop-rpc-context.ts +++ b/apps/desktop/src/main/desktop-rpc-context.ts @@ -7,11 +7,13 @@ import type { LocalGameSettings } from "./game/local-settings"; import type { RoomAiSettingsStore } from "./game/room-ai-settings"; import type { SubscriptionAuth } from "./game/subscription-auth"; import type { McpService } from "./mcp/service"; +import type { ModuleImportService } from "./modules/import-service"; import type { LocalSessionLifecycle } from "./sessions/local-session-lifecycle"; import type { TtsService } from "./tts/service"; export interface DesktopRpcContext extends GameContext { readonly updates: AppUpdateService; + readonly moduleImports: ModuleImportService; readonly mcp: McpService; readonly tts: TtsService; readonly configFilePath: string; diff --git a/apps/desktop/src/main/desktop.ts b/apps/desktop/src/main/desktop.ts index feede81..3a23fdd 100644 --- a/apps/desktop/src/main/desktop.ts +++ b/apps/desktop/src/main/desktop.ts @@ -3,8 +3,9 @@ import { isAbsolute, join } from "node:path"; import { ChatService, createPiRuntime } from "@jointarp/core/node"; import { narrationGapMs } from "@jointarp/core/shared/narration"; import { modulePreviewHash } from "@jointarp/module-cli/commands"; +import { parseModuleImportLink } from "@shared/module-import"; import { NativeMessagePort, type NativeBridge } from "@shared/native-bridge"; -import Electrobun, { BrowserView, Updater, Utils } from "electrobun/main"; +import Electrobun, { BrowserView, BuildConfig, Updater, Utils } from "electrobun/main"; import manifest from "../../package.json"; import { AppUpdateService, createAcceleratedUpdater } from "./app-update"; @@ -15,9 +16,12 @@ import { createDesktopRpcHandler } from "./desktop-rpc"; import { LocalGameSettings } from "./game/local-settings"; import { RoomAiSettingsStore } from "./game/room-ai-settings"; import { SubscriptionAuth } from "./game/subscription-auth"; +import { registerImportProtocol } from "./import-protocol"; import { PlaytestService } from "./mcp/playtest"; import { McpService } from "./mcp/service"; import { createMediaServer } from "./media-protocol"; +import { ModuleImportService } from "./modules/import-service"; +import { readModulesDirectory } from "./modules/module-router"; import { configureDesktopWindows, createDesktopWindow, @@ -38,7 +42,15 @@ import { LocalSessionLifecycle } from "./sessions/local-session-lifecycle"; import { TtsService } from "./tts/service"; import { openHttpLink } from "./window-security"; -export async function startDesktop(modulePath?: string): Promise { +// 尽早订阅原生事件,初始化期间的请求留给完成启动的宿主。 +const earlyImportLinks: string[] = []; +let importLinkHandler: ((url: string) => void) | undefined; +Electrobun.events.on("open-url", (event) => { + if (importLinkHandler) importLinkHandler(event.data.url); + else if (earlyImportLinks.length < 2) earlyImportLinks.push(event.data.url); +}); + +export async function startDesktop(modulePath?: string, importUrl?: string): Promise { let mainWindow: DesktopWindow | null = null; let shuttingDown = false; let shutdownComplete = false; @@ -47,20 +59,60 @@ export async function startDesktop(modulePath?: string): Promise { const configLocation = resolveAppConfigLocation(); let pendingPreview = modulePath; let initialized = false; - const instance = await acquireDesktopInstance(configLocation.directory, modulePath, (path) => { - if (path && isAbsolute(path)) pendingPreview = path; - if (!initialized) return; - if (!isWindowOpen(mainWindow)) mainWindow = createMainWindow(); - else { - if (path && isAbsolute(path)) loadWindow(mainWindow, modulePreviewHash(path)); - showWindow(mainWindow); - } - }); + const pendingImports = [ + ...new Set([importUrl, ...earlyImportLinks].filter((url): url is string => Boolean(url))), + ]; + earlyImportLinks.length = 0; + const firstImportUrl = pendingImports[0]; + const instance = await acquireDesktopInstance( + configLocation.directory, + firstImportUrl + ? { kind: "import", url: firstImportUrl } + : modulePath + ? { kind: "preview", path: modulePath } + : { kind: "activate" }, + (request) => { + if (request.kind === "import") { + if (!initialized) { + if (pendingImports.length < 2) pendingImports.push(request.url); + } else receiveImport(request.url); + return; + } + const path = request.kind === "preview" ? request.path : undefined; + if (path && isAbsolute(path)) pendingPreview = path; + if (!initialized) return; + if (!isWindowOpen(mainWindow)) mainWindow = createMainWindow(); + else { + if (path && isAbsolute(path)) loadWindow(mainWindow, modulePreviewHash(path)); + showWindow(mainWindow); + } + }, + ); if (!instance) { process.exit(0); } await readAppConfig(configLocation.filePath); + const moduleImports = new ModuleImportService( + async () => (await readModulesDirectory(configLocation.filePath)).path, + ); + function receiveImport(url: string) { + if (shuttingDown) return; + if (!initialized) { + if (pendingImports.length < 2) pendingImports.push(url); + return; + } + if (!isWindowOpen(mainWindow)) mainWindow = createMainWindow(); + else showWindow(mainWindow); + try { + moduleImports.start(parseModuleImportLink(url)); + } catch (error) { + moduleImports.report(error); + } + } + importLinkHandler = receiveImport; + // 实例锁初始化时到达的原生事件也必须交给当前任务服务。 + pendingImports.push(...earlyImportLinks.splice(0)); const media = createMediaServer(); const gameSettings = new LocalGameSettings(configLocation.directory); const subscriptionAuth = new SubscriptionAuth( @@ -159,6 +211,7 @@ export async function startDesktop(modulePath?: string): Promise { chat, mcp, updates, + moduleImports, isHost: true, player: await gameSettings.player(), }), @@ -242,6 +295,16 @@ export async function startDesktop(modulePath?: string): Promise { }); initialized = true; mainWindow = createMainWindow(); + for (const url of pendingImports) receiveImport(url); + void registerImportProtocol(BuildConfig.getSync().channel).catch((error) => { + console.error("注册官网导入协议失败", error); + void Utils.showMessageBox({ + type: "error", + title: "无法启用官网一键导入", + message: "你仍可在模组库中手动导入。请重新启动应用后重试。", + detail: error instanceof Error ? error.message : String(error), + }); + }); await mcp.initialize(); void updates.check(); } diff --git a/apps/desktop/src/main/import-protocol.ts b/apps/desktop/src/main/import-protocol.ts new file mode 100644 index 0000000..309ef19 --- /dev/null +++ b/apps/desktop/src/main/import-protocol.ts @@ -0,0 +1,29 @@ +import { execFile } from "node:child_process"; +import { access } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +export function windowsProtocolCommand(launcher: string): string { + if (/["\r\n\u0000]/u.test(launcher)) throw new Error("应用启动路径无效。"); + return `"${launcher}" "%1"`; +} + +/** 仅由正式版 GUI 调用;首次运行与升级后均修正用户级注册。 */ +export async function registerImportProtocol(channel: string): Promise { + if (channel !== "stable" || process.platform !== "win32") return; + const launcher = join(dirname(process.execPath), "launcher.exe"); + await access(launcher); + const key = "HKCU\\Software\\Classes\\tarp"; + for (const [subkey, args] of [ + [key, ["/ve", "/d", "URL:TARP 模组导入"]], + [key, ["/v", "URL Protocol", "/d", ""]], + [`${key}\\shell\\open\\command`, ["/ve", "/d", windowsProtocolCommand(launcher)]], + ] as const) { + await execute("reg.exe", ["add", subkey, ...args, "/f"], { + timeout: 10_000, + windowsHide: true, + }); + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9375578..4772094 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,11 +1,12 @@ import { parseModuleCommand, runModuleCommand } from "@jointarp/module-cli/commands"; +import { moduleImportArgument } from "@shared/module-import"; import manifest from "../../package.json"; -async function launchDesktop(path?: string): Promise { +async function launchDesktop(path?: string, importUrl?: string): Promise { try { const { startDesktop } = await import("./desktop"); - await startDesktop(path); + await startDesktop(path, importUrl); } catch (error) { console.error("桌面端启动失败", error); const { Utils } = await import("electrobun/main"); @@ -18,23 +19,28 @@ async function launchDesktop(path?: string): Promise { } } -const command = parseModuleCommand( - process.env.TARP_MODULE_PREVIEW - ? ["module", "preview", process.env.TARP_MODULE_PREVIEW] - : process.argv.slice(2), -); -if (command.kind === "desktop") { - await launchDesktop(); +const importUrl = moduleImportArgument(process.argv.slice(2)); +if (importUrl) { + await launchDesktop(undefined, importUrl); } else { - try { - const result = await runModuleCommand(command, manifest.version); - if (result.stdout) await Bun.write(Bun.stdout, result.stdout); - if (result.stderr) await Bun.write(Bun.stderr, result.stderr); - if (result.previewPath) { - await launchDesktop(result.previewPath); - } else process.exit(result.exitCode); - } catch (error) { - console.error(`命令执行失败:${error instanceof Error ? error.message : String(error)}`); - process.exit(1); + const command = parseModuleCommand( + process.env.TARP_MODULE_PREVIEW + ? ["module", "preview", process.env.TARP_MODULE_PREVIEW] + : process.argv.slice(2), + ); + if (command.kind === "desktop") { + await launchDesktop(); + } else { + try { + const result = await runModuleCommand(command, manifest.version); + if (result.stdout) await Bun.write(Bun.stdout, result.stdout); + if (result.stderr) await Bun.write(Bun.stderr, result.stderr); + if (result.previewPath) { + await launchDesktop(result.previewPath); + } else process.exit(result.exitCode); + } catch (error) { + console.error(`命令执行失败:${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } } } diff --git a/apps/desktop/src/main/modules/import-git-module.ts b/apps/desktop/src/main/modules/import-git-module.ts index 1493567..6c48ede 100644 --- a/apps/desktop/src/main/modules/import-git-module.ts +++ b/apps/desktop/src/main/modules/import-git-module.ts @@ -1,7 +1,11 @@ import { execFile } from "node:child_process"; +import { lstat, mkdir, readdir } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; +import { withFileMutation, isMissingFile } from "@jointarp/core/node/storage/file-queue"; +import { githubRepository } from "@shared/module-import"; + import { assertNoSymlinks, moduleFolderName, publishModule } from "./module-import"; import { resolveModuleRoot } from "./module-root"; @@ -9,7 +13,7 @@ const runGit = promisify(execFile); export interface ImportGitModuleInput { readonly url: string; - readonly ref?: string; + readonly replacePath?: string; } function repositoryAddress(value: string): { url: string; name: string } { @@ -46,7 +50,7 @@ function repositoryAddress(value: string): { url: string; name: string } { return { url, name }; } -async function cloneRepository(url: string, destination: string, ref: string): Promise { +async function cloneRepository(url: string, destination: string): Promise { try { await runGit( "git", @@ -60,7 +64,6 @@ async function cloneRepository(url: string, destination: string, ref: string): P "--no-tags", "--no-local", "--template=", - ...(ref ? ["--branch", ref] : []), "--", url, destination, @@ -83,25 +86,97 @@ async function cloneRepository(url: string, destination: string, ref: string): P if (failure.code === "ENOENT") throw new Error("未找到 Git,请安装 Git 并重启应用后重试。"); if (failure.killed) throw new Error("Git 下载超时,请检查网络后重试。"); const detail = failure.stderr?.trim().slice(-1500); - throw new Error(`Git 下载失败,请检查仓库地址、分支和访问权限。${detail ? `\n${detail}` : ""}`); + throw new Error(`Git 下载失败,请检查仓库地址、网络和访问权限。${detail ? `\n${detail}` : ""}`); + } +} + +export class ExistingGitModuleError extends Error { + constructor(readonly path: string) { + super("该仓库已导入。替换会丢弃现有模组的本地修改,已有冒险存档不受影响。"); } } +function repositoryIdentity(url: string): string { + return githubRepository(url) ?? url.replace(/\/+$/u, ""); +} + +export async function findImportedRepository( + directory: string, + url: string, +): Promise { + await mkdir(directory, { recursive: true }); + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const path = join(directory, entry.name); + try { + const config = join(path, ".git", "config"); + if (!(await lstat(join(path, ".git"))).isDirectory() || !(await lstat(config)).isFile()) + continue; + const { stdout } = await runGit( + "git", + ["config", "--file", config, "--get", "remote.origin.url"], + { timeout: 5000, windowsHide: true }, + ); + if (repositoryIdentity(stdout.trim()) === repositoryIdentity(url)) return path; + } catch (error) { + // 普通本地模组没有 Git 来源;读取错误不能成为覆盖它的理由。 + if (!isMissingFile(error) && (error as { killed?: boolean }).killed) + throw new Error("读取已有模组来源超时,请重试。"); + } + } + return undefined; +} + export async function importGitModule( directory: string, input: ImportGitModuleInput, -): Promise<{ path: string; name: string }> { +): Promise<{ path: string; name: string; revision: string }> { const { url, name } = repositoryAddress(input.url); - const ref = input.ref?.trim() ?? ""; - if (ref.startsWith("-") || /[\u0000-\u001f\u007f]/u.test(ref)) - throw new Error("分支或标签格式无效。"); - return publishModule(directory, name, async (staging) => { - const repository = join(staging, "repository"); - await cloneRepository(url, repository, ref); - await assertNoSymlinks(repository); - const module = await resolveModuleRoot(repository); - if (module !== join(repository, "module")) - throw new Error("Git 仓库必须把模组内容放在固定的 module/ 目录中。"); - return repository; + return withFileMutation(join(directory, ".git-import"), async () => { + const existing = await findImportedRepository(directory, url); + if (existing && input.replacePath !== existing) throw new ExistingGitModuleError(existing); + if (input.replacePath && existing !== input.replacePath) + throw new Error("已有模组来源已变化,请重新导入并确认。"); + const identity = githubRepository(url); + let folderName = existing + ? basename(existing) + : identity + ? moduleFolderName(identity.replace("/", "--")) + : name; + if (!existing) { + const base = folderName; + for (let index = 2; ; index++) { + try { + await lstat(join(directory, folderName)); + const suffix = `-${index}`; + folderName = `${base.slice(0, 100 - suffix.length)}${suffix}`; + } catch (error) { + if (isMissingFile(error)) break; + throw error; + } + } + } + let revision = ""; + const result = await publishModule( + directory, + folderName, + async (staging) => { + const repository = join(staging, "repository"); + await cloneRepository(url, repository); + await assertNoSymlinks(repository); + const module = await resolveModuleRoot(repository); + if (module !== join(repository, "module")) + throw new Error("Git 仓库必须把模组内容放在固定的 module/ 目录中。"); + revision = ( + await runGit("git", ["-C", repository, "rev-parse", "HEAD"], { + timeout: 5000, + windowsHide: true, + }) + ).stdout.trim(); + return repository; + }, + { replace: Boolean(existing) }, + ); + return { ...result, revision }; }); } diff --git a/apps/desktop/src/main/modules/import-service.test.ts b/apps/desktop/src/main/modules/import-service.test.ts new file mode 100644 index 0000000..6e93e39 --- /dev/null +++ b/apps/desktop/src/main/modules/import-service.test.ts @@ -0,0 +1,111 @@ +import { test } from "bun:test"; +import { strict as assert } from "node:assert"; + +import { waitFor } from "@jointarp/module/testing/async"; +import { + githubRepository, + parseModuleImportLink, + moduleImportArgument, +} from "@shared/module-import"; + +import { windowsProtocolCommand } from "../import-protocol"; +import { ExistingGitModuleError } from "./import-git-module"; +import { ModuleImportService } from "./import-service"; + +test("官网链接仅接受 GitHub 仓库与提醒版本,不能注入路径、分支或命令", () => { + const revision = "a".repeat(40); + assert.deepEqual( + parseModuleImportLink(`tarp://import?repository=Author%2FRepo&revision=${revision}`), + { url: "https://github.com/author/repo.git", revision }, + ); + for (const value of [ + "tarp://document/test", + "https://github.com/a/b", + "tarp://import?repository=../repo", + "tarp://import?repository=a/b/c", + "tarp://import?repository=a/b&ref=main", + "tarp://import?repository=a/b&repository=c/d", + "tarp://import?repository=a/b&revision=main", + "tarp://import?repository=a%2Fb%0A--upload-pack=evil", + "tarp://evil@import?repository=a/b", + "tarp://import?repository=a/b#fragment", + ]) + assert.throws(() => parseModuleImportLink(value), Error, value); + assert.equal( + moduleImportArgument(["tarp://import?repository=a%2Fb"]), + "tarp://import?repository=a%2Fb", + ); + assert.equal( + moduleImportArgument(["module", "validate", "tarp://import?repository=a/b"]), + undefined, + ); + assert.equal(moduleImportArgument([]), undefined); + for (const origin of [ + "https://github.com/Author/Repo.git", + "git@github.com:author/repo.git", + "ssh://git@github.com/AUTHOR/repo.git", + ]) + assert.equal(githubRepository(origin), "author/repo"); + assert.notEqual( + githubRepository("https://github.com/other/repo"), + githubRepository("https://github.com/author/repo"), + ); + assert.equal( + windowsProtocolCommand("C:\\用户 文件\\TARP\\launcher.exe"), + '"C:\\用户 文件\\TARP\\launcher.exe" "%1"', + ); + assert.throws(() => windowsProtocolCommand('C:\\bad" path\\launcher.exe')); +}); + +test("导入互斥、替换确认取消、失败重试与官网版本提醒", async () => { + let complete!: () => void; + const gate = new Promise((resolve) => { + complete = resolve; + }); + let calls = 0; + const service = new ModuleImportService( + async () => "/library", + async (_directory, input) => { + calls++; + if (calls === 1) { + await gate; + throw new ExistingGitModuleError("/library/author--repo"); + } + if (calls === 2) throw new ExistingGitModuleError("/library/author--repo"); + if (calls === 3) { + assert.equal(input.replacePath, "/library/author--repo"); + throw new Error("网络中断"); + } + return { path: "/library/author--repo/module", name: "测试冒险", revision: "b".repeat(40) }; + }, + ); + const request = { url: "https://github.com/author/repo.git", revision: "a".repeat(40) }; + service.start(request); + assert.throws(() => service.start(request), /稍后重试/); + assert.equal(service.get().busyNotice, 1); + await assert.rejects( + service.local("ZIP", async () => assert.fail("忙碌时不能打开选择器")), + /稍后重试/, + ); + complete(); + await waitFor(() => assert.equal(service.get().status, "confirm")); + const oldId = service.get().id; + service.confirm(oldId, false); + assert.equal(service.get().status, "idle"); + service.start(request); + await waitFor(() => assert.equal(service.get().status, "confirm")); + assert.throws(() => service.confirm(oldId, true), /失效/); + service.confirm(service.get().id, true); + await waitFor(() => assert.equal(service.get().status, "error")); + service.retry(service.get().id); + await waitFor(() => assert.equal(service.get().status, "success")); + assert.equal(service.get().mismatch, true); + service.dismiss(service.get().id); + service.start({ ...request, revision: "b".repeat(40) }); + await waitFor(() => assert.equal(service.get().status, "success")); + assert.equal(service.get().mismatch, false); + service.dismiss(service.get().id); + service.start({ url: request.url }); + await waitFor(() => assert.equal(service.get().status, "success")); + assert.equal(service.get().mismatch, false); +}); diff --git a/apps/desktop/src/main/modules/import-service.ts b/apps/desktop/src/main/modules/import-service.ts new file mode 100644 index 0000000..626483e --- /dev/null +++ b/apps/desktop/src/main/modules/import-service.ts @@ -0,0 +1,119 @@ +import type { + GitImportRequest, + ModuleImportResult, + ModuleImportState, +} from "@shared/module-import"; + +import { ExistingGitModuleError, importGitModule } from "./import-git-module"; + +/** 任务留在宿主中,刷新页面不会丢失确认、进度或结果。 */ +export class ModuleImportService { + private state: ModuleImportState = { id: 0, status: "idle" }; + private request?: GitImportRequest; + constructor( + private readonly directory: () => Promise, + private readonly importGit = importGitModule, + ) {} + + get(): ModuleImportState { + return this.state; + } + + private available(): void { + if (this.state.status === "running" || this.state.status === "confirm") { + this.state = { ...this.state, busyNotice: (this.state.busyNotice ?? 0) + 1 }; + throw new Error("正在导入模组,请稍后重试。"); + } + } + + start(request: GitImportRequest): void { + this.available(); + this.request = request; + this.state = { id: this.state.id + 1, status: "running", source: request.url, canRetry: true }; + void this.download(); + } + + report(error: unknown): void { + if (this.state.status === "running" || this.state.status === "confirm") return; + this.request = undefined; + this.state = { + id: this.state.id + 1, + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } + + private async download(replacePath?: string): Promise { + const request = this.request!; + try { + const result = await this.importGit(await this.directory(), { + url: request.url, + replacePath, + }); + this.state = { + ...this.state, + status: "success", + result, + existingPath: undefined, + message: undefined, + mismatch: Boolean(request.revision && request.revision !== result.revision), + }; + } catch (error) { + this.state = + error instanceof ExistingGitModuleError + ? { ...this.state, status: "confirm", existingPath: error.path, message: error.message } + : { + ...this.state, + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } + } + + confirm(id: number, replace: boolean): void { + if (id !== this.state.id || this.state.status !== "confirm") + throw new Error("导入确认已失效。"); + if (!replace) { + this.dismiss(id); + return; + } + const path = this.state.existingPath!; + this.state = { ...this.state, status: "running" }; + void this.download(path); + } + + retry(id: number): void { + if (id !== this.state.id || this.state.status !== "error" || !this.request) + throw new Error("请重新选择导入来源。"); + this.start(this.request); + } + + dismiss(id: number): void { + if (id !== this.state.id || this.state.status === "running") return; + this.state = { id, status: "idle" }; + this.request = undefined; + } + + async local( + source: string, + run: (directory: string) => Promise, + ): Promise { + this.available(); + this.request = undefined; + this.state = { id: this.state.id + 1, status: "running", source }; + try { + const result = await run(await this.directory()); + this.state = result + ? { ...this.state, status: "success", result } + : { id: this.state.id, status: "idle" }; + return result; + } catch (error) { + this.state = { + ...this.state, + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + throw error; + } + } +} diff --git a/apps/desktop/src/main/modules/list-modules.test.ts b/apps/desktop/src/main/modules/list-modules.test.ts index 947ab72..02bb7f7 100644 --- a/apps/desktop/src/main/modules/list-modules.test.ts +++ b/apps/desktop/src/main/modules/list-modules.test.ts @@ -3,7 +3,7 @@ import { strict as assert } from "node:assert"; import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; @@ -19,9 +19,14 @@ import { writeTestFile as writeFile } from "@jointarp/module/testing/files"; import { documentMediaUrl } from "../../shared/document-media"; import { resolveMediaRequestFile } from "../media-path"; -import { importGitModule } from "./import-git-module"; +import { + importGitModule, + ExistingGitModuleError, + findImportedRepository, +} from "./import-git-module"; import { importLocalModule } from "./import-local-module"; import { listModules } from "./list-modules"; +import { publishModule } from "./module-import"; async function runGit(directory: string, ...args: string[]) { return ( @@ -253,7 +258,7 @@ describe("从 Git 导入模组", () => { it("手动拉取更新后列表与新冒险使用新内容,已有冒险副本不变", async () => { const f = await gitFixture(); - const imported = await importGitModule(f.directory, { url: f.url, ref: "main" }); + const imported = await importGitModule(f.directory, { url: f.url }); const repository = dirname(imported.path); const sessions = join(f.root, "冒险"); const before = await createSession(sessions, imported.path, "更新前"); @@ -275,43 +280,144 @@ describe("从 Git 导入模组", () => { assert.ok(!(await readdir(join(sessions, after.id, "module"))).includes(".git")); }); - it("标签导入保留仓库及固定版本,并发重复导入保留已有文件", async () => { + it("默认分支导入保留仓库,并发重复导入保留已有文件", async () => { const f = await gitFixture(); - const input = { url: f.url, ref: "v1" }; + const input = { url: f.url }; const results = await Promise.allSettled([ importGitModule(f.directory, input), importGitModule(f.directory, input), ]); assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); const rejected = results.find((result) => result.status === "rejected"); - assert.ok(rejected?.status === "rejected" && /已存在/.test(rejected.reason.message)); + assert.ok(rejected?.status === "rejected" && /已导入/.test(rejected.reason.message)); const repository = join(f.directory, "测试仓库"); assert.equal( JSON.parse(await readFile(join(repository, "module", "地点/入口.json"), "utf8")).name, - "旧入口", + "新入口", ); assert.ok((await readdir(repository)).includes(".git")); - assert.equal(await runGit(repository, "rev-parse", "HEAD"), await f.git("rev-parse", "v1")); - assert.equal(await runGit(repository, "rev-parse", "--abbrev-ref", "HEAD"), "HEAD"); + assert.equal(await runGit(repository, "rev-parse", "HEAD"), await f.git("rev-parse", "HEAD")); + assert.equal(await runGit(repository, "rev-parse", "--abbrev-ref", "HEAD"), "main"); assert.equal((await listModules(f.directory))[0]?.path, join(repository, "module")); assert.deepEqual(await readdir(f.directory), ["测试仓库"]); }); it("下载和配置失败清理半成品,修复后可以重试", async () => { const f = await gitFixture(); - await assert.rejects( - importGitModule(f.directory, { url: f.url, ref: "不存在的分支" }), - /Git 下载失败/, - ); + await assert.rejects(importGitModule(f.directory, { url: `${f.url}-missing` }), /Git 下载失败/); await writeFile(join(f.module, ".types", "地点.fields.json"), "{invalid}"); await f.git("add", "."); await f.git("commit", "-m", "损坏类型配置"); await assert.rejects(importGitModule(f.directory, { url: f.url }), /地点.fields.json/); assert.deepEqual(await readdir(f.directory), []); - await importGitModule(f.directory, { url: f.url, ref: "v1" }); + await writeFile(join(f.module, ".types", "地点.fields.json"), '{"fields":{}}'); + await f.git("add", "."); + await f.git("commit", "-m", "修复类型"); + await importGitModule(f.directory, { url: f.url }); assert.equal((await listModules(f.directory)).length, 1); }); + it("同源重导须确认,失败保留本地修改,成功替换不改变已有冒险", async () => { + const f = await gitFixture(); + const first = await importGitModule(f.directory, { url: f.url }); + const repository = dirname(first.path); + const before = await createSession(join(f.root, "sessions"), first.path, "替换前"); + await writeFile(join(first.path, "本地修改.txt"), "玩家的本地修改"); + await assert.rejects(importGitModule(f.directory, { url: f.url }), ExistingGitModuleError); + await writeFile(join(f.module, ".types", "地点.fields.json"), "invalid"); + await f.git("add", "."); + await f.git("commit", "-m", "损坏配置"); + await assert.rejects( + importGitModule(f.directory, { url: f.url, replacePath: repository }), + /地点.fields.json/, + ); + assert.equal(await readFile(join(first.path, "本地修改.txt"), "utf8"), "玩家的本地修改"); + await writeFile(join(f.module, ".types", "地点.fields.json"), '{"fields":{}}'); + await writeFile(join(f.module, "地点/入口.json"), '{"name":"替换后的入口"}'); + await f.git("add", "."); + await f.git("commit", "-m", "修复并更新"); + const result = await importGitModule(f.directory, { url: f.url, replacePath: repository }); + assert.equal(result.path, first.path); + assert.equal(result.revision, await f.git("rev-parse", "HEAD")); + assert.equal( + JSON.parse(await readFile(join(result.path, "地点/入口.json"), "utf8")).name, + "替换后的入口", + ); + await assert.rejects(readFile(join(result.path, "本地修改.txt"))); + assert.equal( + JSON.parse( + await readFile(join(f.root, "sessions", before.id, "module/地点/入口.json"), "utf8"), + ).name, + "新入口", + ); + assert.deepEqual(await readdir(f.directory), ["测试仓库"]); + }); + + it("已有 GitHub 来源统一 HTTPS 与 SSH,不同作者的同名仓库分别识别", async () => { + const f = await gitFixture(); + const first = await importGitModule(f.directory, { url: f.url }); + await runGit( + dirname(first.path), + "remote", + "set-url", + "origin", + "git@github.com:Alice/adventure.git", + ); + const second = await importGitModule(f.directory, { url: f.url }); + await runGit( + dirname(second.path), + "remote", + "set-url", + "origin", + "https://github.com/Bob/adventure.git", + ); + assert.equal( + await findImportedRepository(f.directory, "https://github.com/alice/adventure"), + dirname(first.path), + ); + assert.equal( + await findImportedRepository(f.directory, "ssh://git@github.com/bob/adventure.git"), + dirname(second.path), + ); + assert.equal( + await findImportedRepository(f.directory, "https://github.com/other/adventure.git"), + undefined, + ); + }); + + it("无关同名目录保留,发布切换失败恢复原目录", async () => { + const f = await gitFixture(); + await mkdir(join(f.directory, "测试仓库"), { recursive: true }); + await writeFile(join(f.directory, "测试仓库/本地.txt"), "不要覆盖"); + const imported = await importGitModule(f.directory, { url: f.url }); + assert.equal(basename(dirname(imported.path)), "测试仓库-2"); + assert.equal(await readFile(join(f.directory, "测试仓库/本地.txt"), "utf8"), "不要覆盖"); + // 候选目录在切换时消失,触发发布失败,检查旧内容完整恢复。 + await assert.rejects( + publishModule(f.directory, "测试仓库-2", async () => imported.path, { replace: true }), + ); + assert.equal( + JSON.parse(await readFile(join(imported.path, "地点/入口.json"), "utf8")).name, + "新入口", + ); + assert.deepEqual((await readdir(f.directory)).sort(), ["测试仓库", "测试仓库-2"]); + }); + + it("替换用户改名的长目录时保留真实路径,不覆盖同名前缀目录", async () => { + const f = await gitFixture(); + const first = await importGitModule(f.directory, { url: f.url }); + const name = "a".repeat(105); + const renamed = join(f.directory, name); + const prefix = join(f.directory, name.slice(0, 100)); + await rename(dirname(first.path), renamed); + await mkdir(prefix); + await writeFile(join(prefix, "本地.txt"), "不要覆盖"); + const result = await importGitModule(f.directory, { url: f.url, replacePath: renamed }); + assert.equal(result.path, join(renamed, "module")); + assert.equal(await readFile(join(prefix, "本地.txt"), "utf8"), "不要覆盖"); + assert.deepEqual((await readdir(f.directory)).sort(), [name.slice(0, 100), name]); + }); + it("Git 仓库必须提供固定 module/,拒绝根目录清单和双层清单", async () => { const f = await gitFixture(""); await assert.rejects(importGitModule(f.directory, { url: f.url }), /固定的 module/); @@ -346,8 +452,8 @@ describe("从 Git 导入模组", () => { /格式无效/, ); await assert.rejects( - importGitModule(f.directory, { url: f.url, ref: "--upload-pack=恶意命令" }), - /格式无效/, + importGitModule(f.directory, { url: f.url, replacePath: join(f.directory, "外部目录") }), + /来源已变化/, ); const outside = join(f.root, "本机秘密.txt"); await writeFile(outside, "本机资料"); diff --git a/apps/desktop/src/main/modules/module-import.ts b/apps/desktop/src/main/modules/module-import.ts index 992b076..cd79174 100644 --- a/apps/desktop/src/main/modules/module-import.ts +++ b/apps/desktop/src/main/modules/module-import.ts @@ -1,5 +1,5 @@ import { lstat, mkdir, mkdtemp, readdir, rename, rm } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { basename, join, relative } from "node:path"; import { isMissingFile, withFileMutation } from "@jointarp/core/node/storage/file-queue"; import { loadDocumentTypes } from "@jointarp/module/node/document-types"; @@ -34,17 +34,22 @@ export async function publishModule( directory: string, folderName: string, prepare: (staging: string) => Promise, + options: { replace?: boolean } = {}, ): Promise<{ path: string; name: string }> { await mkdir(directory, { recursive: true }); - const destination = join(directory, moduleFolderName(folderName)); + if (options.replace && (basename(folderName) !== folderName || [".", ".."].includes(folderName))) + throw new Error("替换目标必须是模组库中的直接子目录。"); + // 已有仓库可能被用户改名,替换时必须保留真实目录名。 + const destination = join(directory, options.replace ? folderName : moduleFolderName(folderName)); return withFileMutation(destination, async () => { try { await lstat(destination); - throw new Error(`模组目录「${folderName}」已存在,请勿重复导入。`); + if (!options.replace) throw new Error(`模组目录「${folderName}」已存在,请勿重复导入。`); } catch (error) { if (!isMissingFile(error)) throw error; } const staging = await mkdtemp(join(directory, ".module-import-")); + let preserveBackup = false; try { const ready = await prepare(staging); const content = await resolveModuleRoot(ready); @@ -52,10 +57,24 @@ export async function publishModule( loadModuleMetadata(content), loadDocumentTypes(content), ]); - await rename(ready, destination); + const backup = join(staging, "previous"); + if (options.replace) await rename(destination, backup); + try { + await rename(ready, destination); + } catch (error) { + if (options.replace) { + try { + await rename(backup, destination); + } catch { + preserveBackup = true; + throw new Error(`替换失败且无法自动恢复。原模组保留在:${backup}`); + } + } + throw error; + } return { path: join(destination, relative(ready, content)), name: metadata.name }; } finally { - await rm(staging, { recursive: true, force: true }); + if (!preserveBackup) await rm(staging, { recursive: true, force: true }); } }); } diff --git a/apps/desktop/src/main/modules/module-router.ts b/apps/desktop/src/main/modules/module-router.ts index 6fbd5f0..7f1cf11 100644 --- a/apps/desktop/src/main/modules/module-router.ts +++ b/apps/desktop/src/main/modules/module-router.ts @@ -18,11 +18,10 @@ import { import { desktopProcedure as procedure } from "../desktop-rpc-context"; import { allowDocumentMediaRoots } from "../media-path"; import { documentsPath, showPathPicker } from "../native-dialog"; -import { importGitModule } from "./import-git-module"; import { importLocalModule } from "./import-local-module"; import { listModules } from "./list-modules"; -async function readModulesDirectory(configFilePath: string): Promise { +export async function readModulesDirectory(configFilePath: string): Promise { const documentsDir = documentsPath(); const config = await readAppConfig(configFilePath); return { @@ -55,20 +54,34 @@ async function loadLibraryFromPath(path: string): Promise context.moduleImports.get()), + confirmImport: procedure + .input(z.object({ id: z.number().int(), replace: z.boolean() })) + .handler(({ context, input }) => context.moduleImports.confirm(input.id, input.replace)), + retryImport: procedure + .input(z.object({ id: z.number().int() })) + .handler(({ context, input }) => context.moduleImports.retry(input.id)), + dismissImport: procedure + .input(z.object({ id: z.number().int() })) + .handler(({ context, input }) => context.moduleImports.dismiss(input.id)), importLocal: procedure .input(z.object({ kind: z.enum(["zip", "folder"]) })) .handler(async ({ context, input }) => { try { - const selection = await showPathPicker({ - title: input.kind === "zip" ? "选择模组 ZIP 文件" : "选择模组文件夹", - properties: [input.kind === "zip" ? "openFile" : "openDirectory"], - ...(input.kind === "zip" - ? { filters: [{ name: "ZIP 压缩包", extensions: ["zip"] }] } - : {}), - }); - if (selection.canceled || !selection.filePaths[0]) return null; - const directory = await readModulesDirectory(context.configFilePath); - return await importLocalModule(directory.path, selection.filePaths[0], input.kind); + return await context.moduleImports.local( + input.kind === "zip" ? "ZIP 文件" : "本地文件夹", + async (directory) => { + const selection = await showPathPicker({ + title: input.kind === "zip" ? "选择模组 ZIP 文件" : "选择模组文件夹", + properties: [input.kind === "zip" ? "openFile" : "openDirectory"], + ...(input.kind === "zip" + ? { filters: [{ name: "ZIP 压缩包", extensions: ["zip"] }] } + : {}), + }); + if (selection.canceled || !selection.filePaths[0]) return null; + return await importLocalModule(directory, selection.filePaths[0], input.kind); + }, + ); } catch (error) { throw new ORPCError("BAD_REQUEST", { message: error instanceof Error ? error.message : "导入模组失败,请重试。", @@ -77,15 +90,13 @@ export const moduleRouter = { }), importGit: procedure .input( - z.object({ + z.strictObject({ url: z.string().trim().min(1).max(2048), - ref: z.string().trim().max(255).optional(), }), ) .handler(async ({ context, input }) => { try { - const directory = await readModulesDirectory(context.configFilePath); - return await importGitModule(directory.path, input); + context.moduleImports.start(input); } catch (error) { throw new ORPCError("BAD_REQUEST", { message: error instanceof Error ? error.message : "导入模组失败,请重试。", diff --git a/apps/desktop/src/renderer/src/features/modules/ImportModuleButton.tsx b/apps/desktop/src/renderer/src/features/modules/ImportModuleButton.tsx index 7b813b5..fad060c 100644 --- a/apps/desktop/src/renderer/src/features/modules/ImportModuleButton.tsx +++ b/apps/desktop/src/renderer/src/features/modules/ImportModuleButton.tsx @@ -15,25 +15,26 @@ import { Input } from "../../components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; import { desktopOrpc, desktopRpc, queryClient } from "../../desktop-rpc"; -type ImportSource = { kind: "zip" | "folder" } | { kind: "git"; url: string; ref: string }; +type ImportSource = { kind: "zip" | "folder" } | { kind: "git"; url: string }; export function ImportModuleButton({ directory }: { directory?: string }) { const [open, setOpen] = useState(false); const [url, setUrl] = useState(""); - const [ref, setRef] = useState(""); const [importedName, setImportedName] = useState(null); const importer = useMutation({ - mutationFn: (source: ImportSource) => + mutationFn: async (source: ImportSource) => source.kind === "git" - ? desktopRpc.modules.importGit(source) - : desktopRpc.modules.importLocal(source), + ? await desktopRpc.modules.importGit({ url: source.url }) + : await desktopRpc.modules.importLocal(source), onSuccess: async (module) => { - if (!module) return; + if (!module) { + setOpen(false); + return; + } setImportedName(module.name); await queryClient.invalidateQueries(desktopOrpc.modules.list.queryOptions()); setOpen(false); setUrl(""); - setRef(""); }, }); return ( @@ -131,7 +132,6 @@ export function ImportModuleButton({ directory }: { directory?: string }) { importer.mutate({ kind: "git", url: url.trim(), - ref: ref.trim(), }); }} > @@ -158,20 +158,9 @@ export function ImportModuleButton({ directory }: { directory?: string }) { -

- 仓库须在 module/ 中提供完整模组。导入会保留 Git 仓库,方便以后手动更新。 + 导入默认分支的最新内容,仓库须在 module/ + 中提供完整模组。同一仓库已导入时会询问是否替换,替换会丢弃本地修改。

+

+ ) : null} + {action.error ? ( +

+ {action.error.message} +

+ ) : null} + {state.status === "success" || state.status === "error" ? ( +
+ {state.result ? ( + + ) : null} + {state.status === "error" && state.canRetry ? ( + + ) : null} + +
+ ) : null} + + { + if (!open && !action.isPending) + run(() => desktopRpc.modules.confirmImport({ id: state.id, replace: false })); + }} + > + + + 替换已有模组? + {state.message} + +

{state.existingPath}

+
+ + +
+ {action.error ? ( +

+ {action.error.message} +

+ ) : null} +
+
+ + ); +} diff --git a/apps/desktop/src/renderer/src/router.tsx b/apps/desktop/src/renderer/src/router.tsx index 4fc8617..93d2a3f 100644 --- a/apps/desktop/src/renderer/src/router.tsx +++ b/apps/desktop/src/renderer/src/router.tsx @@ -13,6 +13,7 @@ import { SettingsLayer } from "./components/SettingsLayer"; import { desktopOrpc, queryClient } from "./desktop-rpc"; import { HomePage, ModuleLibraryPage } from "./features/modules/HomePage"; import { HomeCatalogSkeleton } from "./features/modules/ModuleCardList"; +import { ModuleImportNotice } from "./features/modules/ModuleImportNotice"; import { ModulePage } from "./features/modules/ModulePage"; import { ModulePagePending } from "./features/modules/ModuleWorkspace"; import { GamePage } from "./features/sessions/GamePage"; @@ -30,6 +31,7 @@ function RootLayout() { + ); } diff --git a/apps/desktop/src/shared/module-import.ts b/apps/desktop/src/shared/module-import.ts new file mode 100644 index 0000000..02d6c4d --- /dev/null +++ b/apps/desktop/src/shared/module-import.ts @@ -0,0 +1,76 @@ +export interface GitImportRequest { + readonly url: string; + readonly revision?: string; +} + +export function moduleImportArgument(args: readonly string[]): string | undefined { + return args.length === 1 && /^tarp:/iu.test(args[0]!) ? args[0] : undefined; +} + +/** GitHub 来源按 owner/repo 识别,HTTPS 与 SSH 地址指向同一仓库。 */ +export function githubRepository(value: string): string | undefined { + const match = value + .trim() + .match( + /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^/?#\s]+)\/([^/?#\s]+?)\/?$/iu, + ); + if (!match) return undefined; + const owner = match[1]!; + const repo = match[2]!.replace(/\.git$/iu, ""); + if ( + !/^[a-z\d](?:[a-z\d-]{0,38})$/iu.test(owner) || + !/^[\w.-]{1,100}$/u.test(repo) || + /^\.+$/u.test(repo) + ) + return undefined; + return `${owner}/${repo}`.toLowerCase(); +} + +export function parseModuleImportLink(value: string): GitImportRequest { + if (value.length > 2048 || /[\u0000-\u0020\u007f]/u.test(value)) + throw new Error("模组导入链接无效。"); + const link = new URL(value); + if ( + link.protocol !== "tarp:" || + link.hostname !== "import" || + link.username || + link.password || + link.port || + link.hash || + !["", "/"].includes(link.pathname) + ) + throw new Error("模组导入链接无效。"); + for (const key of link.searchParams.keys()) { + if (!["repository", "revision"].includes(key) || link.searchParams.getAll(key).length !== 1) + throw new Error("模组导入链接包含无效参数。"); + } + const repository = link.searchParams.get("repository") ?? ""; + const identity = githubRepository(`https://github.com/${repository}`); + if (!identity || repository.endsWith("/") || repository.endsWith(".git")) + throw new Error("请提供有效的 GitHub 仓库标识。"); + const revision = link.searchParams.get("revision") ?? undefined; + if (revision !== undefined && !/^[a-f\d]{40}$/iu.test(revision)) + throw new Error("官网版本标识无效。"); + return { + url: `https://github.com/${identity}.git`, + ...(revision ? { revision: revision.toLowerCase() } : {}), + }; +} + +export interface ModuleImportResult { + readonly path: string; + readonly name: string; + readonly revision?: string; +} + +export interface ModuleImportState { + readonly id: number; + readonly status: "idle" | "running" | "confirm" | "success" | "error"; + readonly source?: string; + readonly existingPath?: string; + readonly result?: ModuleImportResult; + readonly message?: string; + readonly mismatch?: boolean; + readonly busyNotice?: number; + readonly canRetry?: boolean; +} diff --git "a/docs/\346\250\241\347\273\204\345\212\240\350\275\275\344\270\216\346\226\207\346\241\243\345\256\236\347\216\260.md" "b/docs/\346\250\241\347\273\204\345\212\240\350\275\275\344\270\216\346\226\207\346\241\243\345\256\236\347\216\260.md" index 9b81217..a0c70ba 100644 --- "a/docs/\346\250\241\347\273\204\345\212\240\350\275\275\344\270\216\346\226\207\346\241\243\345\256\236\347\216\260.md" +++ "b/docs/\346\250\241\347\273\204\345\212\240\350\275\275\344\270\216\346\226\207\346\241\243\345\256\236\347\216\260.md" @@ -22,15 +22,21 @@ TOML 编解码不能引入与 JSON 不同的日期对象、非有限数或危险 ## 导入边界 -桌面入口 `modules.importLocal` 根据 `kind` 打开 ZIP 文件或文件夹选择器,取消时返回 `null`;本机路径由主进程选择,不经渲染进程传入。`modules.importGit` 只接受仓库地址和可选分支/标签,模组固定放在仓库的 `module/`。界面入口为 `ImportModuleButton.tsx`。 +桌面入口 `modules.importLocal` 根据 `kind` 打开 ZIP 文件或文件夹选择器,取消时返回 `null`;本机路径由主进程选择,不经渲染进程传入。`modules.importGit` 只接受仓库地址,导入默认分支最新内容,模组固定放在仓库的 `module/`。界面入口为 `ImportModuleButton.tsx`。 -`apps/desktop/src/main/modules/module-import.ts` 统一协调目标目录锁、隐藏临时目录、元配置与文档类型校验、发布和失败清理。目标目录同名时拒绝覆盖;校验使用 Zod 与共享模组加载器,文档内容问题在打开模组、创建冒险或执行 CLI 校验时处理。文件夹导入允许选择内容目录或包含固定 `module/` 的仓库外层,只复制内容,不修改源目录。Git 导入整体保留浅克隆仓库、远程地址和分支跟踪关系;发布目录是完整仓库,校验和返回路径指向其中的 `module/`,目录名不追加子目录后缀。 +`apps/desktop/src/main/modules/module-import.ts` 统一协调目标目录锁、隐藏临时目录、元配置与文档类型校验、发布和失败清理。本地文件夹与 ZIP 目标目录同名时拒绝覆盖;Git 来源按 origin 识别,GitHub 的 HTTPS 与 SSH 地址统一为 owner/repo,不同作者的同名仓库分别保存,无关目录不覆盖。同源重导先由全局任务服务确认丢弃本地修改,再下载校验并在目标锁内替换;切换失败恢复原目录,恢复失败保留隐藏备份路径并报错。校验使用 Zod 与共享模组加载器,文档内容问题在打开模组、创建冒险或执行 CLI 校验时处理。文件夹导入允许选择内容目录或包含固定 `module/` 的仓库外层,只复制内容,不修改源目录。Git 导入整体保留浅克隆仓库、远程地址和分支跟踪关系;发布目录是完整仓库,校验和返回路径指向其中的 `module/`,目录名不追加子目录后缀。 `module-root.ts` 统一解析直接内容目录与固定 `module/` 外层:双层清单报结构冲突,缺少清单报明确错误,内部 `module/` 符号链接被拒绝;模组库条目的根路径仍允许本地快捷方式。列表只展示一个模组,`ModuleSummary.path` 指向实际内容目录,预览、阅读页、媒体授权和新建冒险使用该路径,不扫描仓库说明与 Git 元数据。 模组列表只解析内容目录和读取元配置,为卡片提供名称、描述、封面等信息,不加载类型定义、布局、阅读页或游戏文档,不遍历内容目录、不统计文档数量、不执行公式,也不调用完整 `validateModule`。卡片只展示目录或元配置读取错误;类型、文档与公式结果中的问题在显式打开模组、创建冒险或执行 CLI 校验时处理,创建冒险按钮不依赖列表预校验。 -应用不自动拉取或监听文件。列表与预览路由通过 staleTime=0 的 fetchQuery 在重新进入时读取文件,不复用一分钟内的旧结果;组件沿用已加载的查询数据。用户手动更新仓库后,下次列表请求或重新打开预览读取新内容;已有冒险使用独立模组副本,只有显式同步才替换;新冒险使用更新内容。标签导入保留固定版本,不建立分支跟踪;文件夹与 ZIP 导入仅保存内容副本。 +应用不自动拉取或监听文件。列表与预览路由通过 staleTime=0 的 fetchQuery 在重新进入时读取文件,不复用一分钟内的旧结果;组件沿用已加载的查询数据。用户手动更新仓库后,下次列表请求或重新打开预览读取新内容;已有冒险使用独立模组副本,只有显式同步才替换;新冒险使用更新内容。Git 导入只跟踪默认分支;文件夹与 ZIP 导入仅保存内容副本。 + +官网一键导入使用 `tarp://import?repository=owner%2Frepo&revision=`,revision 可省略,仅用于与下载后的 HEAD 比较并显示提醒,不影响默认分支下载。链接入口只接受 GitHub 仓库标识;未知参数、重复参数、其他协议及本机路径拒绝。官网目录同步也读取默认分支 HEAD。 + +`ModuleImportService` 统一管理 Git、ZIP 和文件夹的忙碌状态、替换确认与结果。任务保留在主进程,渲染层通过 RPC 读取状态;一次仅接受一个任务,忙碌时提示重试,不排队。对局中成功只提供打开入口,其他主窗口页面成功后打开模组。资料参考窗口不消费导入结果。 + +启动参数在 CLI 解析前分流导入链接;原生 open-url 与第二实例激活消息进入同一服务。现有 Unix socket / Windows named pipe 转发带类型的唤醒、预览或导入请求;初始化期间缓存导入请求,窗口就绪后处理。macOS 正式包声明 URL scheme;Windows 正式版 GUI 启动时向当前用户注册 launcher 协议命令,不要求管理员权限。开发构建不抢占正式协议。Windows 卸载可能残留注册,升级或重装后首次启动重新写入;Linux 不注册协议,使用官网复制地址和手动 Git 导入。 `packages/module/src/node/extract-zip.ts` 在新建临时目录逐文件流式解压,拒绝越界路径、符号链接、特殊文件、加密条目和重复路径。路径检查兼顾 Windows 名称限制与大小写、Unicode 别名;忽略 `.git` 与 macOS 归档辅助文件,保留 `.types`。ZIP 文件与单文件各限 512 MiB,解压总量限 2 GiB,条目限 10,000 个。归档根目录含配置时直接使用,否则查找唯一模组根目录;存在多个候选时拒绝自动选择。