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
50 changes: 49 additions & 1 deletion apps/desktop/src/main/modules/import-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ import { windowsProtocolCommand } from "../import-protocol";
import { ExistingGitModuleError } from "./import-git-module";
import { ModuleImportService } from "./import-service";

test("本地来源选择期间不开始导入,取消后可重选,选定后才进入导入状态", async () => {
let directoryReads = 0;
const service = new ModuleImportService(async () => {
directoryReads++;
return "/library";
});
let select!: (path: string | null) => void;
let finish!: () => void;
let imports = 0;
const run = async (directory: string, path: string) => {
imports++;
assert.equal(directory, "/library");
assert.equal(path, "/tmp/module.zip");
await new Promise<void>((resolve) => {
finish = resolve;
});
return { path: "/library/module", name: "测试模组" };
};
const choose = () => new Promise<string | null>((resolve) => (select = resolve));
const canceled = service.local("ZIP 文件", choose, run);
assert.equal(service.get().status, "selecting");
assert.equal(directoryReads, 0);
assert.equal(imports, 0);
assert.throws(() => service.start({ url: "https://github.com/a/b.git" }), /稍后重试/);
await assert.rejects(service.local("文件夹", choose, run), /稍后重试/);
service.dismiss(service.get().id);
service.report(new Error("不能打断选择"));
assert.equal(service.get().status, "selecting");
select(null);
assert.equal(await canceled, null);
assert.equal(service.get().status, "idle");
assert.equal(directoryReads, 0);
assert.equal(imports, 0);

const importing = service.local("ZIP 文件", choose, run);
select("/tmp/module.zip");
await waitFor(() => assert.equal(imports, 1));
assert.equal(service.get().status, "running");
assert.equal(directoryReads, 1);
finish();
assert.deepEqual(await importing, { path: "/library/module", name: "测试模组" });
assert.equal(service.get().status, "success");
});

