{show.value === "ma2" && <>
{t('music.edit.replaceChartConfirm', { level: DIFFICULTY[selectedLevel.value!] })}
-
{fileHandle.value?.name}
+
{file.value?.name}
↓
>}
{(show.value === "maidata" || show.value === "failed") &&
}
diff --git a/MaiChartManager/Front/src/components/JacketBox.tsx b/MaiChartManager/Front/src/components/JacketBox.tsx
index 74e55dc3..e1b2e4f0 100644
--- a/MaiChartManager/Front/src/components/JacketBox.tsx
+++ b/MaiChartManager/Front/src/components/JacketBox.tsx
@@ -5,6 +5,7 @@ import { showTransactionalDialog } from "@munet/ui";
import { globalCapture, selectedADir, selectedMusic } from "@/store/refs";
import { MusicXmlWithABJacket } from "@/client/apiGen";
import { useI18n } from 'vue-i18n';
+import { pickFile } from "@/utils/pickFile";
export let upload = async (fileHandle?: FileSystemFileHandle) => {
}
@@ -24,23 +25,15 @@ export default defineComponent({
upload = async (fileHandle?: FileSystemFileHandle) => {
if (!props.upload) return;
try {
+ let file: File;
if (!fileHandle) {
- [fileHandle] = await window.showOpenFilePicker({
- id: 'jacket',
- startIn: 'downloads',
- types: [
- {
- description: t('genre.imageDescription'),
- accept: {
- "application/jpeg": [".jpeg", ".jpg"],
- "application/png": [".png"],
- },
- },
- ],
- });
+ // 封面图片,使用通用单文件选择(兼容 WebKitGTK)
+ const picked = await pickFile('image/jpeg,image/png');
+ if (!picked) return;
+ file = picked;
+ } else {
+ file = await fileHandle.getFile();
}
- if (!fileHandle) return;
- const file = await fileHandle.getFile();
const res = await api.SetMusicJacket(props.info.id!, selectedADir.value, { file });
if (res.error) {
diff --git a/MaiChartManager/Front/src/utils/httpImportDirectory.ts b/MaiChartManager/Front/src/utils/httpImportDirectory.ts
new file mode 100644
index 00000000..3c10762e
--- /dev/null
+++ b/MaiChartManager/Front/src/utils/httpImportDirectory.ts
@@ -0,0 +1,70 @@
+import { ImportDirectory, ImportFileHandle } from "@/utils/importDirectory";
+import { getUrl } from "@/client/api";
+
+// 后端返回的子项结构,与 ImportBrowseController.ImportDirEntry 对应
+interface BackendEntry {
+ name: string;
+ path: string;
+ isDirectory: boolean;
+}
+
+// 取路径最后一段作为显示名;同时按 '/' 和 '\\' 切分,兼容 Windows 风格路径
+function basename(p: string): string {
+ const parts = p.split(/[/\\]/).filter(Boolean);
+ return parts.length ? parts[parts.length - 1] : p;
+}
+
+// 读文件:
+// - 只传 path 时,后端直接读该完整路径(用于 values() 里已知绝对路径的文件项)
+// - 传 path(目录) + name 时,后端 Path.Combine(path, name)(用于 getFileHandle,避免前端跨平台拼路径)
+async function readFile(path: string, displayName: string, name?: string): Promise
{
+ let url = getUrl('ReadImportFileApi') + '?path=' + encodeURIComponent(path);
+ if (name !== undefined) {
+ url += '&name=' + encodeURIComponent(name);
+ }
+ console.log('[imp] readFile fetch:', url);
+ const res = await fetch(url);
+ if (!res.ok) throw new Error('文件不存在: ' + displayName);
+ return new File([await res.blob()], displayName);
+}
+
+// 基于后端 3 个接口实现的 ImportDirectory 适配器。
+// 供 WebKitGTK / Photino 等没有 File System Access API 的本地宿主使用。
+// absPath:目录绝对路径;name:显示名(默认取 absPath 最后一段)
+export function httpImportDirectory(absPath: string, name?: string): ImportDirectory {
+ return {
+ kind: 'directory',
+ name: name ?? basename(absPath),
+
+ // 按名取目录下文件的句柄。不在这里拼路径,交给后端 Path.Combine(传 absPath + name)。
+ // 不存在时 getFile 会抛错,由 tryGetFile 兜住。
+ async getFileHandle(fileName: string): Promise {
+ return {
+ kind: 'file',
+ name: fileName,
+ getFile: () => readFile(absPath, fileName, fileName),
+ };
+ },
+
+ // 迭代目录直接子项:目录递归构造适配器,文件构造文件句柄
+ async *values(): AsyncIterableIterator {
+ const listUrl = getUrl('ListImportDirApi') + '?path=' + encodeURIComponent(absPath);
+ console.log('[imp] listDir fetch:', listUrl);
+ const res = await fetch(listUrl);
+ if (!res.ok) return;
+ const entries: BackendEntry[] = await res.json();
+ for (const child of entries) {
+ if (child.isDirectory) {
+ yield httpImportDirectory(child.path, child.name);
+ } else {
+ yield {
+ kind: 'file',
+ name: child.name,
+ // 已知子项绝对路径,只传 path 即可
+ getFile: () => readFile(child.path, child.name),
+ } satisfies ImportFileHandle;
+ }
+ }
+ },
+ };
+}
diff --git a/MaiChartManager/Front/src/utils/importDirectory.ts b/MaiChartManager/Front/src/utils/importDirectory.ts
new file mode 100644
index 00000000..37847f3d
--- /dev/null
+++ b/MaiChartManager/Front/src/utils/importDirectory.ts
@@ -0,0 +1,22 @@
+// 导入流程用的「目录句柄」抽象接口。
+// 真实的浏览器 FileSystemDirectoryHandle(Chromium / WebView2 / 远程 Chrome)和
+// WebKitGTK 下基于 的适配器都要实现它,
+// 这样 startProcess / prepareFolder / tryGetFile 就不用写死 FileSystemDirectoryHandle,
+// 避免在不支持 File System Access API 的内核上类型/运行时出错。
+
+// 文件项:能拿到底层 File
+export interface ImportFileHandle {
+ readonly kind: 'file';
+ readonly name: string;
+ getFile(): Promise;
+}
+
+// 目录项:能按名取文件、能迭代子项
+export interface ImportDirectory {
+ readonly kind: 'directory';
+ readonly name: string;
+ // 取目录下某个文件的句柄;不存在时按 File System Access API 的语义抛错(由 tryGetFile 兜住)
+ getFileHandle(name: string): Promise;
+ // 迭代子项(文件或子目录),与 FileSystemDirectoryHandle.values() 形状一致
+ values(): AsyncIterableIterator;
+}
diff --git a/MaiChartManager/Front/src/utils/pickDirectory.ts b/MaiChartManager/Front/src/utils/pickDirectory.ts
new file mode 100644
index 00000000..b267c561
--- /dev/null
+++ b/MaiChartManager/Front/src/utils/pickDirectory.ts
@@ -0,0 +1,51 @@
+import { ImportDirectory } from "@/utils/importDirectory";
+import { httpImportDirectory } from "@/utils/httpImportDirectory";
+import { getUrl, isLocalHost, isPhotino } from "@/client/api";
+
+// 抛一个 AbortError,与 showDirectoryPicker 取消时的语义一致(startProcess 里会 catch 掉)
+function abort(message = '用户取消选择目录'): never {
+ const err = new Error(message);
+ err.name = 'AbortError';
+ throw err;
+}
+
+// 走后端:弹原生选文件夹对话框,再用 httpImportDirectory 适配器通过 HTTP 提供目录内容。
+async function pickViaBackend(): Promise {
+ const res = await fetch(getUrl('PickImportFolderApi'));
+ if (!res.ok) abort('选择目录失败');
+ // 后端 Ok(string) 走 ASP.NET 的 string 特例,以 text/plain 返回裸路径(不是 JSON),取消时为空。
+ // 所以必须用 res.text() 而不是 res.json()。
+ const path = (await res.text()) || null;
+ if (!path) abort();
+ return httpImportDirectory(path);
+}
+
+// 通用选目录:
+// - Photino(WebKitGTK):一律走后端原生对话框。WebKitGTK 即使暴露了 window.showDirectoryPicker,
+// 其实现也有问题(调用后访问 handle 会抛 "The string did not match the expected pattern"),
+// 所以**不能**用 typeof 检测来决定,必须在 showDirectoryPicker 之前优先判 isPhotino。
+// - 其它有真实 File System Access API 的环境(WebView2 / 远程 Chrome):用真实 handle,行为不变。
+// - 其余本地宿主但无可用 picker:兜底走后端。
+// - 都不满足(远程浏览器且无 File System Access API):不支持,按取消处理。
+export async function pickDirectory(
+ options?: { id?: string; startIn?: string },
+): Promise {
+ // Photino/WebKitGTK:优先后端,绕开有问题的 webkit showDirectoryPicker
+ if (isPhotino) {
+ return pickViaBackend();
+ }
+
+ // 真实 File System Access API(WebView2 / Chromium / 远程 Chrome)
+ if (typeof window.showDirectoryPicker === 'function') {
+ // 真实 FileSystemDirectoryHandle 在结构上满足 ImportDirectory
+ return window.showDirectoryPicker(options as any) as unknown as Promise;
+ }
+
+ // 其它本地桌面宿主(无 showDirectoryPicker):后端原生选目录
+ if (isLocalHost) {
+ return pickViaBackend();
+ }
+
+ // 远程浏览器且无 File System Access API:不支持,按取消处理
+ abort('当前环境不支持选择目录');
+}
diff --git a/MaiChartManager/Front/src/utils/pickFile.ts b/MaiChartManager/Front/src/utils/pickFile.ts
new file mode 100644
index 00000000..c6945154
--- /dev/null
+++ b/MaiChartManager/Front/src/utils/pickFile.ts
@@ -0,0 +1,16 @@
+// 通用单文件选择:用 ,WebKitGTK / Chromium / 远程浏览器都支持,
+// 替代 WebKitGTK 不支持的 window.showOpenFilePicker。
+export function pickFile(accept?: string): Promise {
+ return new Promise(resolve => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ if (accept) input.accept = accept;
+ input.style.display = 'none';
+ document.body.appendChild(input);
+ let settled = false;
+ input.addEventListener('change', () => { settled = true; resolve(input.files?.[0] ?? null); input.remove(); });
+ // 取消时多数内核不触发 change;用 window focus 兜底判定取消
+ window.addEventListener('focus', () => setTimeout(() => { if (!settled) { resolve(null); input.remove(); } }, 500), { once: true });
+ input.click();
+ });
+}
diff --git a/MaiChartManager/Front/src/utils/tryGetFile.ts b/MaiChartManager/Front/src/utils/tryGetFile.ts
index 4d35c538..cf64d34a 100644
--- a/MaiChartManager/Front/src/utils/tryGetFile.ts
+++ b/MaiChartManager/Front/src/utils/tryGetFile.ts
@@ -1,4 +1,9 @@
-export default async (dir: FileSystemDirectoryHandle, file: string): Promise => {
+import { ImportDirectory } from "@/utils/importDirectory";
+
+// 取目录下指定文件,找不到返回 undefined。
+// 参数类型用 ImportDirectory 而非写死 FileSystemDirectoryHandle,
+// 这样真实 handle 和 WebKitGTK 适配器都能传进来。
+export default async (dir: ImportDirectory, file: string): Promise => {
try {
const handle = await dir.getFileHandle(file);
return await handle.getFile();
diff --git a/MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts b/MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts
new file mode 100644
index 00000000..e6d393b4
--- /dev/null
+++ b/MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts
@@ -0,0 +1,93 @@
+import { ImportDirectory, ImportFileHandle } from "@/utils/importDirectory";
+
+// WebKitGTK 没有 window.showDirectoryPicker,只能用 ,
+// 拿到的是扁平的 FileList,每个 File 带 webkitRelativePath(形如 "顶层目录/子目录/文件名")。
+// 这里把扁平列表重建成目录树,并包装成实现 ImportDirectory 接口的对象,
+// 让现有导入流程(startProcess / prepareFolder / tryGetFile)无需改动即可消费。
+
+// 内部目录树节点
+interface DirNode {
+ name: string;
+ files: Map; // 直接子文件:文件名 -> File
+ dirs: Map; // 直接子目录:目录名 -> 节点
+}
+
+// 文件句柄适配器
+class FileHandleAdapter implements ImportFileHandle {
+ readonly kind = 'file' as const;
+ constructor(readonly name: string, private readonly file: File) {}
+ async getFile(): Promise {
+ return this.file;
+ }
+}
+
+// 目录句柄适配器
+class DirectoryAdapter implements ImportDirectory {
+ readonly kind = 'directory' as const;
+ constructor(private readonly node: DirNode) {}
+
+ get name(): string {
+ return this.node.name;
+ }
+
+ async getFileHandle(name: string): Promise {
+ const file = this.node.files.get(name);
+ if (!file) {
+ // 对齐 File System Access API 的语义:找不到就抛 NotFoundError,由 tryGetFile 的 try/catch 兜住
+ const err = new Error(`未找到文件: ${name}`);
+ err.name = 'NotFoundError';
+ throw err;
+ }
+ return new FileHandleAdapter(name, file);
+ }
+
+ async *values(): AsyncIterableIterator {
+ for (const [name, file] of this.node.files) {
+ yield new FileHandleAdapter(name, file);
+ }
+ for (const child of this.node.dirs.values()) {
+ yield new DirectoryAdapter(child);
+ }
+ }
+}
+
+// 创建空节点
+const makeNode = (name: string): DirNode => ({ name, files: new Map(), dirs: new Map() });
+
+// 把扁平 FileList(带 webkitRelativePath)重建为目录树,返回根目录适配器。
+// 选目录时浏览器会把所选目录名作为 webkitRelativePath 的第一段,因此根节点名取该第一段。
+export function buildDirectoryFromFileList(files: FileList | File[]): ImportDirectory {
+ const list = Array.from(files);
+ // 用一个虚拟根承载,最终若只有单一顶层目录则把它作为返回根
+ const virtualRoot = makeNode('');
+
+ for (const file of list) {
+ // webkitRelativePath 形如 "topDir/sub/file.txt";个别实现可能为空,退回用文件名
+ const relPath = (file as any).webkitRelativePath as string || file.name;
+ const parts = relPath.split('/').filter(Boolean);
+ if (parts.length === 0) continue;
+
+ const fileName = parts[parts.length - 1];
+ const dirParts = parts.slice(0, -1);
+
+ let cursor = virtualRoot;
+ for (const part of dirParts) {
+ let next = cursor.dirs.get(part);
+ if (!next) {
+ next = makeNode(part);
+ cursor.dirs.set(part, next);
+ }
+ cursor = next;
+ }
+ cursor.files.set(fileName, file);
+ }
+
+ // 通常 webkitdirectory 选择后只有一个顶层目录,直接返回它,
+ // 这样根目录名 = 用户所选目录名,与 showDirectoryPicker 的行为一致。
+ if (virtualRoot.files.size === 0 && virtualRoot.dirs.size === 1) {
+ const only = virtualRoot.dirs.values().next().value as DirNode;
+ return new DirectoryAdapter(only);
+ }
+
+ return new DirectoryAdapter(virtualRoot);
+}
diff --git a/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx b/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx
index 2bfc5993..c4f2efbc 100644
--- a/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx
+++ b/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx
@@ -2,7 +2,7 @@ import { defineComponent, PropType, ref } from "vue";
import { MusicXmlWithABJacket } from "@/client/apiGen";
import { Button, Radio, Select, Popover, addToast } from "@munet/ui";
import { STEP } from "@/views/BatchAction/index";
-import api, { isWebView } from "@/client/api";
+import api, { isLocalHost, requestExportMaidata } from "@/client/api";
import { showNeedPurchaseDialog, updateMusicList, version } from "@/store/refs";
import remoteExport from "@/views/BatchAction/remoteExport";
import TransitionVertical from "@/components/TransitionVertical.vue";
@@ -53,14 +53,14 @@ export default defineComponent({
break;
case OPTIONS.CreateNewOpt:
case OPTIONS.CreateNewOptCompatible:
- if (isWebView) {
+ if (isLocalHost) {
props.continue(STEP.Select);
await api.RequestCopyTo({music: props.selectedMusic, removeEvents: selectedOption.value === OPTIONS.CreateNewOptCompatible, legacyFormat: false});
addToast({message: t('music.batch.exportSuccess'), type: 'success'});
break;
}
case OPTIONS.CreateNewOptMa2_103:
- if (isWebView) {
+ if (isLocalHost) {
props.continue(STEP.Select);
await api.RequestCopyTo({music: props.selectedMusic, removeEvents: true, legacyFormat: true});
addToast({message: t('music.batch.exportSuccess'), type: 'success'});
@@ -73,6 +73,24 @@ export default defineComponent({
showNeedPurchaseDialog.value = true
break;
}
+ if (isLocalHost) {
+ // 本地宿主(Photino/WebKitGTK、WebView2):走后端 RequestExportMaidata,弹原生选目录对话框。
+ // 注意:ConvertToMaidataById(按 ID 命名子目录)在本地路径下无法精确还原——
+ // 后端目前按「歌名 + DX」命名子目录,因此本地路径下 ById 等同于普通 maidata 导出。
+ // 远程路径(remoteExport)仍按 ID 命名,保持原样。
+ load.value = true;
+ try {
+ await requestExportMaidata(
+ props.selectedMusic!.map(it => ({id: it.id!, assetDir: it.assetDir!})),
+ selectedOption.value === OPTIONS.ConvertToMaidataIgnoreVideo,
+ );
+ addToast({message: t('music.batch.exportSuccess'), type: 'success'});
+ } finally {
+ load.value = false;
+ }
+ props.continue(STEP.Select);
+ break;
+ }
remoteExport(props.continue as any, props.selectedMusic!, selectedOption.value, selectedMaidataSubdir.value);
break;
}
diff --git a/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx b/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx
index 794a334b..638a9f38 100644
--- a/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx
+++ b/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx
@@ -1,6 +1,6 @@
import { defineComponent, ref } from "vue";
import { Button, Modal, Progress, addToast } from "@munet/ui";
-import api, { getUrl, isWebView } from "@/client/api";
+import api, { getUrl, isLocalHost } from "@/client/api";
import { updateAssetDirs } from "@/store/refs";
import axios from "axios";
import { UploadAssetDirResult } from "@/client/apiGen";
@@ -15,7 +15,7 @@ export default defineComponent({
const importLocal = async () => {
importWait.value = true;
- if (!isWebView) {
+ if (!isLocalHost) {
// 浏览器模式
let folderHandle: FileSystemDirectoryHandle;
try {
diff --git a/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx b/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx
index 5762d9d7..8b4550bb 100644
--- a/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx
+++ b/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx
@@ -1,5 +1,5 @@
import { computed, defineComponent, ref } from "vue";
-import api, { getUrl, isWebView } from "@/client/api";
+import api, { getUrl, isLocalHost, requestExportMaidata } from "@/client/api";
import { globalCapture, selectedADir, selectedMusic, selectMusicId, showNeedPurchaseDialog, version } from "@/store/refs";
import { DropMenu, addToast } from "@munet/ui";
import { BlobWriter, ZipReader } from "@zip.js/zip.js";
@@ -28,8 +28,8 @@ export default defineComponent({
const copy = async (type: CopyType) => {
wait.value = true;
- if (!isWebView || type === CopyType.exportMaidata || type === CopyType.exportMaidataIgnoreVideo) {
- // 浏览器模式,使用 zip.js 获取并解压
+ if (!isLocalHost) {
+ // 远程浏览器模式:用 File System Access API(showDirectoryPicker + zip.js 获取并解压写盘)
let folderHandle: FileSystemDirectoryHandle;
try {
folderHandle = await window.showDirectoryPicker({
@@ -78,12 +78,21 @@ export default defineComponent({
}
return;
}
+ // 本地宿主(Photino/WebKitGTK、WebView2):所有类型都走后端原生对话框,不用浏览器 File System Access API
try {
- // 本地 webview 打开,使用本地模式
- await api.RequestCopyTo({
- music: [{id: selectMusicId.value, assetDir: selectedADir.value}],
- removeEvents: false,
- });
+ if (type === CopyType.export) {
+ // Opt 导出沿用已有的 RequestCopyTo
+ await api.RequestCopyTo({
+ music: [{id: selectMusicId.value, assetDir: selectedADir.value}],
+ removeEvents: false,
+ });
+ } else {
+ // maidata 导出改用后端新接口 RequestExportMaidata
+ await requestExportMaidata(
+ [{id: selectMusicId.value, assetDir: selectedADir.value}],
+ type === CopyType.exportMaidataIgnoreVideo,
+ );
+ }
} finally {
wait.value = false;
}
diff --git a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx
index 71f85b75..1b62d2a3 100644
--- a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx
+++ b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx
@@ -17,8 +17,10 @@ import getNextUnusedMusicId from "@/utils/getNextUnusedMusicId";
import { useI18n } from 'vue-i18n';
import { createImportFatal, createVideoConvertWarning, getCaptureTarget, isAbortError } from "./importErrors";
import tryGetFile from "@/utils/tryGetFile";
+import { ImportDirectory } from "@/utils/importDirectory";
+import { pickDirectory } from "@/utils/pickDirectory";
-export let startProcess = (_dir?: FileSystemDirectoryHandle | FileSystemDirectoryHandle[]) => { }
+export let startProcess = (_dir?: ImportDirectory | ImportDirectory[]) => { }
export default defineComponent({
setup() {
@@ -39,7 +41,7 @@ export default defineComponent({
modalReject.value && modalReject.value({ name: 'AbortError' });
}
- const prepareFolder = async (dir: FileSystemDirectoryHandle, id: number) => {
+ const prepareFolder = async (dir: ImportDirectory, id: number) => {
let reject = false;
const maidata = await tryGetFile(dir, 'maidata.txt');
@@ -208,7 +210,7 @@ export default defineComponent({
}
}
- startProcess = async (dir?: FileSystemDirectoryHandle | FileSystemDirectoryHandle[]) => {
+ startProcess = async (dir?: ImportDirectory | ImportDirectory[]) => {
let id = getNextUnusedMusicId();
const usedIds = [] as number[];
errors.value = [];
@@ -218,18 +220,32 @@ export default defineComponent({
currentProcessing.value = dummyMeta;
try {
if (!dir) {
- dir = await window.showDirectoryPicker({
+ // pickDirectory:支持 showDirectoryPicker 时返回真实 handle,否则用 webkitdirectory 适配器
+ dir = await pickDirectory({
id: 'maidata-dir',
startIn: 'downloads',
});
}
step.value = STEP.checking;
- if (dir instanceof FileSystemDirectoryHandle && await tryGetFile(dir, 'maidata.txt')) {
+ // 不再依赖 instanceof FileSystemDirectoryHandle(适配器不是它)。
+ // 统一逻辑:单个目录句柄时,先看根目录有没有 maidata.txt,有就当作单首谱面导入;
+ // 没有(或传入的是数组)就遍历子目录。真实 handle 与适配器都走得通。
+ if (!Array.isArray(dir) && await tryGetFile(dir, 'maidata.txt')) {
await prepareFolder(dir, id);
} else {
- for await (const entry of dir.values()) {
- if (entry.kind !== 'directory') continue;
+ // 数组(拖拽多个)时直接用这些句柄;单目录时遍历其子项。两种都只取目录项。
+ const entries: (ImportDirectory)[] = [];
+ if (Array.isArray(dir)) {
+ for (const entry of dir) {
+ if (entry.kind === 'directory') entries.push(entry);
+ }
+ } else {
+ for await (const entry of dir.values()) {
+ if (entry.kind === 'directory') entries.push(entry);
+ }
+ }
+ for (const entry of entries) {
if (await prepareFolder(entry, id)) {
usedIds.push(id);
id = getNextUnusedMusicId(usedIds);
@@ -264,7 +280,9 @@ export default defineComponent({
}
} catch (e) {
if (isAbortError(e)) return
- console.log(e)
+ // WebKit 的 console 直接 log 异常对象时无法正确转文本,这里显式打印字符串便于定位
+ const err = e as any;
+ console.log('[imp] FAILED step=' + step.value + ' message=' + String(err?.message ?? err) + ' stack=' + String(err?.stack ?? '(无栈)'));
globalCapture(e, t('chart.import.error.importErrorGlobal'))
} finally {
if (step.value !== STEP.showResultError)
diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx
index 11a225dd..527b5979 100644
--- a/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx
+++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx
@@ -8,6 +8,7 @@ import api, { getUrl } from "@/client/api";
import AudioPreviewEditorButton from "@/views/Charts/MusicEdit/AudioPreviewEditorButton";
import SetMovieButton from "@/views/Charts/MusicEdit/SetMovieButton";
import { t } from "@/locales";
+import { pickFile } from "@/utils/pickFile";
export let uploadFlow = async (fileHandle?: FileSystemFileHandle) => {
@@ -34,44 +35,27 @@ export default defineComponent({
uploadFlow = async (fileHandle?: FileSystemFileHandle) => {
tipShow.value = true
try {
+ let file: File;
if (!fileHandle) {
- [fileHandle] = await window.showOpenFilePicker({
- id: 'acbawb',
- startIn: 'downloads',
- types: [
- {
- description: t('music.edit.supportedFileTypes'),
- accept: {
- "application/x-supported": [".mp3", ".wav", ".ogg", ".acb"],
- },
- },
- ],
- });
+ // 音频文件,使用通用单文件选择(兼容 WebKitGTK)
+ const picked = await pickFile('.mp3,.wav,.ogg,.acb');
+ tipShow.value = false;
+ if (!picked) return;
+ file = picked;
+ } else {
+ tipShow.value = false;
+ file = await fileHandle.getFile() as File;
}
- tipShow.value = false;
- if (!fileHandle) return;
- const file = await fileHandle.getFile() as File;
let res: HttpResponse;
if (file.name.endsWith('.acb')) {
tipSelectAwbShow.value = true;
- const [fileHandle] = await window.showOpenFilePicker({
- id: 'acbawb',
- startIn: 'downloads',
- types: [
- {
- description: t('music.edit.supportedFileTypes'),
- accept: {
- "application/x-supported": [".awb"],
- },
- },
- ],
- });
+ // 对应的 awb 文件,使用通用单文件选择(兼容 WebKitGTK)
+ const awb = await pickFile('.awb');
tipSelectAwbShow.value = false;
- if (!fileHandle) return;
+ if (!awb) return;
load.value = true;
- const awb = await fileHandle.getFile() as File;
res = await api.SetAudio(props.song.id!, selectedADir.value, { file, awb, padding: 0 });
} else {
offset.value = 0;
diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx
index c2123c47..2dc520f9 100644
--- a/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx
+++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx
@@ -1,6 +1,7 @@
import { defineComponent } from "vue";
import { selectedADir } from "@/store/refs";
import { t } from "@/locales";
+import { isPhotino } from "@/client/api";
export default defineComponent({
props: {
@@ -20,6 +21,19 @@ export default defineComponent({
const top = (screen.height - height) / 2;
const url = new URL(location.href);
url.hash = `/chart-preview?${params}`;
+
+ if (isPhotino) {
+ // WebKitGTK 不支持 window.open 弹新窗口。改为通知 Photino 宿主开一个内置 webview 子窗口加载预览页。
+ (window as any).external.sendMessage(JSON.stringify({
+ type: 'open-window',
+ url: url.toString(),
+ title: t('music.edit.previewChart'),
+ width,
+ height,
+ }));
+ return;
+ }
+
window.open(url, '_blank', `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`);
};
diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx
index fcc90896..dd8abda6 100644
--- a/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx
+++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx
@@ -8,6 +8,7 @@ import { globalCapture, selectedADir, showNeedPurchaseDialog, version } from "@/
import { fetchEventSource } from "@microsoft/fetch-event-source";
import { handleSseOpen } from "@/utils/sseOpen";
import { t } from "@/locales";
+import { pickFile } from "@/utils/pickFile";
enum STEP {
None,
@@ -80,24 +81,17 @@ export default defineComponent({
uploadFlow = async (fileHandle?: FileSystemFileHandle) => {
step.value = STEP.Select
try {
+ let file: File;
if (!fileHandle) {
- [fileHandle] = await window.showOpenFilePicker({
- id: 'movie',
- startIn: 'downloads',
- types: [
- {
- description: t('music.edit.supportedFileTypes'),
- accept: {
- "video/*": [".dat"],
- "image/*": [],
- },
- },
- ],
- });
+ // 视频/图片文件,使用通用单文件选择(兼容 WebKitGTK)
+ const picked = await pickFile('video/*,image/*,.dat');
+ step.value = STEP.None
+ if (!picked) return;
+ file = picked;
+ } else {
+ step.value = STEP.None
+ file = await fileHandle.getFile() as File;
}
- step.value = STEP.None
- if (!fileHandle) return;
- const file = await fileHandle.getFile() as File;
if (file.name.endsWith('.dat')) {
load.value = true;
@@ -168,7 +162,8 @@ export default defineComponent({
>
-
{progress.value === 100 ? t('tools.videoOptions.processing') : `${progress.value}%`}
+ {/* 进度条自带百分比,这里只在 100% 后(ffmpeg 完成、仍在打包 USM)提示「正在处理」 */}
+ {progress.value === 100 &&
{t('tools.videoOptions.processing')}
}
;
diff --git a/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx b/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx
index 77b45415..027d2444 100644
--- a/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx
+++ b/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx
@@ -6,6 +6,7 @@ import SelectFileTypeTip from "./SelectFileTypeTip";
import { globalCapture, updateAddVersionList, updateGenreList } from "@/store/refs";
import { EDIT_TYPE } from "./index";
import { useI18n } from 'vue-i18n';
+import { pickFile } from "@/utils/pickFile";
export default defineComponent({
props: {
@@ -30,23 +31,11 @@ export default defineComponent({
const startProcess = async () => {
showTip.value = true;
try {
- const [fileHandle] = await window.showOpenFilePicker({
- id: 'genreTitle',
- startIn: 'downloads',
- types: [
- {
- description: t('genre.imageDescription'),
- accept: {
- "application/jpeg": [".jpeg", ".jpg"],
- "application/png": [".png"],
- },
- },
- ],
- });
+ // 分类/版本图片,使用通用单文件选择(兼容 WebKitGTK)
+ const file = await pickFile('image/jpeg,image/png');
showTip.value = false;
- if (!fileHandle) return;
- const file = await fileHandle.getFile();
+ if (!file) return;
await (props.type === EDIT_TYPE.Genre ? api.SetGenreTitleImage : api.SetVersionTitleImage)({id: props.genre.id!, image: file});
await updateGenreList();
diff --git a/MaiChartManager/Front/src/views/Index.tsx b/MaiChartManager/Front/src/views/Index.tsx
index 2a8ee4d0..014c31ef 100644
--- a/MaiChartManager/Front/src/views/Index.tsx
+++ b/MaiChartManager/Front/src/views/Index.tsx
@@ -15,6 +15,7 @@ import Settings from './Settings';
import Splash from '@/components/Splash';
import { ensureBackendUrl } from '@/utils/ensureBackendUrl';
import ChangelogModal from '@/components/ChangelogModal';
+import { isLocalHost } from '@/client/api';
export default defineComponent({
setup() {
@@ -30,7 +31,9 @@ export default defineComponent({
});
});
- if (window.showDirectoryPicker === undefined) {
+ // 本地宿主(Windows WebView2 / Photino)下目录操作走后端原生对话框,不需要 File System Access API;
+ // 仅当是远程浏览器时才检测并提示浏览器不支持
+ if (!isLocalHost && window.showDirectoryPicker === undefined) {
const showError = () => {
showTransactionalDialog(
t('error.browserUnsupported.title'),
diff --git a/MaiChartManager/Libs/AssetStudio.PInvoke.dll b/MaiChartManager/Libs/AssetStudio.PInvoke.dll
deleted file mode 100644
index d91e3145..00000000
Binary files a/MaiChartManager/Libs/AssetStudio.PInvoke.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/AssetStudio.dll b/MaiChartManager/Libs/AssetStudio.dll
deleted file mode 100644
index bbc1147b..00000000
Binary files a/MaiChartManager/Libs/AssetStudio.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/AssetStudioFBXWrapper.dll b/MaiChartManager/Libs/AssetStudioFBXWrapper.dll
deleted file mode 100644
index bcbdc71a..00000000
Binary files a/MaiChartManager/Libs/AssetStudioFBXWrapper.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/AssetStudioUtility.dll b/MaiChartManager/Libs/AssetStudioUtility.dll
deleted file mode 100644
index 4922e624..00000000
Binary files a/MaiChartManager/Libs/AssetStudioUtility.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/K4os.Compression.LZ4.dll b/MaiChartManager/Libs/K4os.Compression.LZ4.dll
deleted file mode 100644
index 96542a6e..00000000
Binary files a/MaiChartManager/Libs/K4os.Compression.LZ4.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/Mono.Cecil.Mdb.dll b/MaiChartManager/Libs/Mono.Cecil.Mdb.dll
deleted file mode 100644
index 7e1a0eab..00000000
Binary files a/MaiChartManager/Libs/Mono.Cecil.Mdb.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/Mono.Cecil.Pdb.dll b/MaiChartManager/Libs/Mono.Cecil.Pdb.dll
deleted file mode 100644
index f001d333..00000000
Binary files a/MaiChartManager/Libs/Mono.Cecil.Pdb.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/Mono.Cecil.Rocks.dll b/MaiChartManager/Libs/Mono.Cecil.Rocks.dll
deleted file mode 100644
index 74061b83..00000000
Binary files a/MaiChartManager/Libs/Mono.Cecil.Rocks.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/Mono.Cecil.dll b/MaiChartManager/Libs/Mono.Cecil.dll
deleted file mode 100644
index f583a788..00000000
Binary files a/MaiChartManager/Libs/Mono.Cecil.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/Texture2DDecoderWrapper.dll b/MaiChartManager/Libs/Texture2DDecoderWrapper.dll
deleted file mode 100644
index a6947251..00000000
Binary files a/MaiChartManager/Libs/Texture2DDecoderWrapper.dll and /dev/null differ
diff --git a/MaiChartManager/Libs/WinBlur.dll b/MaiChartManager/Libs/WinBlur.dll
deleted file mode 100644
index 8ccf8e3e..00000000
Binary files a/MaiChartManager/Libs/WinBlur.dll and /dev/null differ
diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs
new file mode 100644
index 00000000..9e3ce943
--- /dev/null
+++ b/MaiChartManager/LinuxProgram.cs
@@ -0,0 +1,181 @@
+#if !WINDOWS
+using System.Globalization;
+using System.Text.Json;
+using Photino.NET;
+using FFMpegCore;
+
+namespace MaiChartManager;
+
+public static class LinuxProgram
+{
+ public static void Main(string[] args)
+ {
+ Directory.CreateDirectory(StaticSettings.appData);
+ Directory.CreateDirectory(StaticSettings.tempPath);
+ InitConfiguration();
+ ConfigureFfmpeg();
+
+ // 启动进程内 Kestrel:loopback + 伺服 SPA(wwwroot)+ API 同源,但不开 LAN 端口。
+ // Kestrel 在后台线程运行(StartApp 内部 Task.Run),主线程留给 Photino 开窗。
+ var serverReady = new ManualResetEventSlim(false);
+ string? backendUrl = null;
+ ServerManager.StartApp(export: false, serveSpa: true, onStart: url =>
+ {
+ backendUrl = url;
+ serverReady.Set();
+ });
+
+ // 等待后端就绪拿到 loopback url,超时 30 秒视为启动失败。
+ if (!serverReady.Wait(TimeSpan.FromSeconds(30)) || string.IsNullOrWhiteSpace(backendUrl))
+ {
+ Console.Error.WriteLine("后端在 30 秒内未能就绪,退出。");
+ Environment.Exit(1);
+ return;
+ }
+
+ Console.WriteLine($"MaiChartManager backend listening at {backendUrl}");
+
+ // 决定初始路由(对齐 Windows AppMain 的逻辑):
+ // 未配置有效游戏目录时加载 OOBE 引导页(#/oobe),否则加载主界面(根路由)。
+ // 直接加载主界面会让 SPA 立刻调用依赖 GamePath 的接口,导致一连串异常。
+ var startUrl = string.IsNullOrEmpty(StaticSettings.GamePath)
+ ? $"{backendUrl.TrimEnd('/')}/#/oobe"
+ : backendUrl;
+
+ // Photino 必须在主线程创建并显示窗口。Linux 下底层走系统 WebKitGTK。
+ // 加载 Kestrel 的 loopback 地址:SPA 与 API 同源,前端无需注入 backendUrl。
+ var window = new PhotinoWindow()
+ .SetTitle("MaiChartManager")
+ .SetUseOsDefaultSize(false)
+ .SetSize(1600, 800)
+ .Center()
+ .Load(new Uri(startUrl));
+
+ // 把窗口实例交给平台服务持有者,供 Linux 的对话框服务与应用外壳(导航/标题等)使用。
+ Platform.Linux.PhotinoWindowHolder.Current = window;
+
+ // 处理前端发来的「开新窗口」请求(预览谱面等)。WebKitGTK 不支持 window.open,
+ // 前端改为 window.external.sendMessage 通知宿主,由宿主开一个内置 webview 子窗口。
+ window.RegisterWebMessageReceivedHandler((sender, message) => HandleWebMessage(window, message));
+
+ window.WaitForClose();
+ }
+
+ ///
+ /// 处理前端通过 window.external.sendMessage 发来的消息。
+ /// 目前支持 { type:"open-window", url, title, width, height }:开一个内置 webview 子窗口加载 url。
+ ///
+ private static void HandleWebMessage(PhotinoWindow parent, string message)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(message);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("type", out var typeProp) || typeProp.GetString() != "open-window") return;
+
+ var url = root.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(url)) return;
+ var title = root.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "MaiChartManager" : "MaiChartManager";
+ var width = root.TryGetProperty("width", out var wProp) && wProp.TryGetInt32(out var w) ? w : 960;
+ var height = root.TryGetProperty("height", out var hProp) && hProp.TryGetInt32(out var h) ? h : 640;
+
+ // 在宿主 UI 线程上创建子窗口(消息回调本身就在 UI 线程)。
+ // child.WaitForClose() 会进入一个嵌套的 GTK 事件循环:父窗口仍可交互,子窗口关闭后返回。
+ var child = new PhotinoWindow(parent)
+ .SetTitle(title)
+ .SetUseOsDefaultSize(false)
+ .SetSize(width, height)
+ .Center()
+ .Load(new Uri(url));
+ child.WaitForClose();
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"处理 WebMessage 失败:{e}");
+ }
+ }
+
+ ///
+ /// 配置 FFMpegCore 使用系统 ffmpeg/ffprobe。
+ /// Windows 版在 AppMain 里指向内置的 ffmpeg.exe;Linux 不内置,改用系统 PATH 里的 ffmpeg 所在目录。
+ /// FFMpegCore 用参数数组传给 ffmpeg(无引号问题),按 OS 自动补可执行名后缀。
+ /// 公开以便 CLI 等其它 Linux 入口复用。
+ ///
+ public static void ConfigureFfmpeg()
+ {
+ var dir = ResolveExecutableDir("ffmpeg") ?? "/usr/bin";
+ GlobalFFOptions.Configure(o =>
+ {
+ o.BinaryFolder = dir;
+ o.TemporaryFilesFolder = StaticSettings.tempPath;
+ });
+ // 检测硬件加速(与 Windows 的 AppMain 一致,失败不影响主流程)
+ _ = MaiChartManager.Utils.VideoConvert.CheckHardwareAcceleration();
+ }
+
+ /// 在 $PATH 中查找可执行文件所在目录,找不到返回 null。
+ private static string? ResolveExecutableDir(string exe)
+ {
+ var path = Environment.GetEnvironmentVariable("PATH");
+ if (string.IsNullOrEmpty(path)) return null;
+ foreach (var d in path.Split(Path.PathSeparator))
+ {
+ if (string.IsNullOrWhiteSpace(d)) continue;
+ if (File.Exists(Path.Combine(d, exe))) return d;
+ }
+ return null;
+ }
+
+ ///
+ /// Linux 的最小化无头配置加载。对应 AppMain.InitConfiguration,但去掉了
+ /// Sentry / MessageBox / WinForms 相关部分(这些代码在被排除的 AppMain.cs 中)。
+ /// 公开以便 CLI 等其它 Linux 入口复用。
+ ///
+ public static void InitConfiguration()
+ {
+ var cfgFilePath = Path.Combine(StaticSettings.appData, "config.json");
+ if (File.Exists(cfgFilePath))
+ {
+ try
+ {
+ var cfg = JsonSerializer.Deserialize(File.ReadAllText(cfgFilePath));
+ if (cfg != null)
+ {
+ StaticSettings.Config = cfg;
+ }
+ }
+ catch
+ {
+ // 配置文件损坏:丢弃并使用默认值继续(进入 OOBE 流程)。
+ try { File.Delete(cfgFilePath); }
+ catch { /* ignore */ }
+ }
+ }
+
+ // 应用持久化的语言区域(AppMain.SetLocale 仅限 Windows)。
+ var locale = string.IsNullOrWhiteSpace(StaticSettings.Config.Locale) ? "zh" : StaticSettings.Config.Locale;
+ if (locale != "zh" && locale != "zh-TW" && locale != "en")
+ locale = "zh";
+
+ StaticSettings.CurrentLocale = locale;
+ StaticSettings.Config.Locale = locale;
+
+ var culture = locale switch
+ {
+ "zh" => new CultureInfo("zh-CN"),
+ "zh-TW" => new CultureInfo("zh-TW"),
+ _ => new CultureInfo("en-US"),
+ };
+ Locale.Culture = culture;
+ CultureInfo.CurrentCulture = culture;
+ CultureInfo.CurrentUICulture = culture;
+ MuConvert.utils.Utils.SetLocale(new CultureInfo(locale));
+
+ // 如果已持久化有效的游戏路径,则恢复它,使应用以管理模式启动。
+ if (!string.IsNullOrWhiteSpace(StaticSettings.Config.GamePath) && Directory.Exists(StaticSettings.Config.GamePath))
+ {
+ StaticSettings.GamePath = StaticSettings.Config.GamePath;
+ }
+ }
+}
+#endif
diff --git a/MaiChartManager/Locale.zh-hans.resx b/MaiChartManager/Locale.zh-Hans.resx
similarity index 100%
rename from MaiChartManager/Locale.zh-hans.resx
rename to MaiChartManager/Locale.zh-Hans.resx
diff --git a/MaiChartManager/Locale.zh-hant.resx b/MaiChartManager/Locale.zh-Hant.resx
similarity index 100%
rename from MaiChartManager/Locale.zh-hant.resx
rename to MaiChartManager/Locale.zh-Hant.resx
diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj
index 8dad9d20..e7dfa550 100644
--- a/MaiChartManager/MaiChartManager.csproj
+++ b/MaiChartManager/MaiChartManager.csproj
@@ -1,31 +1,49 @@
- net10.0-windows10.0.17763.0
+ true
enable
enable
false
x64
True
True
- WinExe
False
- win-x64
False
False
true
true
- MaiChartManager.Program
False
PerMonitorV2
- true
NU1605
true
- Debug;Release;Crack
+ Debug;Release;Crack;LinuxDebug;LinuxRelease
..\Packaging\Pack
true
false
icon.ico
+
+ $(DefineConstants);WINDOWS
+ net10.0-windows10.0.17763.0
+ WinExe
+ win-x64
+ true
+ MaiChartManager.Program
+
+
+ net10.0
+ Exe
+ linux-x64
+ MaiChartManager.LinuxProgram
+
+ false
+
+ false
+
False
TRACE;CRACK
@@ -33,7 +51,7 @@
True
-
+
true
@@ -41,53 +59,72 @@
CRACK
true
+
+ False
+ TRACE
+
+
+ True
+ true
+
x64
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Form
-
+
Form
@@ -96,33 +133,49 @@
True
Resources.resx
-
True
True
Locale.resx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
LICENSE
PreserveNewest
-
-
-
-
- PreserveNewest
-
+
+
PreserveNewest
-
+
+
%(Filename)%(Extension)
PreserveNewest
@@ -133,18 +186,8 @@
%(Filename)%(Extension)
PreserveNewest
-
-
-
-
- Libs\AssetStudio.dll
-
-
- Libs\AssetStudioUtility.dll
-
-
- Libs\Mono.Cecil.dll
-
+
@@ -155,11 +198,11 @@
ResXFileCodeGenerator
Locale.Designer.cs
-
- Locale.resx
-
-
- Locale.resx
-
+
+ Locale.resx
+
+
+ Locale.resx
+
-
+
\ No newline at end of file
diff --git a/MaiChartManager/Models/GenreXml.cs b/MaiChartManager/Models/GenreXml.cs
index 557600fe..1a3cbe88 100644
--- a/MaiChartManager/Models/GenreXml.cs
+++ b/MaiChartManager/Models/GenreXml.cs
@@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using System.Xml;
-using Microsoft.VisualBasic.FileIO;
namespace MaiChartManager.Models;
@@ -12,7 +11,7 @@ public class GenreXml
// name.str 在游戏里不会被用到
public int Id { get; }
- public string FilePath => Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicGenre/musicgenre{Id:000000}/MusicGenre.xml");
+ public string FilePath => MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicGenre", $"musicgenre{Id:000000}", "MusicGenre.xml");
public GenreXml(int id, string assetDir, string gamePath)
{
@@ -25,7 +24,7 @@ public GenreXml(int id, string assetDir, string gamePath)
public static GenreXml CreateNew(int id, string assetDir, string gamePath)
{
- var dir = Path.Combine(gamePath, @"Sinmai_Data\StreamingAssets", assetDir, $"musicGenre/musicgenre{id:000000}");
+ var dir = MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, "musicGenre", $"musicgenre{id:000000}");
Directory.CreateDirectory(dir);
var text = $"""
@@ -96,6 +95,7 @@ public void Save()
public void Delete()
{
- FileSystem.DeleteDirectory(Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicGenre/musicgenre{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin);
+ // 跨平台删除(Windows 进回收站,Linux 直接删除)
+ MaiChartManager.Platform.PlatformFile.DeleteDirectory(MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicGenre", $"musicgenre{Id:000000}"));
}
}
diff --git a/MaiChartManager/Models/MusicXml.cs b/MaiChartManager/Models/MusicXml.cs
index 4996871c..df8768d0 100644
--- a/MaiChartManager/Models/MusicXml.cs
+++ b/MaiChartManager/Models/MusicXml.cs
@@ -203,7 +203,7 @@ public static MusicXml CreateNew(int dxId, string gamePath, string assetDir)
0
""";
- var path = Path.Combine(gamePath, @"Sinmai_Data\StreamingAssets", assetDir, $@"music\music{dxId:000000}");
+ var path = Path.Combine(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, "music", $"music{dxId:000000}");
Directory.CreateDirectory(path);
File.WriteAllText(Path.Combine(path, "Music.xml"), data);
return new MusicXml(Path.Combine(path, "Music.xml"), gamePath);
diff --git a/MaiChartManager/Models/MusicXmlWithABJacket.cs b/MaiChartManager/Models/MusicXmlWithABJacket.cs
index 75b1e580..d6e1f567 100644
--- a/MaiChartManager/Models/MusicXmlWithABJacket.cs
+++ b/MaiChartManager/Models/MusicXmlWithABJacket.cs
@@ -1,5 +1,6 @@
using System.Xml;
-using Microsoft.VisualBasic.FileIO;
+using MaiChartManager.Platform;
+using MaiChartManager.Utils;
namespace MaiChartManager.Models;
@@ -123,16 +124,16 @@ public List Problems
internal bool DeleteJacket()
{
- bool shouldDelete = HasJacket && RealJacketPath?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false;
+ bool shouldDelete = HasJacket && PathUtils.ContainsSegment(RealJacketPath, "A000") == false;
if (!shouldDelete) return false;
var assetBundleJacket = this.AssetBundleJacket;
Console.WriteLine("删除 jacket: " + RealJacketPath);
try
{
- FileSystem.DeleteFile(RealJacketPath);
+ PlatformFile.DeleteFile(RealJacketPath);
if (RealJacketPath.EndsWith(".ab") && File.Exists(RealJacketPath + ".manifest")) // .ab的情况,要额外把manifest也干掉
- FileSystem.DeleteFile(RealJacketPath + ".manifest");
+ PlatformFile.DeleteFile(RealJacketPath + ".manifest");
}
catch
{
@@ -143,7 +144,7 @@ internal bool DeleteJacket()
StaticSettings.AssetBundleJacketMap.Remove(NonDxId);
StaticSettings.PseudoAssetBundleJacketMap.Remove(NonDxId);
- // Issue #42: AB jackets come with a companion jacket_s/ui_jacket_xxx_s.ab, delete it too to avoid orphan
+ // Issue #42: AB 封面带有同级目录 jacket_s/ui_jacket_xxx_s.ab,一并删除以避免孤立文件
if (assetBundleJacket is not null)
{
var abDir = Path.GetDirectoryName(assetBundleJacket);
@@ -152,14 +153,14 @@ internal bool DeleteJacket()
{
var jacketSPath = Path.Combine(parentDir, "jacket_s",
Path.GetFileNameWithoutExtension(assetBundleJacket) + "_s" + Path.GetExtension(assetBundleJacket));
- if (File.Exists(jacketSPath) && !jacketSPath.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase))
+ if (File.Exists(jacketSPath) && !PathUtils.ContainsSegment(jacketSPath, "A000"))
{
Console.WriteLine("删除 jacket_s: " + jacketSPath);
try
{
- FileSystem.DeleteFile(jacketSPath);
+ PlatformFile.DeleteFile(jacketSPath);
if (File.Exists(jacketSPath + ".manifest"))
- FileSystem.DeleteFile(jacketSPath + ".manifest");
+ PlatformFile.DeleteFile(jacketSPath + ".manifest");
}
catch
{
@@ -175,12 +176,12 @@ public void Delete()
{
DeleteJacket();
- if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.acb", out var acb) && acb?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false)
+ if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.acb", out var acb) && PathUtils.ContainsSegment(acb, "A000") == false)
{
Console.WriteLine("删除 acb: " + acb);
try
{
- FileSystem.DeleteFile(acb);
+ PlatformFile.DeleteFile(acb);
}
catch
{
@@ -188,12 +189,12 @@ public void Delete()
}
}
- if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.awb", out var awb) && awb?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false)
+ if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.awb", out var awb) && PathUtils.ContainsSegment(awb, "A000") == false)
{
Console.WriteLine("删除 awb: " + awb);
try
{
- FileSystem.DeleteFile(awb);
+ PlatformFile.DeleteFile(awb);
}
catch
{
@@ -201,12 +202,12 @@ public void Delete()
}
}
- if (StaticSettings.MovieDataMap.TryGetValue(NonDxId, out var movieData) && movieData?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false)
+ if (StaticSettings.MovieDataMap.TryGetValue(NonDxId, out var movieData) && PathUtils.ContainsSegment(movieData, "A000") == false)
{
Console.WriteLine("删除 movieData: " + movieData);
try
{
- FileSystem.DeleteFile(movieData);
+ PlatformFile.DeleteFile(movieData);
}
catch
{
@@ -217,7 +218,7 @@ public void Delete()
try
{
Console.WriteLine("删除目录: " + Path.GetDirectoryName(FilePath));
- FileSystem.DeleteDirectory(Path.GetDirectoryName(FilePath), DeleteDirectoryOption.DeleteAllContents);
+ PlatformFile.DeleteDirectoryPermanent(Path.GetDirectoryName(FilePath));
}
catch
{
diff --git a/MaiChartManager/Models/VersionXml.cs b/MaiChartManager/Models/VersionXml.cs
index 72b04e04..2ecccd6f 100644
--- a/MaiChartManager/Models/VersionXml.cs
+++ b/MaiChartManager/Models/VersionXml.cs
@@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using System.Xml;
-using Microsoft.VisualBasic.FileIO;
namespace MaiChartManager.Models;
@@ -12,7 +11,7 @@ public class VersionXml
// name.str 在游戏里不会被用到
public int Id { get; }
- public string FilePath => Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicVersion/MusicVersion{Id:000000}/MusicVersion.xml");
+ public string FilePath => MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicVersion", $"MusicVersion{Id:000000}", "MusicVersion.xml");
public VersionXml(int id, string assetDir, string gamePath)
{
@@ -25,7 +24,7 @@ public VersionXml(int id, string assetDir, string gamePath)
public static VersionXml CreateNew(int id, string assetDir, string gamePath)
{
- var dir = Path.Combine(gamePath, @"Sinmai_Data\StreamingAssets", assetDir, $"musicVersion/MusicVersion{id:000000}");
+ var dir = MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, "musicVersion", $"MusicVersion{id:000000}");
Directory.CreateDirectory(dir);
var text = $"""
@@ -103,6 +102,7 @@ public void Save()
public void Delete()
{
- FileSystem.DeleteDirectory(Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicVersion/MusicVersion{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin);
+ // 跨平台删除(Windows 进回收站,Linux 直接删除)
+ MaiChartManager.Platform.PlatformFile.DeleteDirectory(MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicVersion", $"MusicVersion{Id:000000}"));
}
}
diff --git a/MaiChartManager/Platform/IAppShell.cs b/MaiChartManager/Platform/IAppShell.cs
new file mode 100644
index 00000000..541fcb66
--- /dev/null
+++ b/MaiChartManager/Platform/IAppShell.cs
@@ -0,0 +1,39 @@
+namespace MaiChartManager.Platform;
+
+///
+/// Web 控制器使用的桌面外壳 / 原生窗口操作接口。
+/// 在 Windows 上委托给 WinForms(AppLifecycleManager / AppMain / Browser / Application / UWP StartupTask)。
+/// 在 Linux 上为空操作或返回默认值(第三阶段 Photino 会接入真正的原生行为)。
+///
+public interface IAppShell
+{
+ /// 显示(或聚焦并刷新)指定回环地址的主浏览器窗口。
+ void ShowBrowser(string loopbackUrl);
+
+ /// 切换到指定回环地址的 OOBE / 模式切换窗口。
+ void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode");
+
+ /// 关闭并释放 OOBE 浏览器窗口(若存在)。
+ void CloseOobeBrowser();
+
+ /// 向 OOBE 浏览器窗口注入(可能已更新的)后端地址。
+ void InjectOobeBackendUrl(string loopbackUrl);
+
+ /// 根据当前游戏路径更新主窗口标题。
+ void UpdateMainWindowTitle(string gamePath);
+
+ /// 显示 / 隐藏托盘图标(导出模式 + 开机启动模式)。
+ void DisposeTrayIcon();
+
+ /// 启用或禁用系统"开机自启"任务,成功返回 true。
+ Task SetStartupEnabledAsync(bool enabled);
+
+ /// 将语言区域变更应用到原生 UI(窗口装饰、内嵌库等)。
+ void ReloadLocale(string locale);
+
+ /// 主窗口的 DPI 缩放比例,用于报告默认 UI 缩放值。
+ double GetTargetDpiScale();
+
+ /// 退出整个应用程序。
+ void ExitApp();
+}
diff --git a/MaiChartManager/Platform/IDesktopDialogService.cs b/MaiChartManager/Platform/IDesktopDialogService.cs
new file mode 100644
index 00000000..7882a013
--- /dev/null
+++ b/MaiChartManager/Platform/IDesktopDialogService.cs
@@ -0,0 +1,9 @@
+namespace MaiChartManager.Platform;
+
+public interface IDesktopDialogService
+{
+ string? PickFolder(string? title = null);
+ string? PickFile(string? title = null, string? filter = null);
+ bool Confirm(string message, string title, bool defaultResult = false);
+ void ShowError(string message, string title);
+}
diff --git a/MaiChartManager/Platform/IProgressController.cs b/MaiChartManager/Platform/IProgressController.cs
new file mode 100644
index 00000000..5adeffd6
--- /dev/null
+++ b/MaiChartManager/Platform/IProgressController.cs
@@ -0,0 +1,13 @@
+namespace MaiChartManager.Platform;
+
+public interface IProgressController
+{
+ /// 创建并显示一个可取消的批量进度会话;Dispose 时关闭。
+ IProgressSession Begin(string title, string description, string cancelMessage);
+}
+
+public interface IProgressSession : IDisposable
+{
+ void Report(ulong value, ulong total, string? detail = null);
+ bool IsCancelled { get; }
+}
diff --git a/MaiChartManager/Platform/IShellService.cs b/MaiChartManager/Platform/IShellService.cs
new file mode 100644
index 00000000..24eb02d8
--- /dev/null
+++ b/MaiChartManager/Platform/IShellService.cs
@@ -0,0 +1,8 @@
+namespace MaiChartManager.Platform;
+
+public interface IShellService
+{
+ void RevealInFileManager(string path);
+ void OpenUrl(string url);
+ void OpenPath(string path);
+}
diff --git a/MaiChartManager/Platform/ITaskbarProgress.cs b/MaiChartManager/Platform/ITaskbarProgress.cs
new file mode 100644
index 00000000..c3729347
--- /dev/null
+++ b/MaiChartManager/Platform/ITaskbarProgress.cs
@@ -0,0 +1,8 @@
+namespace MaiChartManager.Platform;
+
+public interface ITaskbarProgress
+{
+ void Set(ulong value, ulong total = 100);
+ void SetIndeterminate();
+ void Clear();
+}
diff --git a/MaiChartManager/Platform/Linux/HeadlessProgressController.cs b/MaiChartManager/Platform/Linux/HeadlessProgressController.cs
new file mode 100644
index 00000000..2cdc4117
--- /dev/null
+++ b/MaiChartManager/Platform/Linux/HeadlessProgressController.cs
@@ -0,0 +1,24 @@
+using Microsoft.Extensions.Logging;
+
+namespace MaiChartManager.Platform.Linux;
+
+public class HeadlessProgressController(ILogger logger) : IProgressController
+{
+ public IProgressSession Begin(string title, string description, string cancelMessage)
+ {
+ logger.LogInformation("{title}: {description}", title, description);
+ return new HeadlessProgressSession(logger);
+ }
+}
+
+public sealed class HeadlessProgressSession(ILogger logger) : IProgressSession
+{
+ public void Report(ulong value, ulong total, string? detail = null)
+ {
+ if (detail is not null)
+ logger.LogInformation("Progress {value}/{total}: {detail}", value, total, detail);
+ }
+
+ public bool IsCancelled => false;
+ public void Dispose() { }
+}
diff --git a/MaiChartManager/Platform/Linux/LinuxShellService.cs b/MaiChartManager/Platform/Linux/LinuxShellService.cs
new file mode 100644
index 00000000..81d7c673
--- /dev/null
+++ b/MaiChartManager/Platform/Linux/LinuxShellService.cs
@@ -0,0 +1,32 @@
+using System.Diagnostics;
+using MaiChartManager.Platform;
+using Microsoft.Extensions.Logging;
+
+namespace MaiChartManager.Platform.Linux;
+
+/// 通过 xdg-open / xdg-utils 实现 Linux 系统集成。
+public class LinuxShellService(ILogger logger) : IShellService
+{
+ public void RevealInFileManager(string path)
+ {
+ // Linux 文件管理器没有通用的"选中文件"功能;改为打开所在目录。
+ var target = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path;
+ XdgOpen(target);
+ }
+
+ public void OpenUrl(string url) => XdgOpen(url);
+
+ public void OpenPath(string path) => XdgOpen(path);
+
+ private void XdgOpen(string arg)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo("xdg-open", $"\"{arg}\"") { UseShellExecute = false });
+ }
+ catch (Exception e)
+ {
+ logger.LogWarning(e, "Failed to xdg-open {Arg}", arg);
+ }
+ }
+}
diff --git a/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs b/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs
new file mode 100644
index 00000000..321c27a3
--- /dev/null
+++ b/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs
@@ -0,0 +1,11 @@
+using MaiChartManager.Platform;
+
+namespace MaiChartManager.Platform.Linux;
+
+/// Linux 上的空操作任务栏进度(无 Windows 任务栏)。
+public class NoopTaskbarProgress : ITaskbarProgress
+{
+ public void Set(ulong value, ulong total = 100) { }
+ public void SetIndeterminate() { }
+ public void Clear() { }
+}
diff --git a/MaiChartManager/Platform/Linux/PhotinoAppShell.cs b/MaiChartManager/Platform/Linux/PhotinoAppShell.cs
new file mode 100644
index 00000000..aee40261
--- /dev/null
+++ b/MaiChartManager/Platform/Linux/PhotinoAppShell.cs
@@ -0,0 +1,106 @@
+#if !WINDOWS
+using MaiChartManager.Platform;
+using Microsoft.Extensions.Logging;
+
+namespace MaiChartManager.Platform.Linux;
+
+///
+/// Linux 下基于 Photino 单窗口的应用外壳实现。
+/// Windows 是多窗口(OOBE 窗口 + 主窗口),Linux 只有一个 Photino 窗口,
+/// 因此「打开主界面 / 切换模式」等操作统一转化为对同一个窗口的导航(Load)。
+/// 托盘 / 开机启动等 Windows 专属能力在 Linux 上为空操作。
+///
+public class PhotinoAppShell(ILogger logger) : IAppShell
+{
+ /// 在窗口的 UI 线程上把窗口导航到指定地址(Photino 的 Load 需在 UI 线程调用)。
+ private void Navigate(string targetUrl)
+ {
+ var window = PhotinoWindowHolder.Current;
+ if (window is null)
+ {
+ logger.LogWarning("Photino 窗口尚未就绪,无法导航到 {Url}", targetUrl);
+ return;
+ }
+
+ window.Invoke(() =>
+ {
+ try
+ {
+ window.Load(new Uri(targetUrl));
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "导航到 {Url} 失败", targetUrl);
+ }
+ });
+ }
+
+ /// 拼出 SPA 的 hash 路由地址,例如 http://127.0.0.1:port/#/oobe
+ private static string HashUrl(string loopbackUrl, string hash)
+ => $"{loopbackUrl.TrimEnd('/')}/#{hash}";
+
+ // 打开主界面:导航到 loopback 根路由,SPA 进入主界面(此时 GamePath 已配置)。
+ public void ShowBrowser(string loopbackUrl) => Navigate(loopbackUrl);
+
+ // 切换模式:导航到对应 hash 路由(单窗口,等价于在当前窗口换路由)。
+ public void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode")
+ => Navigate(HashUrl(loopbackUrl, hash));
+
+ // 单窗口模型下没有独立的 OOBE 窗口,ShowBrowser 已经完成了导航,这里无需操作。
+ public void CloseOobeBrowser()
+ => logger.LogDebug("CloseOobeBrowser:Linux 单窗口,无需关闭独立 OOBE 窗口");
+
+ // 局域网(export)模式下后端会重启并换端口,需要把窗口导航到新的 OOBE 地址以重新连接。
+ public void InjectOobeBackendUrl(string loopbackUrl)
+ => Navigate(HashUrl(loopbackUrl, "/oobe"));
+
+ public void UpdateMainWindowTitle(string gamePath)
+ {
+ var window = PhotinoWindowHolder.Current;
+ if (window is null) return;
+ window.Invoke(() =>
+ {
+ try
+ {
+ window.SetTitle($"MaiChartManager ({gamePath})");
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "设置窗口标题失败");
+ }
+ });
+ }
+
+ // Linux 不做托盘。
+ public void DisposeTrayIcon() => logger.LogDebug("DisposeTrayIcon:Linux 无托盘,空操作");
+
+ // Linux 不做开机自启。
+ public Task SetStartupEnabledAsync(bool enabled)
+ {
+ logger.LogInformation("SetStartupEnabledAsync:Linux 不支持开机自启,返回 false(请求值 {Enabled})", enabled);
+ return Task.FromResult(false);
+ }
+
+ // 语言切换由前端通过接口自行处理;Linux 原生窗口无需额外刷新。
+ public void ReloadLocale(string locale) => logger.LogDebug("ReloadLocale:Linux 无需刷新原生 UI({Locale})", locale);
+
+ // Linux 暂不实现 DPI 缩放上报,返回 1.0。
+ public double GetTargetDpiScale() => 1.0;
+
+ public void ExitApp()
+ {
+ var window = PhotinoWindowHolder.Current;
+ if (window is null)
+ {
+ Environment.Exit(0);
+ return;
+ }
+ window.Invoke(() =>
+ {
+ try { window.Close(); }
+ catch (Exception e) { logger.LogError(e, "关闭窗口失败"); Environment.Exit(0); }
+ });
+ }
+}
+
+#endif
diff --git a/MaiChartManager/Platform/Linux/PhotinoDialogService.cs b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs
new file mode 100644
index 00000000..89a59a21
--- /dev/null
+++ b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs
@@ -0,0 +1,155 @@
+#if !WINDOWS
+using MaiChartManager.Platform;
+using Microsoft.Extensions.Logging;
+using Photino.NET;
+
+namespace MaiChartManager.Platform.Linux;
+
+///
+/// Linux 平台基于 Photino 原生对话框的 实现。
+/// 底层走 WebKitGTK,弹出 GTK 原生的文件/文件夹选择与消息框。
+///
+///
+/// Photino 的对话框是 的实例方法,且必须在窗口的 UI 线程上执行。
+/// Controller 在 Kestrel 的请求线程调用本服务,因此这里通过
+/// 把调用 marshal 到 UI 线程,并用 阻塞等待结果返回。
+///
+public class PhotinoDialogService(ILogger logger) : IDesktopDialogService
+{
+ public string? PickFolder(string? title = null)
+ {
+ var window = PhotinoWindowHolder.Current;
+ if (window is null)
+ {
+ // 理论上不会发生:窗口在 LinuxProgram.Main 创建后即赋值。
+ logger.LogWarning("PickFolder:Photino 主窗口尚未就绪,返回 null。title={Title}", title);
+ return null;
+ }
+
+ string[]? result = null;
+ var done = new ManualResetEventSlim();
+ // 上次选过的目录作为初始目录(没有则交给系统默认)
+ var startDir = StaticSettings.Config.LastDialogFolder is { Length: > 0 } d && Directory.Exists(d) ? d : "";
+ // 必须在 UI 线程调用 ShowOpenFolder。
+ window.Invoke(() =>
+ {
+ try
+ {
+ // ShowOpenFolder(string title, string defaultPath, bool multiSelect)
+ result = window.ShowOpenFolder(title ?? "", startDir, false);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "PickFolder:弹出文件夹选择对话框失败。");
+ }
+ finally
+ {
+ done.Set();
+ }
+ });
+ done.Wait();
+ var picked = result is { Length: > 0 } ? result[0] : null;
+ if (picked is not null)
+ {
+ // 记住本次目录,下次从这里开始
+ StaticSettings.Config.LastDialogFolder = picked;
+ try { StaticSettings.Config.Save(); } catch (Exception e) { logger.LogWarning(e, "保存 LastDialogFolder 失败"); }
+ }
+ return picked;
+ }
+
+ public string? PickFile(string? title = null, string? filter = null)
+ {
+ var window = PhotinoWindowHolder.Current;
+ if (window is null)
+ {
+ logger.LogWarning("PickFile:Photino 主窗口尚未就绪,返回 null。title={Title}", title);
+ return null;
+ }
+
+ string[]? result = null;
+ var done = new ManualResetEventSlim();
+ window.Invoke(() =>
+ {
+ try
+ {
+ // 忽略 WinForms 风格的 filter 字符串,传 null 表示允许所有文件,
+ // 避免 WinForms→Photino 的 filter 格式转换出错。
+ // ShowOpenFile(string title, string defaultPath, bool multiSelect, (string,string[])[] filters)
+ result = window.ShowOpenFile(title ?? "", null, false, null);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "PickFile:弹出文件选择对话框失败。");
+ }
+ finally
+ {
+ done.Set();
+ }
+ });
+ done.Wait();
+ return result is { Length: > 0 } ? result[0] : null;
+ }
+
+ public bool Confirm(string message, string title, bool defaultResult = false)
+ {
+ var window = PhotinoWindowHolder.Current;
+ if (window is null)
+ {
+ logger.LogWarning("Confirm:Photino 主窗口尚未就绪,返回默认值 {Default}。title={Title}", defaultResult, title);
+ return defaultResult;
+ }
+
+ var confirmed = false;
+ var done = new ManualResetEventSlim();
+ window.Invoke(() =>
+ {
+ try
+ {
+ // ShowMessage(string title, string text, PhotinoDialogButtons buttons, PhotinoDialogIcon icon)
+ var ret = window.ShowMessage(title, message, PhotinoDialogButtons.YesNo, PhotinoDialogIcon.Question);
+ confirmed = ret == PhotinoDialogResult.Yes;
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Confirm:弹出确认对话框失败,回退到默认值 {Default}。", defaultResult);
+ confirmed = defaultResult;
+ }
+ finally
+ {
+ done.Set();
+ }
+ });
+ done.Wait();
+ return confirmed;
+ }
+
+ public void ShowError(string message, string title)
+ {
+ var window = PhotinoWindowHolder.Current;
+ if (window is null)
+ {
+ logger.LogError("ShowError ({Title}): {Message}", title, message);
+ return;
+ }
+
+ var done = new ManualResetEventSlim();
+ window.Invoke(() =>
+ {
+ try
+ {
+ window.ShowMessage(title, message, PhotinoDialogButtons.Ok, PhotinoDialogIcon.Error);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "ShowError:弹出错误对话框失败。原始消息 ({Title}): {Message}", title, message);
+ }
+ finally
+ {
+ done.Set();
+ }
+ });
+ done.Wait();
+ }
+}
+#endif
diff --git a/MaiChartManager/Platform/Linux/PhotinoWindowHolder.cs b/MaiChartManager/Platform/Linux/PhotinoWindowHolder.cs
new file mode 100644
index 00000000..4af1c399
--- /dev/null
+++ b/MaiChartManager/Platform/Linux/PhotinoWindowHolder.cs
@@ -0,0 +1,13 @@
+#if !WINDOWS
+using Photino.NET;
+
+namespace MaiChartManager.Platform.Linux;
+
+///
+/// 持有当前 Photino 主窗口引用,供 Linux 平台服务(对话框等)使用。
+///
+public static class PhotinoWindowHolder
+{
+ public static PhotinoWindow? Current { get; set; }
+}
+#endif
diff --git a/MaiChartManager/Platform/PlatformFile.cs b/MaiChartManager/Platform/PlatformFile.cs
new file mode 100644
index 00000000..5007daf7
--- /dev/null
+++ b/MaiChartManager/Platform/PlatformFile.cs
@@ -0,0 +1,64 @@
+namespace MaiChartManager.Platform;
+
+///
+/// 跨平台文件删除助手。
+/// Windows 走 Microsoft.VisualBasic 的回收站删除(与原行为一致);
+/// Linux 直接永久删除(无回收站概念,且 VisualBasic 的回收站/对话框选项在 Linux 运行时会抛异常)。
+///
+public static class PlatformFile
+{
+ /// 删除文件:Windows 送回收站,Linux 直接删除。文件不存在时静默忽略。
+ public static void DeleteFile(string path)
+ {
+#if WINDOWS
+ if (File.Exists(path))
+ Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(path,
+ Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
+ Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
+#else
+ if (File.Exists(path)) File.Delete(path);
+#endif
+ }
+
+ /// 删除目录(递归):Windows 送回收站,Linux 直接删除。目录不存在时静默忽略。
+ /// showDialog=true 时 Windows 显示删除进度对话框(对应原 UIOption.AllDialogs)。
+ public static void DeleteDirectory(string path, bool showDialog = false)
+ {
+#if WINDOWS
+ if (Directory.Exists(path))
+ Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(path,
+ showDialog ? Microsoft.VisualBasic.FileIO.UIOption.AllDialogs : Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
+ Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
+#else
+ if (Directory.Exists(path)) Directory.Delete(path, true);
+#endif
+ }
+
+ /// 永久删除目录(递归,不进回收站)。对应原 DeleteDirectoryOption.DeleteAllContents。
+ public static void DeleteDirectoryPermanent(string path)
+ {
+ if (Directory.Exists(path)) Directory.Delete(path, true);
+ }
+
+ /// 复制文件(跨平台)。
+ public static void CopyFile(string source, string dest, bool overwrite = true)
+ => File.Copy(source, dest, overwrite);
+
+ /// 移动文件(跨平台)。
+ public static void MoveFile(string source, string dest, bool overwrite = true)
+ => File.Move(source, dest, overwrite);
+
+ /// 移动目录(跨平台)。
+ public static void MoveDirectory(string source, string dest)
+ => Directory.Move(source, dest);
+
+ /// 递归复制目录(跨平台)。dest 已存在时合并,文件按 overwrite 覆盖。
+ public static void CopyDirectory(string source, string dest, bool overwrite = true)
+ {
+ Directory.CreateDirectory(dest);
+ foreach (var file in Directory.EnumerateFiles(source))
+ File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite);
+ foreach (var dir in Directory.EnumerateDirectories(source))
+ CopyDirectory(dir, Path.Combine(dest, Path.GetFileName(dir)), overwrite);
+ }
+}
diff --git a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs
new file mode 100644
index 00000000..82a628c3
--- /dev/null
+++ b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs
@@ -0,0 +1,52 @@
+#if WINDOWS
+using System.Windows.Forms;
+using MaiChartManager.Utils;
+
+namespace MaiChartManager.Platform.Windows;
+
+///
+/// 基于 WinForms 的对话框服务,对应原来 WinUtils.ShowDialog +
+/// 各控制器中 FolderBrowserDialog/OpenFileDialog/MessageBox 的用法。
+///
+public class WinFormsDialogService : IDesktopDialogService
+{
+ public string? PickFolder(string? title = null)
+ {
+ using var dialog = new FolderBrowserDialog
+ {
+ ShowNewFolderButton = false,
+ };
+ if (title is not null) dialog.Description = title;
+ // Windows 的 Vista 风格文件夹对话框本身会记住上次目录,无需额外处理
+ return WinUtils.ShowDialog(dialog) == DialogResult.OK ? dialog.SelectedPath : null;
+ }
+
+ public string? PickFile(string? title = null, string? filter = null)
+ {
+ using var dialog = new OpenFileDialog();
+ if (title is not null) dialog.Title = title;
+ if (filter is not null) dialog.Filter = filter;
+ return WinUtils.ShowDialog(dialog) == DialogResult.OK ? dialog.FileName : null;
+ }
+
+ public bool Confirm(string message, string title, bool defaultResult = false)
+ {
+ var owner = AppMain.ActiveForm ?? AppMain.BrowserWin;
+ if (owner == null)
+ return MessageBox.Show(message, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes;
+ return owner.Invoke(() =>
+ MessageBox.Show(owner, message, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes);
+ }
+
+ public void ShowError(string message, string title)
+ {
+ var owner = AppMain.ActiveForm ?? AppMain.BrowserWin;
+ if (owner == null)
+ {
+ MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+ owner.Invoke(() => MessageBox.Show(owner, message, title, MessageBoxButtons.OK, MessageBoxIcon.Error));
+ }
+}
+#endif
diff --git a/MaiChartManager/Platform/Windows/WindowsAppShell.cs b/MaiChartManager/Platform/Windows/WindowsAppShell.cs
new file mode 100644
index 00000000..924493cd
--- /dev/null
+++ b/MaiChartManager/Platform/Windows/WindowsAppShell.cs
@@ -0,0 +1,70 @@
+#if WINDOWS
+using System.Windows.Forms;
+using Windows.ApplicationModel;
+
+namespace MaiChartManager.Platform.Windows;
+
+///
+/// Windows 应用外壳,委托给 AppLifecycleManager / AppMain / Browser /
+/// Application / UWP StartupTask,与原来各控制器的实现完全一致。
+///
+public class WindowsAppShell : IAppShell
+{
+ public void ShowBrowser(string loopbackUrl) => AppLifecycleManager.ShowBrowser(loopbackUrl);
+
+ public void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode")
+ => AppLifecycleManager.GoToModeSwitch(loopbackUrl, hash);
+
+ public void CloseOobeBrowser()
+ {
+ AppMain.UiContext?.Post(_ =>
+ {
+ AppMain.OobeBrowser?.Dispose();
+ AppMain.OobeBrowser = null;
+ }, null);
+ }
+
+ public void InjectOobeBackendUrl(string loopbackUrl)
+ {
+ AppMain.UiContext?.Post(_ => AppMain.OobeBrowser?.InjectBackendUrl(loopbackUrl), null);
+ }
+
+ public void UpdateMainWindowTitle(string gamePath)
+ {
+ AppMain.UiContext?.Post(_ =>
+ {
+ if (AppMain.BrowserWin is { IsDisposed: false })
+ AppMain.BrowserWin.Text = $"MaiChartManager ({gamePath})";
+ }, null);
+ }
+
+ public void DisposeTrayIcon() => AppLifecycleManager.DisposeTrayIcon();
+
+ public async Task SetStartupEnabledAsync(bool enabled)
+ {
+ try
+ {
+ var startupTask = await StartupTask.GetAsync("MaiChartManagerStartupId");
+ if (enabled)
+ await startupTask.RequestEnableAsync();
+ else
+ startupTask.Disable();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public void ReloadLocale(string locale)
+ {
+ // 语言区域状态(CurrentLocale/Config/Culture)已由 LocaleController 以平台无关的方式应用。
+ // WinForms 外壳目前不需要额外刷新任何内容。
+ }
+
+ public double GetTargetDpiScale() => Browser.TargetDpiScale;
+
+ public void ExitApp() => Application.Exit();
+}
+#endif
diff --git a/MaiChartManager/Platform/Windows/WindowsProgressController.cs b/MaiChartManager/Platform/Windows/WindowsProgressController.cs
new file mode 100644
index 00000000..1651e574
--- /dev/null
+++ b/MaiChartManager/Platform/Windows/WindowsProgressController.cs
@@ -0,0 +1,39 @@
+#if WINDOWS
+using Vanara.Windows.Forms;
+
+namespace MaiChartManager.Platform.Windows;
+
+public class WindowsProgressController : IProgressController
+{
+ public IProgressSession Begin(string title, string description, string cancelMessage)
+ => new WindowsProgressSession(title, description, cancelMessage);
+}
+
+public sealed class WindowsProgressSession : IProgressSession
+{
+ private readonly ShellProgressDialog _dialog;
+
+ public WindowsProgressSession(string title, string description, string cancelMessage)
+ {
+ _dialog = new ShellProgressDialog
+ {
+ AutoTimeEstimation = false,
+ Title = title,
+ Description = description,
+ CancelMessage = cancelMessage,
+ HideTimeRemaining = true,
+ };
+ _dialog.Start(AppMain.BrowserWin!);
+ }
+
+ public void Report(ulong value, ulong total, string? detail = null)
+ {
+ if (detail is not null) _dialog.Detail = detail;
+ _dialog.UpdateProgress(value, total);
+ }
+
+ public bool IsCancelled => _dialog.IsCancelled;
+
+ public void Dispose() => _dialog.Stop();
+}
+#endif
diff --git a/MaiChartManager/Platform/Windows/WindowsShellService.cs b/MaiChartManager/Platform/Windows/WindowsShellService.cs
new file mode 100644
index 00000000..e65ba169
--- /dev/null
+++ b/MaiChartManager/Platform/Windows/WindowsShellService.cs
@@ -0,0 +1,24 @@
+#if WINDOWS
+using System.Diagnostics;
+
+namespace MaiChartManager.Platform.Windows;
+
+/// 通过 explorer.exe / ShellExecute 实现 Windows 系统集成。
+public class WindowsShellService : IShellService
+{
+ public void RevealInFileManager(string path)
+ {
+ Process.Start("explorer.exe", $"/select,\"{path}\"");
+ }
+
+ public void OpenUrl(string url)
+ {
+ Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+ }
+
+ public void OpenPath(string path)
+ {
+ Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
+ }
+}
+#endif
diff --git a/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs b/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs
new file mode 100644
index 00000000..bc64115a
--- /dev/null
+++ b/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs
@@ -0,0 +1,13 @@
+#if WINDOWS
+using MaiChartManager.Utils;
+
+namespace MaiChartManager.Platform.Windows;
+
+/// Windows 任务栏进度,委托给基于 Vanara 的 WinUtils 辅助方法。
+public class WindowsTaskbarProgress : ITaskbarProgress
+{
+ public void Set(ulong value, ulong total = 100) => WinUtils.SetTaskbarProgress(value, total);
+ public void SetIndeterminate() => WinUtils.SetTaskbarProgressIndeterminate();
+ public void Clear() => WinUtils.ClearTaskbarProgress();
+}
+#endif
diff --git a/MaiChartManager/Properties/AssemblyInfo.cs b/MaiChartManager/Properties/AssemblyInfo.cs
index 90df6369..1e4a96b7 100644
--- a/MaiChartManager/Properties/AssemblyInfo.cs
+++ b/MaiChartManager/Properties/AssemblyInfo.cs
@@ -1,11 +1,15 @@
-using System.Reflection;
+using System.Reflection;
using MaiChartManager;
[assembly: AssemblyCompany("Clansty")]
-[assembly: AssemblyFileVersion(AppMain.Version)]
-[assembly: AssemblyInformationalVersion(AppMain.Version)]
[assembly: AssemblyProduct("MaiChartManager")]
[assembly: AssemblyTitle("MaiChartManager")]
+// 版本号来自 AppMain.Version(AppMain.g.cs):Windows 由 Packaging/Build.ps1 重写,
+// Linux 由 Packaging/arch/PKGBUILD 重写,两端一致地从 git tag 派生。
+[assembly: AssemblyFileVersion(AppMain.Version)]
+[assembly: AssemblyInformationalVersion(AppMain.Version)]
[assembly: AssemblyVersion(AppMain.Version)]
+#if WINDOWS
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows10.0.17763.0")]
-[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows10.0.17134.0")]
\ No newline at end of file
+[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows10.0.17134.0")]
+#endif
diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs
index e00338b3..705b240c 100644
--- a/MaiChartManager/ServerManager.cs
+++ b/MaiChartManager/ServerManager.cs
@@ -4,7 +4,6 @@
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json.Serialization;
-using System.Windows.Forms;
using idunno.Authentication.Basic;
using MaiChartManager.Controllers.Charts.Services;
using MaiChartManager.Controllers.Mod;
@@ -13,7 +12,6 @@
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.FileProviders;
-using Pluralsight.Crypto;
using Sentry.AspNetCore;
namespace MaiChartManager;
@@ -35,38 +33,17 @@ public static async Task StopAsync()
private static X509Certificate2 GetCert()
{
var path = Path.Combine(StaticSettings.appData, "cert.pfx");
- if (File.Exists(path))
- {
- return new X509Certificate2(path);
- }
-
- // ASP.NET 是不是不支持 ecc
- // var ecdsa = ECDsa.Create();
- // var req = new CertificateRequest("CN=MaiChartManager", ecdsa, HashAlgorithmName.SHA256);
- // req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
- // req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
- // req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], true));
- // req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false));
- // var builder = new SubjectAlternativeNameBuilder();
- // builder.AddDnsName("MaiChartManager");
- // req.CertificateExtensions.Add(builder.Build());
- //
- // var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5));
- using var ctx = new CryptContext();
- ctx.Open();
-
- var cert = ctx.CreateSelfSignedCertificate(
- new SelfSignedCertProperties
- {
- IsPrivateKeyExportable = true,
- KeyBitLength = 4096,
- Name = new X500DistinguishedName("CN=MaiChartManager"),
- ValidFrom = DateTime.Today.AddDays(-1),
- ValidTo = DateTime.Today.AddYears(5),
- });
-
- File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx));
- return cert;
+ if (File.Exists(path)) return X509CertificateLoader.LoadPkcs12FromFile(path, null);
+
+ using var rsa = System.Security.Cryptography.RSA.Create(4096);
+ var req = new System.Security.Cryptography.X509Certificates.CertificateRequest(
+ "CN=MaiChartManager", rsa,
+ System.Security.Cryptography.HashAlgorithmName.SHA256,
+ System.Security.Cryptography.RSASignaturePadding.Pkcs1);
+ var cert = req.CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(5));
+ var pfx = cert.Export(X509ContentType.Pfx);
+ File.WriteAllBytes(path, pfx);
+ return X509CertificateLoader.LoadPkcs12(pfx, null);
}
private static bool IsPortAvailable(int port)
@@ -96,16 +73,24 @@ private static int GetAvailablePort()
return port;
}
- public static void StartApp(bool export, Action? onStart = null)
+ // serveSpa:在 loopback 上伺服 wwwroot 里的 Vue SPA(用于 Photino 桌面宿主),
+ // 但不开放 LAN 端口。放在 onStart 之后以保持现有位置参数调用的兼容性。
+ public static void StartApp(bool export, Action? onStart = null, bool serveSpa = false)
{
- var builder = WebApplication.CreateBuilder();
-
+ // ContentRoot 必须显式指定为应用自身目录:WebApplication 默认用当前工作目录(cwd),
+ // 而桌面宿主常从用户 HOME 启动,host 启动时会对 ContentRoot 做文件监视/扫描,
+ // HOME 下海量文件会让 CreateBuilder 卡上几十秒。指向 exeDir 即可(wwwroot 伺服
+ // 走独立 PhysicalFileProvider,不受 ContentRoot 影响)。
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ ContentRootPath = StaticSettings.exeDir,
+ });
builder.WebHost.UseSentry((SentryAspNetCoreOptions o) =>
{
- // Tells which project in Sentry to send events to:
+ // 指定 Sentry 项目,将事件发送到对应的项目:
o.Dsn = "https://be7a9ae3a9a88f4660737b25894b3c20@sentry.c5y.moe/3";
- // Set TracesSampleRate to 1.0 to capture 100% of transactions for tracing.
- // We recommend adjusting this value in production.
+ // 将 TracesSampleRate 设为 1.0 可捕获 100% 的事务用于追踪。
+ // 建议在生产环境中适当调低该值。
o.TracesSampleRate = 0.5;
})
.ConfigureKestrel(serverOptions =>
@@ -145,6 +130,21 @@ public static void StartApp(bool export, Action? onStart = null)
.AddJsonOptions(options =>
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
+#if WINDOWS
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+#else
+ // 使用 Photino 原生对话框(替换原 HeadlessDialogService 占位实现),让 OOBE 选目录可用。
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+#endif
+
if (StaticSettings.Config.UseAuth)
{
builder.Services.AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme)
@@ -206,7 +206,8 @@ public static void StartApp(bool export, Action? onStart = null)
.UseSwagger()
.UseSwaggerUI()
.UseCors("qwq");
- if (export)
+ // 当 export 或 serveSpa 时都伺服 SPA:export 是导出场景,serveSpa 是 Photino 桌面宿主场景
+ if (export || serveSpa)
app.UseFileServer(new FileServerOptions
{
FileProvider = new PhysicalFileProvider(StaticSettings.wwwroot),
diff --git a/MaiChartManager/StaticSettings.cs b/MaiChartManager/StaticSettings.cs
index dcebacd1..8c5456c9 100644
--- a/MaiChartManager/StaticSettings.cs
+++ b/MaiChartManager/StaticSettings.cs
@@ -10,7 +10,7 @@ public partial class StaticSettings
{
public static readonly string tempPath = Path.Combine(Path.GetTempPath(), "MaiChartManager");
public static readonly string appData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MaiChartManager");
- public static readonly string exeDir = Path.GetDirectoryName(Application.ExecutablePath);
+ public static readonly string exeDir = Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory;
#if DEBUG
public static readonly string wwwroot = Path.Combine(ProjectDir, "wwwroot");
private static string ProjectDir => Path.GetDirectoryName(GetThisFilePath())!;
@@ -47,8 +47,7 @@ public StaticSettings(ILogger logger, Controllers.Mod.ModConfigS
{
_logger.LogError(e, "初始化数据目录时出错");
SentrySdk.CaptureException(e);
- MessageBox.Show(e.Message, Locale.InitDataDirError, MessageBoxButtons.OK, MessageBoxIcon.Error);
- Application.Exit();
+ throw new InvalidOperationException(Locale.InitDataDirError, e);
}
}
@@ -63,19 +62,35 @@ public async Task InitializeGameData()
{
_logger.LogError(e, "初始化数据目录时出错");
SentrySdk.CaptureException(e);
- MessageBox.Show(e.Message, Locale.InitDataDirError, MessageBoxButtons.OK, MessageBoxIcon.Error);
- Application.Exit();
+ throw new InvalidOperationException(Locale.InitDataDirError, e);
}
}
[GeneratedRegex(@"^[A-Z](\d{3})$")]
public static partial Regex ADirRegex();
- public static string GamePath { get; set; }
+ // 默认空字符串而非 null:未配置游戏目录(OOBE 阶段)时,下游 Path.Combine(GamePath, ...)
+ // 不会因 null 抛 ArgumentNullException(空字符串得到相对路径,后续 Directory/File.Exists 返回 false,优雅降级)。
+ public static string GamePath { get; set; } = "";
public static string StreamingAssets => Path.Combine(GamePath, "Sinmai_Data", "StreamingAssets");
- public static IEnumerable AssetsDirs => Directory.EnumerateDirectories(StreamingAssets)
- .Select(Path.GetFileName).Where(it => ADirRegex().IsMatch(it));
+ public static IEnumerable AssetsDirs => Directory.Exists(StreamingAssets)
+ ? Directory.EnumerateDirectories(StreamingAssets).Select(Path.GetFileName).Where(it => ADirRegex().IsMatch(it))
+ : [];
+
+ ///
+ /// 在父目录下按名称大小写不敏感地解析子目录的真实路径,找不到返回 null。
+ /// 用于兼容 Linux 大小写敏感文件系统:游戏目录在 Windows 下大小写随意(如 musicVersion / MusicVersion),
+ /// 直接 Path.Combine 固定大小写会在 Linux 上匹配不到。优先尝试精确路径以避免多数情况下的额外枚举。
+ ///
+ public static string? ResolveSubDir(string parent, string name)
+ {
+ var exact = Path.Combine(parent, name);
+ if (Directory.Exists(exact)) return exact;
+ if (!Directory.Exists(parent)) return null;
+ return Directory.EnumerateDirectories(parent)
+ .FirstOrDefault(d => string.Equals(Path.GetFileName(d), name, StringComparison.OrdinalIgnoreCase));
+ }
public int gameVersion;
private List _musicList = [];
@@ -122,8 +137,8 @@ public void ScanMusicList()
_musicList.Clear();
foreach (var a in AssetsDirs)
{
- var musicDir = Path.Combine(StreamingAssets, a, "music");
- if (!Directory.Exists(musicDir)) continue;
+ var musicDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "music");
+ if (musicDir is null) continue;
foreach (var subDir in Directory.EnumerateDirectories(musicDir))
{
@@ -142,7 +157,7 @@ public void ScanMusicList()
}
}
- _logger.LogInformation("Scan music list, found {0} music.", _musicList.Count);
+ _logger.LogInformation("扫描音乐列表,共找到 {0} 首音乐。", _musicList.Count);
}
public void ScanGenre()
@@ -151,14 +166,17 @@ public void ScanGenre()
foreach (var a in AssetsDirs)
{
- if (!Directory.Exists(Path.Combine(StreamingAssets, a, "musicGenre"))) continue;
- foreach (var genreDir in Directory.EnumerateDirectories(Path.Combine(StreamingAssets, a, "musicGenre"), "musicgenre*"))
+ // 大小写不敏感解析 musicGenre 目录;枚举全部子目录后用大小写不敏感的前缀过滤(不用 glob,避免 Linux 区分大小写匹配不到)。
+ var genreParent = ResolveSubDir(Path.Combine(StreamingAssets, a), "musicGenre");
+ if (genreParent is null) continue;
+ foreach (var genreDir in Directory.EnumerateDirectories(genreParent))
{
+ var dirName = Path.GetFileName(genreDir);
+ if (!dirName.StartsWith("musicgenre", StringComparison.InvariantCultureIgnoreCase)) continue;
if (!File.Exists(Path.Combine(genreDir, "MusicGenre.xml"))) continue;
- if (!Path.GetFileName(genreDir).StartsWith("musicgenre", StringComparison.InvariantCultureIgnoreCase)) continue;
try
{
- var id = int.Parse(Path.GetFileName(genreDir).Substring("musicgenre".Length));
+ var id = int.Parse(dirName.Substring("musicgenre".Length));
var genreXml = new GenreXml(id, a, GamePath);
var existed = GenreList.Find(it => it.Id == id);
@@ -178,7 +196,7 @@ public void ScanGenre()
}
}
- _logger.LogInformation("Scan genre list, found {0} genre.", GenreList.Count);
+ _logger.LogInformation("扫描流派列表,共找到 {0} 个流派。", GenreList.Count);
}
public void ScanVersionList()
@@ -186,14 +204,17 @@ public void ScanVersionList()
VersionList.Clear();
foreach (var a in AssetsDirs)
{
- if (!Directory.Exists(Path.Combine(StreamingAssets, a, "musicVersion"))) continue;
- foreach (var versionDir in Directory.EnumerateDirectories(Path.Combine(StreamingAssets, a, "musicVersion"), "musicversion*"))
+ // 大小写不敏感解析 musicVersion 目录;枚举全部子目录后用大小写不敏感前缀过滤(不用 glob)。
+ var versionParent = ResolveSubDir(Path.Combine(StreamingAssets, a), "musicVersion");
+ if (versionParent is null) continue;
+ foreach (var versionDir in Directory.EnumerateDirectories(versionParent))
{
+ var dirName = Path.GetFileName(versionDir);
+ if (!dirName.StartsWith("musicversion", StringComparison.InvariantCultureIgnoreCase)) continue;
if (!File.Exists(Path.Combine(versionDir, "MusicVersion.xml"))) continue;
- if (!Path.GetFileName(versionDir).StartsWith("musicversion", StringComparison.InvariantCultureIgnoreCase)) continue;
try
{
- var id = int.Parse(Path.GetFileName(versionDir).Substring("musicversion".Length));
+ var id = int.Parse(dirName.Substring("musicversion".Length));
var versionXml = new VersionXml(id, a, GamePath);
var existed = VersionList.Find(it => it.Id == id);
@@ -213,7 +234,7 @@ public void ScanVersionList()
}
}
- _logger.LogInformation("Scan version list, found {VersionListCount} version.", VersionList.Count);
+ _logger.LogInformation("扫描版本列表,共找到 {VersionListCount} 个版本。", VersionList.Count);
}
public void ScanAssetBundles()
@@ -222,8 +243,11 @@ public void ScanAssetBundles()
PseudoAssetBundleJacketMap.Clear();
foreach (var a in AssetsDirs)
{
- if (!Directory.Exists(Path.Combine(StreamingAssets, a, @"AssetBundleImages\jacket"))) continue;
- foreach (var jacketFile in Directory.EnumerateFiles(Path.Combine(StreamingAssets, a, @"AssetBundleImages\jacket")))
+ // 大小写不敏感解析 AssetBundleImages/jacket 两级目录(兼容 Linux)。
+ var abImagesDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "AssetBundleImages");
+ var jacketDir = abImagesDir is null ? null : ResolveSubDir(abImagesDir, "jacket");
+ if (jacketDir is null) continue;
+ foreach (var jacketFile in Directory.EnumerateFiles(jacketDir))
{
if (!Path.GetFileName(jacketFile).StartsWith("ui_jacket_", StringComparison.InvariantCultureIgnoreCase)) continue;
var idStr = Path.GetFileName(jacketFile).Substring("ui_jacket_".Length, 6);
@@ -235,7 +259,7 @@ public void ScanAssetBundles()
}
}
- _logger.LogInformation($"Scan AssetBundles, found {AssetBundleJacketMap.Count} AssetBundles.");
+ _logger.LogInformation($"扫描 AssetBundle,共找到 {AssetBundleJacketMap.Count} 个 AssetBundle。");
}
public void ScanSoundData()
@@ -243,14 +267,15 @@ public void ScanSoundData()
AcbAwb.Clear();
foreach (var a in AssetsDirs)
{
- if (!Directory.Exists(Path.Combine(StreamingAssets, a, "SoundData"))) continue;
- foreach (var sound in Directory.EnumerateFiles(Path.Combine(StreamingAssets, a, @"SoundData")))
+ var soundDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "SoundData");
+ if (soundDir is null) continue;
+ foreach (var sound in Directory.EnumerateFiles(soundDir))
{
AcbAwb[Path.GetFileName(sound).ToLower()] = sound;
}
}
- _logger.LogInformation($"Scan SoundData, found {AcbAwb.Count} SoundData.");
+ _logger.LogInformation($"扫描 SoundData,共找到 {AcbAwb.Count} 个音频文件。");
}
public void ScanMovieData()
@@ -258,15 +283,16 @@ public void ScanMovieData()
MovieDataMap.Clear();
foreach (var a in AssetsDirs)
{
- if (!Directory.Exists(Path.Combine(StreamingAssets, a, "MovieData"))) continue;
- foreach (var dat in Directory.EnumerateFiles(Path.Combine(StreamingAssets, a, @"MovieData")))
+ var movieDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "MovieData");
+ if (movieDir is null) continue;
+ foreach (var dat in Directory.EnumerateFiles(movieDir))
{
if (!int.TryParse(Path.GetFileNameWithoutExtension(dat), out var id)) continue;
MovieDataMap[id] = dat;
}
}
- _logger.LogInformation($"Scan MovieData, found {MovieDataMap.Count} MovieData.");
+ _logger.LogInformation($"扫描 MovieData,共找到 {MovieDataMap.Count} 个视频文件。");
}
public void GetGameVersion()
@@ -277,14 +303,14 @@ public void GetGameVersion()
xmlDoc.Load(Path.Combine(StreamingAssets, @"A000/DataConfig.xml"));
if (!int.TryParse(xmlDoc.SelectSingleNode("/DataConfig/version/minor")?.InnerText, out gameVersion))
{
- MessageBox.Show(Locale.GameVersionNotFound, Locale.GameVersionNotFoundTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ _logger.LogWarning("{message}", Locale.GameVersionNotFound);
}
}
catch (Exception e)
{
_logger.LogError(e, @"无法获取游戏版本号,可能是因为 A000\DataConfig.xml 找不到或者有错误");
SentrySdk.CaptureException(e);
- MessageBox.Show(Locale.GameVersionError, Locale.GameVersionNotFoundTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ _logger.LogWarning(e, "{message}", Locale.GameVersionError);
}
}
diff --git a/MaiChartManager/Utils/AssetBundleCreator.cs b/MaiChartManager/Utils/AssetBundleCreator.cs
index 411b721d..b36bbd8d 100644
--- a/MaiChartManager/Utils/AssetBundleCreator.cs
+++ b/MaiChartManager/Utils/AssetBundleCreator.cs
@@ -181,7 +181,9 @@ public static string CreateMusicJacketAssetBundles(ReadOnlySpan pngImageDa
resizeWidth: 200,
resizeHeight: 200);
- return Path.Combine(abiDir, $"{key}.ab");
+ // 实际写出的文件名是小写(见上面 key.ToLowerInvariant()),返回路径也必须用小写,
+ // 否则在 Linux(大小写敏感)上这个路径匹配不到真实文件(Windows 不区分大小写所以遇不到)。
+ return Path.Combine(abiDir, $"{key.ToLowerInvariant()}.ab");
}
// 读取输入的ab包当中的图片,然后用指定的assetName等参数重新打包。
diff --git a/MaiChartManager/Utils/Audio.cs b/MaiChartManager/Utils/Audio.cs
index 75cbaffb..e592bd27 100644
--- a/MaiChartManager/Utils/Audio.cs
+++ b/MaiChartManager/Utils/Audio.cs
@@ -1,6 +1,6 @@
using NAudio.Lame;
using NAudio.Wave;
-using Xabe.FFmpeg;
+using FFMpegCore;
using VGAudio;
using VGAudio.Cli;
using Xv2CoreLib.ACB;
@@ -67,8 +67,10 @@ public static Stream ConvertToWav(Stream src, string extension, float padding =
using WaveStream reader = extension switch
{
".ogg" => new NAudio.Vorbis.VorbisWaveReader(src, true),
- ".mp3" when !forceUseNAudio => new WaveFileReader(ConvertMp3ToWavViaFfmpeg(src)), // 默认情况下,优先使用ffmpeg
- _ => new StreamMediaFoundationReader(src), // WAV, WMA, AAC, 以及 MP3+forceUseNAudio,NAudio不支持MP3 Gapless,所以作为一种“兼容模式”提供
+ ".mp3" when !forceUseNAudio => new WaveFileReader(ConvertToWavViaFfmpeg(src, ".mp3")), // 默认情况下,优先使用ffmpeg
+ // WAV / WMA / AAC(以及 MP3+forceUseNAudio 的兼容模式)原本走 Windows-only 的 MediaFoundation,
+ // 跨平台改为用 ffmpeg 把任意输入解码成 16bit PCM wav,再用 NAudio WaveFileReader 读取。
+ _ => new WaveFileReader(ConvertToWavViaFfmpeg(src, extension)),
};
// 关于上述MP3 Gapless问题的影响等具体讨论,详见 https://github.com/MuNET-OSS/MaiChartManager/issues/40
var sample = reader.ToSampleProvider();
@@ -98,10 +100,14 @@ public static Stream ConvertToWav(Stream src, string extension, float padding =
return stream;
}
- private static MemoryStream ConvertMp3ToWavViaFfmpeg(Stream src)
+ // 用 ffmpeg 把任意输入流(按 ext 写到临时文件)解码成 16bit PCM wav,返回 wav 的内存流。
+ // 替代 Windows-only 的 MediaFoundation,跨平台可用(系统 ffmpeg 已配好)。
+ private static MemoryStream ConvertToWavViaFfmpeg(Stream src, string ext)
{
var tempFileGuid = Guid.NewGuid();
- var inputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}.mp3");
+ // ext 形如 ".mp3"/".wav"/".aac" 等;去掉前导点用作临时输入文件后缀
+ var inputExt = string.IsNullOrEmpty(ext) ? "" : (ext.StartsWith('.') ? ext : "." + ext);
+ var inputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}{inputExt}");
var outputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}.wav");
try
{
@@ -112,15 +118,17 @@ private static MemoryStream ConvertMp3ToWavViaFfmpeg(Stream src)
src.CopyTo(inputFile);
}
- var conversion = FFmpeg.Conversions.New()
- .AddParameter("-i " + FFmpegHelper.Escape(inputPath))
- .AddParameter("-c:a pcm_s16le") // 转为16-bit little-endian PCM
- .SetOutput(outputPath)
- .SetOverwriteOutput(true);
- conversion.Start().GetAwaiter().GetResult();
+ // 用 FFMpegCore 把任意输入解码成 16bit PCM wav。
+ // FFMpegCore 用参数数组传给 ffmpeg,既能正确处理带空格的路径(Windows),
+ // 又不引入 Xabe 在 Linux 上把引号字面传给 ffmpeg 的问题。
+ // 等价命令行:ffmpeg -y -i -c:a pcm_s16le