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
6 changes: 5 additions & 1 deletion .github/workflows/sync-website-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]*))"
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electrobun.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
21 changes: 12 additions & 9 deletions apps/desktop/scripts/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
);
}
36 changes: 30 additions & 6 deletions apps/desktop/src/main/desktop-instance.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
});
22 changes: 15 additions & 7 deletions apps/desktop/src/main/desktop-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof activationSchema>;

/** 配置目录隔离实例;第二次启动将唤醒、预览或导入请求交给原宿主。 */
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);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/desktop-rpc-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
85 changes: 74 additions & 11 deletions apps/desktop/src/main/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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<void> {
// 尽早订阅原生事件,初始化期间的请求留给完成启动的宿主。
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<void> {
let mainWindow: DesktopWindow | null = null;
let shuttingDown = false;
let shutdownComplete = false;
Expand All @@ -47,20 +59,60 @@ export async function startDesktop(modulePath?: string): Promise<void> {
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(
Expand Down Expand Up @@ -159,6 +211,7 @@ export async function startDesktop(modulePath?: string): Promise<void> {
chat,
mcp,
updates,
moduleImports,
isHost: true,
player: await gameSettings.player(),
}),
Expand Down Expand Up @@ -242,6 +295,16 @@ export async function startDesktop(modulePath?: string): Promise<void> {
});
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();
}
29 changes: 29 additions & 0 deletions apps/desktop/src/main/import-protocol.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
});
}
}
44 changes: 25 additions & 19 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
async function launchDesktop(path?: string, importUrl?: string): Promise<void> {
try {
const { startDesktop } = await import("./desktop");
await startDesktop(path);
await startDesktop(path, importUrl);
} catch (error) {
console.error("桌面端启动失败", error);
const { Utils } = await import("electrobun/main");
Expand All @@ -18,23 +19,28 @@ async function launchDesktop(path?: string): Promise<void> {
}
}

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);
}
}
}
Loading
Loading