test("官网链接仅接受 GitHub 仓库与提醒版本,不能注入路径、分支或命令", () => {
const revision = "a".repeat(40);
assert.deepEqual(
Expand Down Expand Up @@ -84,7 +128,11 @@ test("导入互斥、替换确认取消、失败重试与官网版本提醒", as
assert.throws(() => service.start(request), /稍后重试/);
assert.equal(service.get().busyNotice, 1);
await assert.rejects(
service.local("ZIP", async () => assert.fail("忙碌时不能打开选择器")),
service.local(
"ZIP",
async () => assert.fail("忙碌时不能打开选择器"),
async () => assert.fail("忙碌时不能导入"),
),
/稍后重试/,
);
complete();
Expand Down
23 changes: 14 additions & 9 deletions apps/desktop/src/main/modules/import-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class ModuleImportService {
}

private available(): void {
if (this.state.status === "running" || this.state.status === "confirm") {
if (["selecting", "running", "confirm"].includes(this.state.status)) {
this.state = { ...this.state, busyNotice: (this.state.busyNotice ?? 0) + 1 };
throw new Error("正在导入模组,请稍后重试。");
}
Expand All @@ -34,7 +34,7 @@ export class ModuleImportService {
}

report(error: unknown): void {
if (this.state.status === "running" || this.state.status === "confirm") return;
if (["selecting", "running", "confirm"].includes(this.state.status)) return;
this.request = undefined;
this.state = {
id: this.state.id + 1,
Expand Down Expand Up @@ -89,23 +89,28 @@ export class ModuleImportService {
}

dismiss(id: number): void {
if (id !== this.state.id || this.state.status === "running") return;
if (id !== this.state.id || ["selecting", "running"].includes(this.state.status)) return;
this.state = { id, status: "idle" };
this.request = undefined;
}

async local(
source: string,
run: (directory: string) => Promise<ModuleImportResult | null>,
select: () => Promise<string | null>,
run: (directory: string, path: string) => Promise<ModuleImportResult>,
): Promise<ModuleImportResult | null> {
this.available();
this.request = undefined;
this.state = { id: this.state.id + 1, status: "running", source };
this.state = { id: this.state.id + 1, status: "selecting", source };
try {
const result = await run(await this.directory());
this.state = result
? { ...this.state, status: "success", result }
: { id: this.state.id, status: "idle" };
const path = await select();
if (!path) {
this.state = { id: this.state.id, status: "idle" };
return null;
}
this.state = { ...this.state, status: "running" };
const result = await run(await this.directory(), path);
this.state = { ...this.state, status: "success", result };
return result;
} catch (error) {
this.state = {
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/modules/module-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,17 @@ export const moduleRouter = {
try {
return await context.moduleImports.local(
input.kind === "zip" ? "ZIP 文件" : "本地文件夹",
async (directory) => {
async () => {
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);
return selection.canceled ? null : (selection.filePaths[0] ?? null);
},
(directory, path) => importLocalModule(directory, path, input.kind),
);
} catch (error) {
throw new ORPCError("BAD_REQUEST", {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/native-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface PathPickerOptions {
}
export async function showPathPicker(options: PathPickerOptions) {
const filePaths = await Utils.openFileDialog({
startingFolder: options.defaultPath,
startingFolder: options.defaultPath ?? "~/",
canChooseFiles: options.properties.includes("openFile"),
canChooseDirectory: options.properties.includes("openDirectory"),
allowsMultipleSelection: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FileArchive, Folder, FolderInput, GitBranch, LoaderCircle } from "lucide-react";
import { useState } from "react";

Expand All @@ -19,14 +19,16 @@ type ImportSource = { kind: "zip" | "folder" } | { kind: "git"; url: string };

export function ImportModuleButton({ directory }: { directory?: string }) {
const [open, setOpen] = useState(false);
const [sourceKind, setSourceKind] = useState<ImportSource["kind"]>("zip");
const [url, setUrl] = useState("");
const [importedName, setImportedName] = useState<string | null>(null);
const importer = useMutation({
mutationFn: async (source: ImportSource) =>
source.kind === "git"
? await desktopRpc.modules.importGit({ url: source.url })
: await desktopRpc.modules.importLocal(source),
onSuccess: async (module) => {
onSuccess: async (module, source) => {
if (source.kind !== "git" && !module) return;
if (!module) {
setOpen(false);
return;
Expand All @@ -37,6 +39,16 @@ export function ImportModuleButton({ directory }: { directory?: string }) {
setUrl("");
},
});
const importState = useQuery({
...desktopOrpc.modules.importState.queryOptions(),
enabled: importer.isPending && importer.variables?.kind !== "git",
staleTime: 0,
refetchInterval: 250,
});
const importing =
importer.isPending &&
importState.dataUpdatedAt > importer.submittedAt &&
importState.data?.status === "running";
return (
<div className="flex flex-wrap items-center justify-end gap-3">
{importedName ? (
Expand Down Expand Up @@ -72,7 +84,14 @@ export function ImportModuleButton({ directory }: { directory?: string }) {
选择 ZIP、文件夹或 Git 仓库,将模组保存到你的模组库。
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="zip" onValueChange={() => importer.reset()}>
<Tabs
value={sourceKind}
onValueChange={(value) => {
if (importer.isPending || !value) return;
setSourceKind(value as ImportSource["kind"]);
importer.reset();
}}
>
<TabsList className="w-full" aria-label="模组导入方式">
<TabsTrigger value="zip" disabled={importer.isPending}>
<FileArchive aria-hidden="true" />
Expand All @@ -96,12 +115,16 @@ export function ImportModuleButton({ directory }: { directory?: string }) {
disabled={importer.isPending}
onClick={() => importer.mutate({ kind: "zip" })}
>
{importer.isPending ? (
{importing ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
) : (
<FileArchive className="size-4" aria-hidden="true" />
)}
{importer.isPending ? "正在导入并校验…" : "选择 ZIP 文件并导入"}
{importing
? "正在导入并校验…"
: importer.isPending
? "请选择 ZIP 文件…"
: "选择 ZIP 文件并导入"}
</Button>
</TabsContent>
<TabsContent value="folder" className="space-y-4 pt-3">
Expand All @@ -114,12 +137,16 @@ export function ImportModuleButton({ directory }: { directory?: string }) {
disabled={importer.isPending}
onClick={() => importer.mutate({ kind: "folder" })}
>
{importer.isPending ? (
{importing ? (
<LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
) : (
<Folder className="size-4" aria-hidden="true" />
)}
{importer.isPending ? "正在导入并校验…" : "选择文件夹并导入"}
{importing
? "正在导入并校验…"
: importer.isPending
? "请选择文件夹…"
: "选择文件夹并导入"}
</Button>
</TabsContent>
<TabsContent value="git" className="pt-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function ModuleImportNotice() {
if (!pathname.startsWith("/game/"))
void navigate({ to: "/module", search: { path: state.result.path } });
}, [state, pathname, reference, navigate]);
if (reference || !state || state.status === "idle") return null;
if (reference || !state || state.status === "idle" || state.status === "selecting") return null;
const busyKey = `${state.id}:${state.busyNotice}`;
const busy = Boolean(state.busyNotice) && dismissedBusy !== busyKey;
const run = (task: () => Promise<unknown>) => action.mutate(task);
Expand All @@ -51,7 +51,9 @@ export function ModuleImportNotice() {
>
<p className="text-sm font-medium">
{state.status === "running"
? "正在下载并校验模组…"
? state.canRetry
? "正在下载并校验模组…"
: "正在导入并校验模组…"
: state.status === "success"
? `已导入「${state.result?.name}」`
: state.status === "confirm"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/shared/module-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface ModuleImportResult {

export interface ModuleImportState {
readonly id: number;
readonly status: "idle" | "running" | "confirm" | "success" | "error";
readonly status: "idle" | "selecting" | "running" | "confirm" | "success" | "error";
readonly source?: string;
readonly existingPath?: string;
readonly result?: ModuleImportResult;
Expand Down
2 changes: 2 additions & 0 deletions docs/模组加载与文档实现.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ TOML 编解码不能引入与 JSON 不同的日期对象、非有限数或危险

`ModuleImportService` 统一管理 Git、ZIP 和文件夹的忙碌状态、替换确认与结果。任务保留在主进程,渲染层通过 RPC 读取状态;一次仅接受一个任务,忙碌时提示重试,不排队。对局中成功只提供打开入口,其他主窗口页面成功后打开模组。资料参考窗口不消费导入结果。

本地来源选择期间使用 `selecting` 状态并保留任务互斥,选定路径后才进入 `running` 并读取保存目录、执行导入;取消选择回到 `idle`,不执行导入。导入按钮轮询主进程状态以区分选择和导入阶段,全局进度提示不展示 `selecting`。

启动参数在 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 个。归档根目录含配置时直接使用,否则查找唯一模组根目录;存在多个候选时拒绝自动选择。
Expand Down
Loading