From 2028cc3983af31ea9af7ac453f61f51d90e7c1ad Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 06:59:08 +0800 Subject: [PATCH 01/50] build: conditional TFM + LinuxDebug config skeleton, exclude shell/audio on Linux Co-Authored-By: Claude Opus 4.8 (1M context) --- MaiChartManager/MaiChartManager.csproj | 68 ++++++++++++++++++++------ 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index 8dad9d2..23c4f23 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -1,15 +1,19 @@ - net10.0-windows10.0.17763.0 + true + net10.0 + net10.0-windows10.0.17763.0 enable enable false x64 True True - WinExe + Exe + WinExe False - win-x64 + linux-x64 + win-x64 False False true @@ -17,15 +21,18 @@ MaiChartManager.Program False PerMonitorV2 - true + true NU1605 true - Debug;Release;Crack + Debug;Release;Crack;LinuxDebug;LinuxRelease ..\Packaging\Pack true false icon.ico + + $(DefineConstants);WINDOWS + False TRACE;CRACK @@ -41,6 +48,14 @@ CRACK true + + False + TRACE + + + True + true + x64 @@ -49,27 +64,27 @@ - - - + + + - + - + - + - + @@ -78,7 +93,7 @@ - + @@ -103,6 +118,27 @@ Locale.resx + + + + + + + + + + + + + + + + + + + + + @@ -136,13 +172,13 @@ - + Libs\AssetStudio.dll - + Libs\AssetStudioUtility.dll - + Libs\Mono.Cecil.dll From 145a1be8228dcb488f5cb3a35d7311fef80674aa Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:00:34 +0800 Subject: [PATCH 02/50] build: add System.Resources.Extensions + System.Drawing.Common for Linux build --- MaiChartManager/MaiChartManager.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index 23c4f23..17a6e0b 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -118,6 +118,10 @@ Locale.resx + + + + From 51a1427e5804122bfdcb1531a305a1464f7789fb Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:08:21 +0800 Subject: [PATCH 03/50] build(linux): Mono.Cecil nuget, #if WINDOWS for AssemblyInfo/IAP, BCL self-signed cert --- .../Controllers/App/AppLicenseController.cs | 25 ++++++++++- .../Controllers/App/AppVersionController.cs | 16 ++++++- MaiChartManager/MaiChartManager.csproj | 2 +- MaiChartManager/Properties/AssemblyInfo.cs | 8 +++- MaiChartManager/ServerManager.cs | 45 +++++-------------- 5 files changed, 56 insertions(+), 40 deletions(-) diff --git a/MaiChartManager/Controllers/App/AppLicenseController.cs b/MaiChartManager/Controllers/App/AppLicenseController.cs index 5045368..9279b54 100644 --- a/MaiChartManager/Controllers/App/AppLicenseController.cs +++ b/MaiChartManager/Controllers/App/AppLicenseController.cs @@ -1,4 +1,6 @@ -using Windows.Services.Store; +#if WINDOWS +using Windows.Services.Store; +#endif using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.App; @@ -7,6 +9,7 @@ namespace MaiChartManager.Controllers.App; [Route("MaiChartManagerServlet/[action]Api")] public class AppLicenseController : Controller { +#if WINDOWS public record RequestPurchaseResult(string? ErrorMessage, StorePurchaseStatus Status); [HttpPost] @@ -32,4 +35,22 @@ public async Task VerifyOfflineKey([FromBody] string key) IapManager.SetOfflineLicenseActive(); return true; } -} \ No newline at end of file +#else + // Linux: always licensed — no store/IAP available + public record RequestPurchaseResult(string? ErrorMessage, int Status); + + [HttpPost] + public Task RequestPurchase() + { + // StorePurchaseStatus.Succeeded = 0 + return Task.FromResult(new RequestPurchaseResult(null, 0)); + } + + [HttpPost] + public Task VerifyOfflineKey([FromBody] string key) + { + // No offline key verification on Linux; treat as always licensed + return Task.FromResult(true); + } +#endif +} diff --git a/MaiChartManager/Controllers/App/AppVersionController.cs b/MaiChartManager/Controllers/App/AppVersionController.cs index 2eed099..873688f 100644 --- a/MaiChartManager/Controllers/App/AppVersionController.cs +++ b/MaiChartManager/Controllers/App/AppVersionController.cs @@ -1,4 +1,4 @@ -using MaiChartManager.Utils; +using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.App; @@ -7,6 +7,7 @@ namespace MaiChartManager.Controllers.App; [Route("MaiChartManagerServlet/[action]Api")] public class AppVersionController(StaticSettings settings, ILogger logger) : ControllerBase { +#if WINDOWS public record AppVersionResult(string Version, int GameVersion, IapManager.LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale); [HttpGet] @@ -14,4 +15,15 @@ public AppVersionResult GetAppVersion() { return new AppVersionResult(Application.ProductVersion, settings.gameVersion, IapManager.License, VideoConvert.HardwareAcceleration, VideoConvert.H264Encoder, StaticSettings.CurrentLocale); } -} \ No newline at end of file +#else + public enum LicenseStatus { Pending, Active, Inactive } + public record AppVersionResult(string Version, int GameVersion, LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale); + + [HttpGet] + public AppVersionResult GetAppVersion() + { + var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "linux"; + return new AppVersionResult(version, settings.gameVersion, LicenseStatus.Active, VideoConvert.HardwareAcceleration, VideoConvert.H264Encoder, StaticSettings.CurrentLocale); + } +#endif +} diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index 17a6e0b..041a60f 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -81,7 +81,6 @@ - @@ -121,6 +120,7 @@ + diff --git a/MaiChartManager/Properties/AssemblyInfo.cs b/MaiChartManager/Properties/AssemblyInfo.cs index 90df636..89c26cd 100644 --- a/MaiChartManager/Properties/AssemblyInfo.cs +++ b/MaiChartManager/Properties/AssemblyInfo.cs @@ -1,11 +1,17 @@ using System.Reflection; +#if WINDOWS using MaiChartManager; +#endif [assembly: AssemblyCompany("Clansty")] +#if WINDOWS [assembly: AssemblyFileVersion(AppMain.Version)] [assembly: AssemblyInformationalVersion(AppMain.Version)] +#endif [assembly: AssemblyProduct("MaiChartManager")] [assembly: AssemblyTitle("MaiChartManager")] +#if WINDOWS [assembly: AssemblyVersion(AppMain.Version)] [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 \ No newline at end of file diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index e00338b..6be5e53 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) From dc409231ac30b49e9bfb1f153a301e02619369a6 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:10:40 +0800 Subject: [PATCH 04/50] refactor: decode jacket textures via AssetsTools.NET.Texture (drop AssetStudio runtime dep) --- .../Controllers/Music/MusicController.cs | 1 - MaiChartManager/Utils/ImageConvert.cs | 72 ++++++++++++------- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/MaiChartManager/Controllers/Music/MusicController.cs b/MaiChartManager/Controllers/Music/MusicController.cs index 40e5ed7..57fcd25 100644 --- a/MaiChartManager/Controllers/Music/MusicController.cs +++ b/MaiChartManager/Controllers/Music/MusicController.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using AssetStudio; using MaiChartManager.Models; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; diff --git a/MaiChartManager/Utils/ImageConvert.cs b/MaiChartManager/Utils/ImageConvert.cs index 57dd41b..4668d78 100644 --- a/MaiChartManager/Utils/ImageConvert.cs +++ b/MaiChartManager/Utils/ImageConvert.cs @@ -1,5 +1,10 @@ -using AssetStudio; +using AssetsTools.NET; +using AssetsTools.NET.Extra; +using AssetsTools.NET.Texture; using MaiChartManager.Models; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace MaiChartManager.Utils; @@ -7,34 +12,51 @@ public static class ImageConvert { public static byte[]? GetMusicJacketPngData(this MusicXmlWithABJacket? music) { - if (music == null) - { - return null; - } - - if (System.IO.File.Exists(music.JacketPath)) - { - return File.ReadAllBytes(music.JacketPath); - } - - if (System.IO.File.Exists(music.PseudoAssetBundleJacket)) - { - return File.ReadAllBytes(music.PseudoAssetBundleJacket); - } - + if (music == null) return null; + if (File.Exists(music.JacketPath)) return File.ReadAllBytes(music.JacketPath); + if (File.Exists(music.PseudoAssetBundleJacket)) return File.ReadAllBytes(music.PseudoAssetBundleJacket); if (music.AssetBundleJacket is null) return null; return GetTextureAsPngData(music.AssetBundleJacket); } public static byte[]? GetTextureAsPngData(string inputAbPath) { - var manager = new AssetsManager(); - manager.LoadFiles(inputAbPath); - var asset = manager.assetsFileList[0].Objects.Find(it => it.type == ClassIDType.Texture2D); - if (asset is null) return null; - - var texture = asset as Texture2D; - using var stream = texture.ConvertToStream(ImageFormat.Png, true); - return stream.ToArray(); + var am = new AssetsManager(); + var bunInst = am.LoadBundleFile(inputAbPath, true); + var afileInst = am.LoadAssetsFileFromBundle(bunInst, 0, false); + + foreach (var info in afileInst.file.Metadata.AssetInfos) + { + var baseField = am.GetBaseField(afileInst, info); + if (baseField.IsDummy || baseField.TypeName != "Texture2D") continue; + + var tex = new TextureFile(); + tex.m_Width = baseField["m_Width"].AsInt; + tex.m_Height = baseField["m_Height"].AsInt; + tex.m_TextureFormat = baseField["m_TextureFormat"].AsInt; + tex.pictureData = baseField["image data"].AsByteArray; + tex.m_StreamData.path = baseField["m_StreamData"]["path"].AsString; + tex.m_StreamData.offset = baseField["m_StreamData"]["offset"].AsULong; + tex.m_StreamData.size = baseField["m_StreamData"]["size"].AsUInt; + + // If picture data is in an external .resS, fill it from the bundle directory + if (tex.pictureData is null || tex.pictureData.Length == 0) + { + tex.pictureData = tex.FillPictureData(Path.GetDirectoryName(inputAbPath) ?? "."); + } + + if (tex.pictureData is null || tex.pictureData.Length == 0) return null; + + var bgra = TextureFile.DecodeManagedData( + tex.pictureData, (TextureFormat)tex.m_TextureFormat, tex.m_Width, tex.m_Height, true); + if (bgra is null || bgra.Length == 0) return null; + + using var image = Image.LoadPixelData(bgra, tex.m_Width, tex.m_Height); + image.Mutate(x => x.Flip(FlipMode.Vertical)); + using var ms = new MemoryStream(); + image.SaveAsPng(ms); + return ms.ToArray(); + } + return null; } -} \ No newline at end of file +} From df78aaabcf555c2e650401fc306fb38438549911 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:24:23 +0800 Subject: [PATCH 05/50] feat(platform): add cross-platform abstraction layer (dialog/taskbar/shell/appshell) Introduce IDesktopDialogService, ITaskbarProgress, IShellService and IAppShell under Platform/, with Windows (#if WINDOWS) WinForms-backed impls and Linux headless impls. Register them in ServerManager.StartApp via #if WINDOWS. Co-Authored-By: Claude Opus 4.8 (1M context) --- MaiChartManager/Platform/IAppShell.cs | 39 +++++++++++ .../Platform/IDesktopDialogService.cs | 9 +++ MaiChartManager/Platform/IShellService.cs | 8 +++ MaiChartManager/Platform/ITaskbarProgress.cs | 8 +++ .../Platform/Linux/HeadlessAppShell.cs | 43 ++++++++++++ .../Platform/Linux/HeadlessDialogService.cs | 35 ++++++++++ .../Platform/Linux/LinuxShellService.cs | 32 +++++++++ .../Platform/Linux/NoopTaskbarProgress.cs | 11 ++++ .../Platform/Windows/WinFormsDialogService.cs | 51 +++++++++++++++ .../Platform/Windows/WindowsAppShell.cs | 65 +++++++++++++++++++ .../Platform/Windows/WindowsShellService.cs | 24 +++++++ .../Windows/WindowsTaskbarProgress.cs | 13 ++++ MaiChartManager/ServerManager.cs | 12 ++++ 13 files changed, 350 insertions(+) create mode 100644 MaiChartManager/Platform/IAppShell.cs create mode 100644 MaiChartManager/Platform/IDesktopDialogService.cs create mode 100644 MaiChartManager/Platform/IShellService.cs create mode 100644 MaiChartManager/Platform/ITaskbarProgress.cs create mode 100644 MaiChartManager/Platform/Linux/HeadlessAppShell.cs create mode 100644 MaiChartManager/Platform/Linux/HeadlessDialogService.cs create mode 100644 MaiChartManager/Platform/Linux/LinuxShellService.cs create mode 100644 MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs create mode 100644 MaiChartManager/Platform/Windows/WinFormsDialogService.cs create mode 100644 MaiChartManager/Platform/Windows/WindowsAppShell.cs create mode 100644 MaiChartManager/Platform/Windows/WindowsShellService.cs create mode 100644 MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs diff --git a/MaiChartManager/Platform/IAppShell.cs b/MaiChartManager/Platform/IAppShell.cs new file mode 100644 index 0000000..3cd979c --- /dev/null +++ b/MaiChartManager/Platform/IAppShell.cs @@ -0,0 +1,39 @@ +namespace MaiChartManager.Platform; + +/// +/// Desktop-shell / native window operations used by the web controllers. +/// On Windows these delegate to WinForms (AppLifecycleManager / AppMain / Browser / Application / UWP StartupTask). +/// On Linux they no-op or return defaults (Phase 3 Photino wires real behaviour). +/// +public interface IAppShell +{ + /// Show (or focus + refresh) the main browser window for the given loopback url. + void ShowBrowser(string loopbackUrl); + + /// Switch to the OOBE / mode-switch window for the given loopback url. + void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode"); + + /// Close / dispose the OOBE browser window if present. + void CloseOobeBrowser(); + + /// Inject a (possibly new) backend url into the OOBE browser window. + void InjectOobeBackendUrl(string loopbackUrl); + + /// Update the main window title to reflect the current game path. + void UpdateMainWindowTitle(string gamePath); + + /// Show / hide the tray icon (export + startup mode). + void DisposeTrayIcon(); + + /// Enable or disable the OS "run at startup" task. Returns true on success. + Task SetStartupEnabledAsync(bool enabled); + + /// Apply a locale change to native UI (window chrome, embedded libs). + void ReloadLocale(string locale); + + /// The DPI scale of the main window, used to report UI zoom defaults. + double GetTargetDpiScale(); + + /// Exit the whole application. + void ExitApp(); +} diff --git a/MaiChartManager/Platform/IDesktopDialogService.cs b/MaiChartManager/Platform/IDesktopDialogService.cs new file mode 100644 index 0000000..7882a01 --- /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/IShellService.cs b/MaiChartManager/Platform/IShellService.cs new file mode 100644 index 0000000..24eb02d --- /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 0000000..c372934 --- /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/HeadlessAppShell.cs b/MaiChartManager/Platform/Linux/HeadlessAppShell.cs new file mode 100644 index 0000000..6e600ad --- /dev/null +++ b/MaiChartManager/Platform/Linux/HeadlessAppShell.cs @@ -0,0 +1,43 @@ +using MaiChartManager.Platform; +using Microsoft.Extensions.Logging; + +namespace MaiChartManager.Platform.Linux; + +/// +/// Headless app-shell for Linux. Window / tray / startup-task operations are no-ops; +/// Phase 3 Photino wires the real native behaviour. +/// +public class HeadlessAppShell(ILogger logger) : IAppShell +{ + public void ShowBrowser(string loopbackUrl) + => logger.LogInformation("ShowBrowser (headless no-op): {Url}", loopbackUrl); + + public void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode") + => logger.LogInformation("GoToModeSwitch (headless no-op): {Url}{Hash}", loopbackUrl, hash); + + public void CloseOobeBrowser() + => logger.LogInformation("CloseOobeBrowser (headless no-op)"); + + public void InjectOobeBackendUrl(string loopbackUrl) + => logger.LogInformation("InjectOobeBackendUrl (headless no-op): {Url}", loopbackUrl); + + public void UpdateMainWindowTitle(string gamePath) + => logger.LogDebug("UpdateMainWindowTitle (headless no-op): {GamePath}", gamePath); + + public void DisposeTrayIcon() + => logger.LogDebug("DisposeTrayIcon (headless no-op)"); + + public Task SetStartupEnabledAsync(bool enabled) + { + logger.LogInformation("SetStartupEnabledAsync (headless no-op): {Enabled}", enabled); + return Task.FromResult(false); + } + + public void ReloadLocale(string locale) + => logger.LogDebug("ReloadLocale (headless no-op): {Locale}", locale); + + public double GetTargetDpiScale() => 1.0; + + public void ExitApp() + => logger.LogInformation("ExitApp (headless no-op)"); +} diff --git a/MaiChartManager/Platform/Linux/HeadlessDialogService.cs b/MaiChartManager/Platform/Linux/HeadlessDialogService.cs new file mode 100644 index 0000000..eeb7034 --- /dev/null +++ b/MaiChartManager/Platform/Linux/HeadlessDialogService.cs @@ -0,0 +1,35 @@ +using MaiChartManager.Platform; +using Microsoft.Extensions.Logging; + +namespace MaiChartManager.Platform.Linux; + +/// +/// Headless dialog service for Linux. Native file pickers are not available in the +/// current headless host; Phase 3 will replace this with Photino dialogs. +/// +public class HeadlessDialogService(ILogger logger) : IDesktopDialogService +{ + public string? PickFolder(string? title = null) + { + logger.LogWarning("PickFolder is not supported on this platform (headless). title={Title}", title); + return null; + } + + public string? PickFile(string? title = null, string? filter = null) + { + logger.LogWarning("PickFile is not supported on this platform (headless). title={Title}", title); + return null; + } + + public bool Confirm(string message, string title, bool defaultResult = false) + { + logger.LogWarning("Confirm dialog not supported on this platform (headless), returning default {Default}. title={Title} message={Message}", + defaultResult, title, message); + return defaultResult; + } + + public void ShowError(string message, string title) + { + logger.LogError("ShowError ({Title}): {Message}", title, message); + } +} diff --git a/MaiChartManager/Platform/Linux/LinuxShellService.cs b/MaiChartManager/Platform/Linux/LinuxShellService.cs new file mode 100644 index 0000000..6f4bb86 --- /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; + +/// Shell integration for Linux via xdg-open / xdg-utils. +public class LinuxShellService(ILogger logger) : IShellService +{ + public void RevealInFileManager(string path) + { + // No portable "select file" on Linux file managers; open the containing directory. + 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 0000000..2e6e72a --- /dev/null +++ b/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs @@ -0,0 +1,11 @@ +using MaiChartManager.Platform; + +namespace MaiChartManager.Platform.Linux; + +/// No-op taskbar progress for Linux (no Windows taskbar). +public class NoopTaskbarProgress : ITaskbarProgress +{ + public void Set(ulong value, ulong total = 100) { } + public void SetIndeterminate() { } + public void Clear() { } +} diff --git a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs new file mode 100644 index 0000000..2d25f68 --- /dev/null +++ b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs @@ -0,0 +1,51 @@ +#if WINDOWS +using System.Windows.Forms; +using MaiChartManager.Utils; + +namespace MaiChartManager.Platform.Windows; + +/// +/// WinForms-backed dialog service. Mirrors the original WinUtils.ShowDialog + +/// per-controller FolderBrowserDialog/OpenFileDialog/MessageBox usage. +/// +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; + 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 0000000..6f418f8 --- /dev/null +++ b/MaiChartManager/Platform/Windows/WindowsAppShell.cs @@ -0,0 +1,65 @@ +#if WINDOWS +using System.Windows.Forms; + +namespace MaiChartManager.Platform.Windows; + +/// +/// Windows app-shell, delegating to AppLifecycleManager / AppMain / Browser / +/// Application / UWP StartupTask exactly as the original controllers did. +/// +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 Windows.ApplicationModel.StartupTask.GetAsync("MaiChartManagerStartupId"); + if (enabled) + await startupTask.RequestEnableAsync(); + else + startupTask.Disable(); + return true; + } + catch + { + return false; + } + } + + public void ReloadLocale(string locale) => AppMain.SetLocale(locale); + + public double GetTargetDpiScale() => Browser.TargetDpiScale; + + public void ExitApp() => Application.Exit(); +} +#endif diff --git a/MaiChartManager/Platform/Windows/WindowsShellService.cs b/MaiChartManager/Platform/Windows/WindowsShellService.cs new file mode 100644 index 0000000..2228ddb --- /dev/null +++ b/MaiChartManager/Platform/Windows/WindowsShellService.cs @@ -0,0 +1,24 @@ +#if WINDOWS +using System.Diagnostics; + +namespace MaiChartManager.Platform.Windows; + +/// Windows shell integration via explorer.exe / ShellExecute. +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 0000000..4bd4266 --- /dev/null +++ b/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs @@ -0,0 +1,13 @@ +#if WINDOWS +using MaiChartManager.Utils; + +namespace MaiChartManager.Platform.Windows; + +/// Windows taskbar progress, delegating to the existing Vanara-backed WinUtils helpers. +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/ServerManager.cs b/MaiChartManager/ServerManager.cs index 6be5e53..e01114c 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -122,6 +122,18 @@ 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(); +#else + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +#endif + if (StaticSettings.Config.UseAuth) { builder.Services.AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme) From d4374ca3c28ced0cafc17ff7b63ed6e4f55bb993 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:24:32 +0800 Subject: [PATCH 06/50] feat(linux): exclude audio tool, migrate dialog/taskbar call sites off WinForms - csproj: AudioConvertToolController.cs on Linux (cluster D) - MusicTransferController: inject IDesktopDialogService/ITaskbarProgress; replace FolderBrowserDialog + Vanara ShellProgressDialog with abstractions; wrap audio (AudioConvert/Audio/CriUtils) blocks in #if WINDOWS - AssetDir/ImageToAb/VideoConvertTool controllers: use IDesktopDialogService (PickFolder/PickFile/Confirm) instead of WinForms dialogs/MessageBox - StaticSettings: throw InvalidOperationException instead of MessageBox+Application.Exit; use Environment.ProcessPath for exeDir - VideoConvert: wrap WinUtils taskbar calls in #if WINDOWS Remaining LinuxDebug errors are only cluster C (shell nav) + E (IAP), deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AssetDir/AssetDirController.cs | 13 +--- .../Music/MusicTransferController.cs | 74 ++++++++----------- .../Tools/ImageToAbToolController.cs | 19 ++--- .../Tools/VideoConvertToolController.cs | 12 +-- MaiChartManager/MaiChartManager.csproj | 1 + MaiChartManager/StaticSettings.cs | 12 ++- MaiChartManager/Utils/VideoConvert.cs | 8 ++ 7 files changed, 61 insertions(+), 78 deletions(-) diff --git a/MaiChartManager/Controllers/AssetDir/AssetDirController.cs b/MaiChartManager/Controllers/AssetDir/AssetDirController.cs index 2e23805..ad4d1f7 100644 --- a/MaiChartManager/Controllers/AssetDir/AssetDirController.cs +++ b/MaiChartManager/Controllers/AssetDir/AssetDirController.cs @@ -1,4 +1,5 @@ using MaiChartManager.Attributes; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; @@ -9,7 +10,7 @@ namespace MaiChartManager.Controllers.AssetDir; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class AssetDirController(StaticSettings settings, ILogger logger) : ControllerBase +public class AssetDirController(StaticSettings settings, ILogger logger, IDesktopDialogService dialogService) : ControllerBase { [HttpPost] public void CreateAssetDir([FromBody] string dir) @@ -105,15 +106,9 @@ public void PutAssetDirTxtValue([FromBody] PutAssetDirTxtValueRequest req) [HttpPost] public async Task RequestLocalImportDir() { - var dialog = new FolderBrowserDialog - { - Description = Locale.SelectAssetDirectory, - ShowNewFolderButton = false, - }; - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) return; - var src = dialog.SelectedPath; - logger.LogInformation("LocalImportDir: {src}", src); + var src = dialogService.PickFolder(Locale.SelectAssetDirectory); if (src is null) return; + logger.LogInformation("LocalImportDir: {src}", src); var destName = Path.GetFileName(src); if (!StaticSettings.ADirRegex().IsMatch(destName)) { diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index e8018fc..53ecbc8 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -4,19 +4,22 @@ using System.Security.Cryptography; using System.Text.RegularExpressions; using MaiChartManager.Models; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.VisualBasic.FileIO; using MuConvert.mai; using NAudio.Lame; -using Vanara.Windows.Forms; -using FolderBrowserDialog = System.Windows.Forms.FolderBrowserDialog; namespace MaiChartManager.Controllers.Music; [ApiController] [Route("MaiChartManagerServlet/[action]Api/{assetDir}/{id:int}")] -public partial class MusicTransferController(StaticSettings settings, ILogger logger) : ControllerBase +public partial class MusicTransferController( + StaticSettings settings, + ILogger logger, + IDesktopDialogService dialogService, + ITaskbarProgress taskbarProgress) : ControllerBase { public record RequestCopyToRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool removeEvents, bool legacyFormat); @@ -253,6 +256,7 @@ private void CopyMusicToDirectory( } // copy acbawb +#if WINDOWS if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) && acb is not null && awb is not null) @@ -264,6 +268,9 @@ private void CopyMusicToDirectory( { logger.LogWarning("{message}", BuildAudioResolveErrorMessage(music)); } +#else + logger.LogWarning("Audio export not supported on this platform; skipping ACB/AWB for music {Id}.", music.Id); +#endif // copy movie data if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) @@ -276,32 +283,20 @@ private void CopyMusicToDirectory( [Route("/MaiChartManagerServlet/[action]Api")] public void RequestCopyTo(RequestCopyToRequest request) { - var dialog = new FolderBrowserDialog - { - Description = Locale.SelectTargetLocation - }; - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) return; - var dest = dialog.SelectedPath; + var dest = dialogService.PickFolder(Locale.SelectTargetLocation); + if (dest is null) return; logger.LogInformation("CopyTo: {dest}", dest); - ShellProgressDialog? progress = null; - if (request.music.Length > 1) + var showProgress = request.music.Length > 1; + if (showProgress) { - progress = new ShellProgressDialog() - { - AutoTimeEstimation = false, - Title = Locale.Exporting, - Description = string.Format(Locale.ExportingMultipleMusic, request.music.Length), - CancelMessage = Locale.Cancelling, - HideTimeRemaining = true, - }; - progress.Start(AppMain.BrowserWin!); - progress.UpdateProgress(0, (ulong)request.music.Length); + taskbarProgress.Set(0, (ulong)request.music.Length); + logger.LogInformation("{message}", string.Format(Locale.ExportingMultipleMusic, request.music.Length)); } if (request.music.Length == 0) { - progress?.Stop(); + taskbarProgress.Clear(); return; } @@ -319,7 +314,6 @@ public void RequestCopyTo(RequestCopyToRequest request) musicIndex.TryAdd((music.Id, music.AssetDir), music); } - var cancellation = new CancellationTokenSource(); var progressLock = new object(); var completed = 0; var maxConcurrency = GetBatchExportMaxConcurrency(); @@ -331,22 +325,8 @@ public void RequestCopyTo(RequestCopyToRequest request) Parallel.ForEach(request.music, new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, - CancellationToken = cancellation.Token }, (musicId, state) => { - if (progress is not null) - { - lock (progressLock) - { - if (progress.IsCancelled) - { - cancellation.Cancel(); - state.Stop(); - return; - } - } - } - string? currentMusicName = null; if (!musicIndex.TryGetValue((musicId.Id, musicId.AssetDir), out var music)) { @@ -359,27 +339,27 @@ public void RequestCopyTo(RequestCopyToRequest request) } var done = Interlocked.Increment(ref completed); - if (progress is not null && (done % progressStep == 0 || done == request.music.Length)) + if (showProgress && (done % progressStep == 0 || done == request.music.Length)) { lock (progressLock) { if (currentMusicName is not null) { - progress.Detail = currentMusicName; + logger.LogInformation("Exporting: {detail} ({done}/{total})", currentMusicName, done, request.music.Length); } - progress.UpdateProgress((ulong)done, (ulong)request.music.Length); + taskbarProgress.Set((ulong)done, (ulong)request.music.Length); } } }); } catch (OperationCanceledException) { - logger.LogInformation("Batch export cancelled by user."); + logger.LogInformation("Batch export cancelled."); } finally { - progress?.Stop(); + taskbarProgress.Clear(); } } @@ -472,6 +452,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } // copy acbawb +#if WINDOWS if (!AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) || acb is null || awb is null) { var message = BuildAudioResolveErrorMessage(music); @@ -480,6 +461,9 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } zipArchive.CreateEntryFromFile(acb, $"SoundData/music{resolvedAudioId:000000}.acb"); zipArchive.CreateEntryFromFile(awb, $"SoundData/music{resolvedAudioId:000000}.awb"); +#else + logger.LogWarning("Audio export not supported on this platform; skipping ACB/AWB for music {Id}.", music.Id); +#endif // copy movie data if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) @@ -680,6 +664,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa if (version is not null) simaiFile["version"] = version.GenreName; // demo_seek +#if WINDOWS try { if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var previewAcb, out _) && previewAcb is not null) @@ -695,6 +680,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa { logger.LogWarning(e, "ExportAsMaidata: Failed to get audio preview time, ignoring."); } +#endif for (var i = 0; i < music.Charts.Length; i++) { @@ -763,6 +749,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } // 导出音频 +#if WINDOWS var soundEntry = zipArchive.CreateEntry("track.mp3"); await using var soundStream = soundEntry.Open(); var tag = new ID3TagData @@ -784,6 +771,9 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa var wav = Audio.AcbToWav(acbPath); AudioConvert.ConvertWavToMp3Stream(wav, soundStream, tag); soundStream.Close(); +#else + logger.LogWarning("Audio export not supported on this platform; skipping track.mp3 for music {Id}.", music.Id); +#endif if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) { diff --git a/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs b/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs index 8f027b3..525a905 100644 --- a/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs +++ b/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; @@ -6,7 +7,7 @@ namespace MaiChartManager.Controllers.Tools; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public partial class ImageToAbToolController(StaticSettings settings, ILogger logger) : ControllerBase +public partial class ImageToAbToolController(StaticSettings settings, ILogger logger, IDesktopDialogService dialogService) : ControllerBase { [GeneratedRegex(@"^(?\d+)\.(png|jpg|jpeg)$", RegexOptions.IgnoreCase)] private static partial Regex NumericFileRegex(); @@ -26,34 +27,28 @@ public async Task ImageToAbTool() { Response.Headers.Append("Content-Type", "text/event-stream"); - var dialog = new FolderBrowserDialog - { - Description = Locale.SelectImageFolder, - ShowNewFolderButton = false, - }; + var selectedPath = dialogService.PickFolder(Locale.SelectImageFolder); - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) + if (selectedPath is null) { await WriteEvent(ImageToAbEventType.Error, Locale.FileNotSelected); return; } - var selectedPath = dialog.SelectedPath; if (string.IsNullOrWhiteSpace(selectedPath) || !Directory.Exists(selectedPath)) { await WriteEvent(ImageToAbEventType.Error, Locale.FileNotSelected); return; } - + // 所选择的路径是否是正规的OPT内jacket路径。方法是判断路径结尾是否是AssetBundleImages\jacket var isIngameJacketPath = selectedPath.TrimEnd('\\').EndsWith(@"AssetBundleImages\jacket", StringComparison.OrdinalIgnoreCase); var deleteOriginalPngAfterSuccess = false; if (isIngameJacketPath) { - deleteOriginalPngAfterSuccess = MessageBox.Show( - Locale.ImageToAbDeleteOriginalPngQuestion, Locale.ImageToAb, MessageBoxButtons.YesNo, - MessageBoxIcon.Question) == DialogResult.Yes; + deleteOriginalPngAfterSuccess = dialogService.Confirm( + Locale.ImageToAbDeleteOriginalPngQuestion, Locale.ImageToAb); } var candidates = Directory.EnumerateFiles(selectedPath) diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index 835771e..304554c 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Threading.Channels; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.VisualBasic.FileIO; @@ -8,7 +9,7 @@ namespace MaiChartManager.Controllers.Tools; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class VideoConvertToolController(ILogger logger) : ControllerBase +public class VideoConvertToolController(ILogger logger, IDesktopDialogService dialogService) : ControllerBase { public enum VideoConvertEventType { @@ -22,20 +23,15 @@ public async Task VideoConvertTool() { Response.Headers.Append("Content-Type", "text/event-stream"); - var dialog = new OpenFileDialog() - { - Title = Locale.SelectVideoToConvert, - Filter = Locale.VideoFileFilter, - }; + var inputFile = dialogService.PickFile(Locale.SelectVideoToConvert, Locale.VideoFileFilter); - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) + if (inputFile is null) { await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: {Locale.FileNotSelected}\n\n"); await Response.Body.FlushAsync(); return; } - var inputFile = dialog.FileName; var directory = Path.GetDirectoryName(inputFile); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(inputFile); var inputExt = Path.GetExtension(inputFile).ToLowerInvariant(); diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index 041a60f..f303673 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -142,6 +142,7 @@ + diff --git a/MaiChartManager/StaticSettings.cs b/MaiChartManager/StaticSettings.cs index dcebacd..559149b 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,8 +62,7 @@ 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); } } @@ -277,14 +275,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/VideoConvert.cs b/MaiChartManager/Utils/VideoConvert.cs index 3e8dc33..c8fe758 100644 --- a/MaiChartManager/Utils/VideoConvert.cs +++ b/MaiChartManager/Utils/VideoConvert.cs @@ -123,7 +123,9 @@ public static async Task ConvertVideo(VideoConvertOptions options) { if (options.TaskbarProgress) { +#if WINDOWS WinUtils.SetTaskbarProgressIndeterminate(); +#endif } var outputDirectory = Path.GetDirectoryName(options.OutputPath); @@ -151,7 +153,9 @@ public static async Task ConvertVideo(VideoConvertOptions options) { if (options.TaskbarProgress) { +#if WINDOWS WinUtils.SetTaskbarProgressIndeterminate(); +#endif } WannaCRI.WannaCRI.CreateUsm(intermediateFile, options.OutputPath); @@ -163,7 +167,9 @@ public static async Task ConvertVideo(VideoConvertOptions options) } finally { +#if WINDOWS WinUtils.ClearTaskbarProgress(); +#endif // 清理临时目录 try { @@ -264,7 +270,9 @@ private static async Task ConvertToVp9OrH264(VideoConvertOptions options, string { conversion.OnProgress += (sender, args) => { +#if WINDOWS WinUtils.SetTaskbarProgress((ulong)args.Percent); +#endif }; } From cc00ec012e223cd81162ad2af87a6a16d48953d7 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:39:19 +0800 Subject: [PATCH 07/50] feat(linux): wire shell-nav/IAP abstractions + headless entry; backend builds and runs on Linux --- .../Controllers/App/LocaleController.cs | 29 +++++++- .../Controllers/App/OobeController.cs | 62 +++++------------ .../Controllers/App/SettingsController.cs | 4 +- .../Music/MovieConvertController.cs | 2 + .../Music/MusicTransferController.cs | 5 +- .../Tools/VideoConvertToolController.cs | 2 + MaiChartManager/LinuxProgram.cs | 69 +++++++++++++++++++ MaiChartManager/MaiChartManager.csproj | 3 +- .../Platform/Windows/WindowsAppShell.cs | 6 +- 9 files changed, 130 insertions(+), 52 deletions(-) create mode 100644 MaiChartManager/LinuxProgram.cs diff --git a/MaiChartManager/Controllers/App/LocaleController.cs b/MaiChartManager/Controllers/App/LocaleController.cs index d765527..5b45ff5 100644 --- a/MaiChartManager/Controllers/App/LocaleController.cs +++ b/MaiChartManager/Controllers/App/LocaleController.cs @@ -6,7 +6,7 @@ namespace MaiChartManager.Controllers.App; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class LocaleController(StaticSettings settings, ILogger logger) : ControllerBase +public class LocaleController(StaticSettings settings, ILogger logger, MaiChartManager.Platform.IAppShell appShell) : ControllerBase { [HttpGet] public string GetCurrentLocale() @@ -17,6 +17,31 @@ public string GetCurrentLocale() [HttpPost] public void SetLocale([FromBody] string locale) { - AppMain.SetLocale(locale); + if (locale != "zh" && locale != "zh-TW" && locale != "en") + { + throw new ArgumentException("Invalid locale. Must be 'zh', 'zh-TW', or 'en'"); + } + + StaticSettings.CurrentLocale = locale; + StaticSettings.Config.Locale = locale; + + // 设置 Locale 资源管理器的 Culture(这会影响所有线程) + 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; + + // 给外部依赖库设置Locale + MuConvert.utils.Utils.SetLocale(new CultureInfo(locale)); + + StaticSettings.Config.Save(); + + // 刷新原生 UI(Windows: 窗口/托盘;Linux: no-op) + appShell.ReloadLocale(locale); } } \ No newline at end of file diff --git a/MaiChartManager/Controllers/App/OobeController.cs b/MaiChartManager/Controllers/App/OobeController.cs index e8e4f33..2bf5d03 100644 --- a/MaiChartManager/Controllers/App/OobeController.cs +++ b/MaiChartManager/Controllers/App/OobeController.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Sockets; using System.Text.Json; -using System.Windows.Forms; +using MaiChartManager.Platform; using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.App; @@ -10,7 +10,11 @@ public record CompleteSetupRequest(bool Export, bool UseAuth, string? AuthUserna [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class OobeController(StaticSettings settings, ILogger logger) : ControllerBase +public class OobeController( + StaticSettings settings, + ILogger logger, + IAppShell appShell, + IDesktopDialogService dialogService) : ControllerBase { [HttpGet] public string? GetGamePath() @@ -45,11 +49,7 @@ public IActionResult SetGamePath([FromBody] string path, bool save = false) StaticSettings.Config.Save(); } - AppMain.UiContext?.Post(_ => - { - if (AppMain.BrowserWin is { IsDisposed: false }) - AppMain.BrowserWin.Text = $"MaiChartManager ({StaticSettings.GamePath})"; - }, null); + appShell.UpdateMainWindowTitle(StaticSettings.GamePath); return Ok(); } @@ -77,19 +77,7 @@ public async Task InitializeGameData() [HttpGet] public string? OpenFolderDialog() { - string? result = null; - AppMain.UiContext?.Send(_ => - { - var dialog = new FolderBrowserDialog - { - ShowNewFolderButton = false, - }; - if (dialog.ShowDialog(AppMain.ActiveForm) == DialogResult.OK) - { - result = dialog.SelectedPath; - } - }, null); - return result; + return dialogService.PickFolder(); } [HttpGet] @@ -113,17 +101,9 @@ public async Task CompleteSetup([FromBody] CompleteSetupRequest r if (exportChanged) { - AppLifecycleManager.DisposeTrayIcon(); + appShell.DisposeTrayIcon(); // 管理开机启动 - try - { - var startupTask = await Windows.ApplicationModel.StartupTask.GetAsync("MaiChartManagerStartupId"); - if (request.Export && request.StartupEnabled) - await startupTask.RequestEnableAsync(); - else - startupTask.Disable(); - } - catch { } + await appShell.SetStartupEnabledAsync(request.Export && request.StartupEnabled); _ = Task.Run(async () => { await Task.Delay(100); @@ -136,26 +116,18 @@ public async Task CompleteSetup([FromBody] CompleteSetupRequest r if (StaticSettings.Config.Export) { // 局域网模式:服务器重启后端口变了,需要把新 URL 注入回 OOBE 浏览器 - AppMain.UiContext?.Post(_ => AppMain.OobeBrowser?.InjectBackendUrl(url), null); + appShell.InjectOobeBackendUrl(url); return; } - AppLifecycleManager.ShowBrowser(url); - AppMain.UiContext?.Post(_ => - { - AppMain.OobeBrowser?.Dispose(); - AppMain.OobeBrowser = null; - }, null); + appShell.ShowBrowser(url); + appShell.CloseOobeBrowser(); }); }); } else if (!request.Export) { - AppLifecycleManager.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); - AppMain.UiContext?.Post(_ => - { - AppMain.OobeBrowser?.Dispose(); - AppMain.OobeBrowser = null; - }, null); + appShell.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); + appShell.CloseOobeBrowser(); } return Ok(); @@ -164,13 +136,13 @@ public async Task CompleteSetup([FromBody] CompleteSetupRequest r [HttpPost] public void OpenMainUI() { - AppLifecycleManager.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); + appShell.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); } [HttpPost] public void SwitchToSetMode() { var url = ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null"); - AppLifecycleManager.GoToModeSwitch(url); + appShell.GoToModeSwitch(url); } } diff --git a/MaiChartManager/Controllers/App/SettingsController.cs b/MaiChartManager/Controllers/App/SettingsController.cs index 6a99f51..928953e 100644 --- a/MaiChartManager/Controllers/App/SettingsController.cs +++ b/MaiChartManager/Controllers/App/SettingsController.cs @@ -18,7 +18,7 @@ public class SettingsDto [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class SettingsController : ControllerBase +public class SettingsController(MaiChartManager.Platform.IAppShell appShell) : ControllerBase { [HttpGet] public SettingsDto GetSettings() @@ -33,7 +33,7 @@ public SettingsDto GetSettings() UseLegacyMaiLib = StaticSettings.Config.UseLegacyMaiLib, ConvertJacketToAssetBundle = StaticSettings.Config.ConvertJacketToAssetBundle, UiZoom = StaticSettings.Config.UiZoom, - TargetDpiScale = Browser.TargetDpiScale, + TargetDpiScale = appShell.GetTargetDpiScale(), }; } diff --git a/MaiChartManager/Controllers/Music/MovieConvertController.cs b/MaiChartManager/Controllers/Music/MovieConvertController.cs index d7db76e..b9f2afb 100644 --- a/MaiChartManager/Controllers/Music/MovieConvertController.cs +++ b/MaiChartManager/Controllers/Music/MovieConvertController.cs @@ -38,7 +38,9 @@ public async Task SetMovie(int id, [FromForm] double padding, IFormFile file, st return; } +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) return; +#endif Response.Headers.Append("Content-Type", "text/event-stream"); var tmpDir = Directory.CreateTempSubdirectory(); diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 53ecbc8..e310c06 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -596,7 +596,9 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string [HttpPost] public async Task ModifyId(int id, [FromBody] int newId, string assetDir) { +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) return; +#endif var music = settings.GetMusic(id, assetDir); if (music is null) return; var musicDir = Path.GetDirectoryName(music.FilePath); @@ -726,7 +728,8 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } } - simaiFile["chartconverter"] = $"MaiChartManager v{Application.ProductVersion}"; + var appVersion = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; + simaiFile["chartconverter"] = $"MaiChartManager v{appVersion}"; await using var zipStream = HttpContext.Response.BodyWriter.AsStream(); using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index 304554c..decef5b 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -133,11 +133,13 @@ public async Task BatchConvertPvTool([FromQuery] string folderPath, [FromQuery] Response.Headers.Append("Content-Type", "text/event-stream"); // PV 转换属于赞助功能 +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) { await WriteBatchError(BatchConvertPvErrorCode.NeedLicense, Locale.BatchConvertPvNeedLicense); return; } +#endif if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath)) { diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs new file mode 100644 index 0000000..2eff3b7 --- /dev/null +++ b/MaiChartManager/LinuxProgram.cs @@ -0,0 +1,69 @@ +#if !WINDOWS +using System.Globalization; +using System.Text.Json; + +namespace MaiChartManager; + +public static class LinuxProgram +{ + public static void Main(string[] args) + { + Directory.CreateDirectory(StaticSettings.appData); + Directory.CreateDirectory(StaticSettings.tempPath); + InitConfiguration(); + ServerManager.StartApp(false, url => Console.WriteLine($"MaiChartManager backend listening at {url}")); + Thread.Sleep(Timeout.Infinite); + } + + /// + /// Minimal, headless config load for Linux. Mirrors AppMain.InitConfiguration but + /// without the Sentry / MessageBox / WinForms parts (those live in the excluded AppMain.cs). + /// + private 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 + { + // Corrupted config: drop it and continue with defaults (OOBE flow). + try { File.Delete(cfgFilePath); } + catch { /* ignore */ } + } + } + + // Apply persisted locale (AppMain.SetLocale is Windows-only). + 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 a valid game path was persisted, restore it so the app starts in management mode. + if (!string.IsNullOrWhiteSpace(StaticSettings.Config.GamePath) && Directory.Exists(StaticSettings.Config.GamePath)) + { + StaticSettings.GamePath = StaticSettings.Config.GamePath; + } + } +} +#endif diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index f303673..c7fda57 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -18,7 +18,8 @@ False true true - MaiChartManager.Program + MaiChartManager.Program + MaiChartManager.LinuxProgram False PerMonitorV2 true diff --git a/MaiChartManager/Platform/Windows/WindowsAppShell.cs b/MaiChartManager/Platform/Windows/WindowsAppShell.cs index 6f418f8..5a31f29 100644 --- a/MaiChartManager/Platform/Windows/WindowsAppShell.cs +++ b/MaiChartManager/Platform/Windows/WindowsAppShell.cs @@ -56,7 +56,11 @@ public async Task SetStartupEnabledAsync(bool enabled) } } - public void ReloadLocale(string locale) => AppMain.SetLocale(locale); + public void ReloadLocale(string locale) + { + // Locale state (CurrentLocale/Config/Culture) is applied by LocaleController in a + // platform-independent way. Nothing extra to refresh on the WinForms shell for now. + } public double GetTargetDpiScale() => Browser.TargetDpiScale; From e40dbc53c2445f2a8db7a02f890b0d016d1890d6 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:51:33 +0800 Subject: [PATCH 08/50] feat(platform): cancellable IProgressController (Vanara on Windows, headless on Linux); restore batch-export progress+cancel --- .../Music/MusicTransferController.cs | 45 ++++++++++--------- .../Platform/IProgressController.cs | 13 ++++++ .../Linux/HeadlessProgressController.cs | 24 ++++++++++ .../Windows/WindowsProgressController.cs | 39 ++++++++++++++++ MaiChartManager/ServerManager.cs | 2 + 5 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 MaiChartManager/Platform/IProgressController.cs create mode 100644 MaiChartManager/Platform/Linux/HeadlessProgressController.cs create mode 100644 MaiChartManager/Platform/Windows/WindowsProgressController.cs diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index e310c06..9e6a416 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -19,7 +19,8 @@ public partial class MusicTransferController( StaticSettings settings, ILogger logger, IDesktopDialogService dialogService, - ITaskbarProgress taskbarProgress) : ControllerBase + ITaskbarProgress taskbarProgress, + IProgressController progressController) : ControllerBase { public record RequestCopyToRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool removeEvents, bool legacyFormat); @@ -288,20 +289,18 @@ public void RequestCopyTo(RequestCopyToRequest request) logger.LogInformation("CopyTo: {dest}", dest); var showProgress = request.music.Length > 1; - if (showProgress) - { - taskbarProgress.Set(0, (ulong)request.music.Length); - logger.LogInformation("{message}", string.Format(Locale.ExportingMultipleMusic, request.music.Length)); - } + using var progress = showProgress + ? progressController.Begin(Locale.Exporting, string.Format(Locale.ExportingMultipleMusic, request.music.Length), Locale.Cancelling) + : null; + progress?.Report(0, (ulong)request.music.Length); if (request.music.Length == 0) { - taskbarProgress.Clear(); return; } var musicRootDir = Path.Combine(dest, "music"); - var jacketRootDir = Path.Combine(dest, @"AssetBundleImages\jacket"); + var jacketRootDir = Path.Combine(dest, "AssetBundleImages", "jacket"); var soundRootDir = Path.Combine(dest, "SoundData"); var movieRootDir = Path.Combine(dest, "MovieData"); Directory.CreateDirectory(musicRootDir); @@ -319,14 +318,29 @@ public void RequestCopyTo(RequestCopyToRequest request) var maxConcurrency = GetBatchExportMaxConcurrency(); var progressStep = Math.Max(1, request.music.Length / 100); var copiedSharedDestinations = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var cancellation = new CancellationTokenSource(); try { Parallel.ForEach(request.music, new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, + CancellationToken = cancellation.Token, }, (musicId, state) => { + if (progress is not null) + { + lock (progressLock) + { + if (progress.IsCancelled) + { + cancellation.Cancel(); + state.Stop(); + return; + } + } + } + string? currentMusicName = null; if (!musicIndex.TryGetValue((musicId.Id, musicId.AssetDir), out var music)) { @@ -339,27 +353,18 @@ public void RequestCopyTo(RequestCopyToRequest request) } var done = Interlocked.Increment(ref completed); - if (showProgress && (done % progressStep == 0 || done == request.music.Length)) + if (progress is not null && (done % progressStep == 0 || done == request.music.Length)) { lock (progressLock) { - if (currentMusicName is not null) - { - logger.LogInformation("Exporting: {detail} ({done}/{total})", currentMusicName, done, request.music.Length); - } - - taskbarProgress.Set((ulong)done, (ulong)request.music.Length); + progress.Report((ulong)done, (ulong)request.music.Length, currentMusicName); } } }); } catch (OperationCanceledException) { - logger.LogInformation("Batch export cancelled."); - } - finally - { - taskbarProgress.Clear(); + logger.LogInformation("Batch export cancelled by user."); } } diff --git a/MaiChartManager/Platform/IProgressController.cs b/MaiChartManager/Platform/IProgressController.cs new file mode 100644 index 0000000..5adeffd --- /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/Linux/HeadlessProgressController.cs b/MaiChartManager/Platform/Linux/HeadlessProgressController.cs new file mode 100644 index 0000000..2cdc411 --- /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/Windows/WindowsProgressController.cs b/MaiChartManager/Platform/Windows/WindowsProgressController.cs new file mode 100644 index 0000000..1651e57 --- /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/ServerManager.cs b/MaiChartManager/ServerManager.cs index e01114c..6b3b0a1 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -127,11 +127,13 @@ public static void StartApp(bool export, Action? onStart = null) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #else builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #endif if (StaticSettings.Config.UseAuth) From 80d40d42c01bf5c9af7665cc109b5a3387718d1f Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 18 Jun 2026 07:56:41 +0800 Subject: [PATCH 09/50] fix: cross-platform path separators (segments + ContainsSegment helper) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Mod/InstallationController.cs | 22 +++++++++---------- MaiChartManager/Controllers/Mod/ModPaths.cs | 6 ++--- .../Controllers/Mod/MuModService.cs | 2 +- .../Controllers/Music/CueConvertController.cs | 4 ++-- .../Music/MovieConvertController.cs | 4 ++-- .../Controllers/Music/MusicController.cs | 2 +- .../Music/MusicTransferController.cs | 4 ++-- .../Tools/ImageToAbToolController.cs | 5 +++-- MaiChartManager/Models/GenreXml.cs | 6 ++--- MaiChartManager/Models/MusicXml.cs | 2 +- .../Models/MusicXmlWithABJacket.cs | 11 +++++----- MaiChartManager/Models/VersionXml.cs | 6 ++--- MaiChartManager/StaticSettings.cs | 4 ++-- MaiChartManager/Utils/PathUtils.cs | 9 ++++++++ MaiChartManager/Utils/VideoConvert.cs | 2 +- 15 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 MaiChartManager/Utils/PathUtils.cs diff --git a/MaiChartManager/Controllers/Mod/InstallationController.cs b/MaiChartManager/Controllers/Mod/InstallationController.cs index 377b3c8..a42af4a 100644 --- a/MaiChartManager/Controllers/Mod/InstallationController.cs +++ b/MaiChartManager/Controllers/Mod/InstallationController.cs @@ -100,7 +100,7 @@ private static bool GetIsJudgeDisplay4BInstalled() #region ADX HID 冲突检测和删除 - private static readonly string[] HidModPaths = [@"Mods\Mai2InputMod.dll", @"Mods\hid_input_lib.dll", "hid_input_lib.dll", "mai2io.dll"]; + private static readonly string[] HidModPaths = [Path.Combine("Mods", "Mai2InputMod.dll"), Path.Combine("Mods", "hid_input_lib.dll"), "hid_input_lib.dll", "mai2io.dll"]; [NonAction] private static bool GetIsHidConflictExist() @@ -133,17 +133,17 @@ public void DeleteHidConflict() [NonAction] private static bool GetIsAdxHidIoModAbsent() { - return !System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Mods\ADXHIDIOMod.dll")); + return !System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Mods", "ADXHIDIOMod.dll")); } [NonAction] private static bool GetIsMmlLegacyLibsInstalled() { - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\hidapi.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "hidapi.dll"))) { return false; } - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\libadxhid.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "libadxhid.dll"))) { return false; } @@ -165,18 +165,18 @@ public record InstallMmlLibsDto(bool UseLegacy); public void InstallMmlLibs([FromBody] InstallMmlLibsDto req) { var isMaimollerLegacyModeEnabled = req.UseLegacy; - if (System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Mods\ADXHIDIOMod.dll"))) + if (System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Mods", "ADXHIDIOMod.dll"))) { - System.IO.File.Delete(Path.Combine(StaticSettings.GamePath, @"Mods\ADXHIDIOMod.dll")); + System.IO.File.Delete(Path.Combine(StaticSettings.GamePath, "Mods", "ADXHIDIOMod.dll")); } if (!isMaimollerLegacyModeEnabled || GetIsMmlLegacyLibsInstalled()) return; - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\hidapi.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "hidapi.dll"))) { - CopyFile(Path.Combine(StaticSettings.exeDir, @"hidapi.dll"), Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\hidapi.dll")); + CopyFile(Path.Combine(StaticSettings.exeDir, @"hidapi.dll"), Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "hidapi.dll")); } - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\libadxhid.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "libadxhid.dll"))) { - CopyFile(Path.Combine(StaticSettings.exeDir, @"libadxhid.dll"), Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\libadxhid.dll")); + CopyFile(Path.Combine(StaticSettings.exeDir, @"libadxhid.dll"), Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "libadxhid.dll")); } } @@ -274,7 +274,7 @@ public async Task InstallAquaMaiOnline(InstallAquaMaiOnlineDto req) continue; } // Save to Mods folder - var dest = Path.Combine(StaticSettings.GamePath, @"Mods\AquaMai.dll"); + var dest = Path.Combine(StaticSettings.GamePath, "Mods", "AquaMai.dll"); Directory.CreateDirectory(Path.GetDirectoryName(dest)); await System.IO.File.WriteAllBytesAsync(dest, data); if (System.IO.File.Exists(ModPaths.MuModDllInstalledPath)) diff --git a/MaiChartManager/Controllers/Mod/ModPaths.cs b/MaiChartManager/Controllers/Mod/ModPaths.cs index fa570d3..01c6a2c 100644 --- a/MaiChartManager/Controllers/Mod/ModPaths.cs +++ b/MaiChartManager/Controllers/Mod/ModPaths.cs @@ -4,10 +4,10 @@ public static class ModPaths { public static string AquaMaiConfigPath => Path.Combine(StaticSettings.GamePath, "AquaMai.toml"); public static string AquaMaiConfigBackupDirPath => Path.Combine(StaticSettings.GamePath, "AquaMai.toml.bak"); - public static string AquaMaiDllInstalledPath => Path.Combine(StaticSettings.GamePath, @"Mods\AquaMai.dll"); + public static string AquaMaiDllInstalledPath => Path.Combine(StaticSettings.GamePath, "Mods", "AquaMai.dll"); - public static string MuModDllInstalledPath => Path.Combine(StaticSettings.GamePath, @"Mods\MuMod.dll"); + public static string MuModDllInstalledPath => Path.Combine(StaticSettings.GamePath, "Mods", "MuMod.dll"); public static string MuModDllBuiltinPath => Path.Combine(StaticSettings.exeDir, "MuMod.dll"); public static string MuModConfigPath => Path.Combine(StaticSettings.GamePath, "MuMod.toml"); - public static string MuModDefaultCachePath => Path.Combine(StaticSettings.GamePath, @"LocalAssets\MuMod.cache"); + public static string MuModDefaultCachePath => Path.Combine(StaticSettings.GamePath, "LocalAssets", "MuMod.cache"); } \ No newline at end of file diff --git a/MaiChartManager/Controllers/Mod/MuModService.cs b/MaiChartManager/Controllers/Mod/MuModService.cs index bdc97a7..de70c33 100644 --- a/MaiChartManager/Controllers/Mod/MuModService.cs +++ b/MaiChartManager/Controllers/Mod/MuModService.cs @@ -9,7 +9,7 @@ public class MuModService(ILogger logger, IHttpClientFactory httpC { private const string CosVersionApiUrl = "https://munet-version-config-1251600285.cos.ap-shanghai.myqcloud.com/aquamai.json"; private const string CfVersionApiUrl = "https://aquamai-version-config.mumur.net/api/config"; - private const string DefaultCacheRelativePath = @"LocalAssets\MuMod.cache"; + private const string DefaultCacheRelativePath = "LocalAssets/MuMod.cache"; private enum VersionSource { diff --git a/MaiChartManager/Controllers/Music/CueConvertController.cs b/MaiChartManager/Controllers/Music/CueConvertController.cs index b6ef617..364f4ce 100644 --- a/MaiChartManager/Controllers/Music/CueConvertController.cs +++ b/MaiChartManager/Controllers/Music/CueConvertController.cs @@ -27,8 +27,8 @@ public async Task GetMusicWav(int id, string assetDir) public void SetAudio(int id, [FromForm] float padding, IFormFile file, IFormFile? awb, IFormFile? preview, string assetDir, [FromForm] bool ignoreGapless = false) { id %= 10000; - var targetAcbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"SoundData\music{id:000000}.acb"); - var targetAwbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"SoundData\music{id:000000}.awb"); + var targetAcbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "SoundData", $"music{id:000000}.acb"); + var targetAwbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "SoundData", $"music{id:000000}.awb"); Directory.CreateDirectory(Path.GetDirectoryName(targetAcbPath)); if (Path.GetExtension(file.FileName).Equals(".acb", StringComparison.InvariantCultureIgnoreCase)) diff --git a/MaiChartManager/Controllers/Music/MovieConvertController.cs b/MaiChartManager/Controllers/Music/MovieConvertController.cs index b9f2afb..81a9831 100644 --- a/MaiChartManager/Controllers/Music/MovieConvertController.cs +++ b/MaiChartManager/Controllers/Music/MovieConvertController.cs @@ -30,7 +30,7 @@ public async Task SetMovie(int id, [FromForm] double padding, IFormFile file, st if (Path.GetExtension(file.FileName).Equals(".dat", StringComparison.InvariantCultureIgnoreCase)) { - var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"MovieData\{id:000000}.dat"); + var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "MovieData", $"{id:000000}.dat"); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); await using var stream = System.IO.File.Open(targetPath, FileMode.Create); await file.CopyToAsync(stream); @@ -56,7 +56,7 @@ public async Task SetMovie(int id, [FromForm] double padding, IFormFile file, st } // 目标路径 - var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"MovieData\{id:000000}.{(StaticSettings.Config.MovieCodec == MovieCodec.ForceH264 ? "mp4" : "dat")}"); + var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "MovieData", $"{id:000000}.{(StaticSettings.Config.MovieCodec == MovieCodec.ForceH264 ? "mp4" : "dat")}"); // 使用工具类转换视频 await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions diff --git a/MaiChartManager/Controllers/Music/MusicController.cs b/MaiChartManager/Controllers/Music/MusicController.cs index 57fcd25..d29b50b 100644 --- a/MaiChartManager/Controllers/Music/MusicController.cs +++ b/MaiChartManager/Controllers/Music/MusicController.cs @@ -166,7 +166,7 @@ public string SetMusicJacket(int id, IFormFile file, string assetDir) if (music is null) return "Music not found!"; music.DeleteJacket(); // 删除老的jacket - var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, @"AssetBundleImages\jacket"); + var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "AssetBundleImages", "jacket"); Directory.CreateDirectory(abiDir); if (StaticSettings.Config.ConvertJacketToAssetBundle) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 9e6a416..ba969f2 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -508,8 +508,8 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string // 当新ID和旧ID的 NonDx部分相同时(例如 5003 -> 15003),封面、音频、视频文件的目标路径与源路径完全一致,因此既不需要也不应该删除/移动它们。 if (newNonDxId == music.NonDxId) return; - var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, @"AssetBundleImages\jacket"); - var abiSDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, @"AssetBundleImages\jacket_s"); + var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "AssetBundleImages", "jacket"); + var abiSDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "AssetBundleImages", "jacket_s"); Directory.CreateDirectory(abiDir); Directory.CreateDirectory(abiSDir); var abJacketTarget = Path.Combine(abiDir, $"ui_jacket_{newNonDxId:000000}.ab"); diff --git a/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs b/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs index 525a905..0fa5ead 100644 --- a/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs +++ b/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs @@ -41,8 +41,9 @@ public async Task ImageToAbTool() return; } - // 所选择的路径是否是正规的OPT内jacket路径。方法是判断路径结尾是否是AssetBundleImages\jacket - var isIngameJacketPath = selectedPath.TrimEnd('\\').EndsWith(@"AssetBundleImages\jacket", StringComparison.OrdinalIgnoreCase); + // 所选择的路径是否是正规的OPT内jacket路径。方法是判断路径结尾是否是AssetBundleImages/jacket + var normalizedPath = selectedPath.TrimEnd('/', '\\').Replace('\\', '/'); + var isIngameJacketPath = normalizedPath.EndsWith("AssetBundleImages/jacket", StringComparison.OrdinalIgnoreCase); var deleteOriginalPngAfterSuccess = false; if (isIngameJacketPath) diff --git a/MaiChartManager/Models/GenreXml.cs b/MaiChartManager/Models/GenreXml.cs index 557600f..5d757b6 100644 --- a/MaiChartManager/Models/GenreXml.cs +++ b/MaiChartManager/Models/GenreXml.cs @@ -12,7 +12,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 => Path.Combine(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, $"musicGenre/musicgenre{Id:000000}/MusicGenre.xml"); public GenreXml(int id, string assetDir, string gamePath) { @@ -25,7 +25,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 = Path.Combine(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, $"musicGenre/musicgenre{id:000000}"); Directory.CreateDirectory(dir); var text = $""" @@ -96,6 +96,6 @@ public void Save() public void Delete() { - FileSystem.DeleteDirectory(Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicGenre/musicgenre{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); + FileSystem.DeleteDirectory(Path.Combine(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, $"musicGenre/musicgenre{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); } } diff --git a/MaiChartManager/Models/MusicXml.cs b/MaiChartManager/Models/MusicXml.cs index 4996871..df8768d 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 75b1e58..b03de6e 100644 --- a/MaiChartManager/Models/MusicXmlWithABJacket.cs +++ b/MaiChartManager/Models/MusicXmlWithABJacket.cs @@ -1,4 +1,5 @@ using System.Xml; +using MaiChartManager.Utils; using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Models; @@ -123,7 +124,7 @@ 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; @@ -152,7 +153,7 @@ 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 @@ -175,7 +176,7 @@ 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 @@ -188,7 +189,7 @@ 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 @@ -201,7 +202,7 @@ 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 diff --git a/MaiChartManager/Models/VersionXml.cs b/MaiChartManager/Models/VersionXml.cs index 72b04e0..81a3333 100644 --- a/MaiChartManager/Models/VersionXml.cs +++ b/MaiChartManager/Models/VersionXml.cs @@ -12,7 +12,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 => Path.Combine(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, $"musicVersion/MusicVersion{Id:000000}/MusicVersion.xml"); public VersionXml(int id, string assetDir, string gamePath) { @@ -25,7 +25,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 = Path.Combine(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, $"musicVersion/MusicVersion{id:000000}"); Directory.CreateDirectory(dir); var text = $""" @@ -103,6 +103,6 @@ public void Save() public void Delete() { - FileSystem.DeleteDirectory(Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicVersion/MusicVersion{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); + FileSystem.DeleteDirectory(Path.Combine(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, $"musicVersion/MusicVersion{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); } } diff --git a/MaiChartManager/StaticSettings.cs b/MaiChartManager/StaticSettings.cs index 559149b..ea83d61 100644 --- a/MaiChartManager/StaticSettings.cs +++ b/MaiChartManager/StaticSettings.cs @@ -220,8 +220,8 @@ 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"))) + if (!Directory.Exists(Path.Combine(StreamingAssets, a, "AssetBundleImages", "jacket"))) continue; + foreach (var jacketFile in Directory.EnumerateFiles(Path.Combine(StreamingAssets, a, "AssetBundleImages", "jacket"))) { if (!Path.GetFileName(jacketFile).StartsWith("ui_jacket_", StringComparison.InvariantCultureIgnoreCase)) continue; var idStr = Path.GetFileName(jacketFile).Substring("ui_jacket_".Length, 6); diff --git a/MaiChartManager/Utils/PathUtils.cs b/MaiChartManager/Utils/PathUtils.cs new file mode 100644 index 0000000..05d71b8 --- /dev/null +++ b/MaiChartManager/Utils/PathUtils.cs @@ -0,0 +1,9 @@ +namespace MaiChartManager.Utils; + +public static class PathUtils +{ + /// 判断路径是否包含某个目录段(跨平台,忽略分隔符差异,大小写不敏感) + public static bool ContainsSegment(string? path, string segment) + => path is not null && + path.Replace('\\', '/').Contains($"/{segment}/", System.StringComparison.InvariantCultureIgnoreCase); +} diff --git a/MaiChartManager/Utils/VideoConvert.cs b/MaiChartManager/Utils/VideoConvert.cs index c8fe758..d0dfc0a 100644 --- a/MaiChartManager/Utils/VideoConvert.cs +++ b/MaiChartManager/Utils/VideoConvert.cs @@ -350,7 +350,7 @@ public static async Task ConvertUsmToMp4(string inputPath, string outputPath, Ac // 查找解包后的 IVF 文件 onProgress?.Invoke(50); - var outputIvfFile = Directory.EnumerateFiles(Path.Combine(tmpDir.FullName, @"output\movie.usm\videos")).FirstOrDefault(); + var outputIvfFile = Directory.EnumerateFiles(Path.Combine(tmpDir.FullName, "output", "movie.usm", "videos")).FirstOrDefault(); if (outputIvfFile is null) { throw new Exception("USM 解包失败:未找到视频文件"); From ef128969d90f1c7051bc946b6b517797ab09e1e5 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 01:46:37 +0800 Subject: [PATCH 10/50] fix build --- AquaMai | 2 +- MaiChartManager/AppMain.g.cs | 2 +- MaiChartManager/Platform/Windows/WindowsAppShell.cs | 3 ++- MaiChartManager/Utils/ImageConvert.cs | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AquaMai b/AquaMai index 2d6056f..2b8ec14 160000 --- a/AquaMai +++ b/AquaMai @@ -1 +1 @@ -Subproject commit 2d6056fb2e70044d0b00a53da2611ce5fb643f9a +Subproject commit 2b8ec146c9e7ba787c82187db76244040c640526 diff --git a/MaiChartManager/AppMain.g.cs b/MaiChartManager/AppMain.g.cs index e0ce32b..1d943ec 100644 --- a/MaiChartManager/AppMain.g.cs +++ b/MaiChartManager/AppMain.g.cs @@ -3,5 +3,5 @@ namespace MaiChartManager; public partial class AppMain { - public const string Version = "26.2"; + public const string Version = "26.3"; } diff --git a/MaiChartManager/Platform/Windows/WindowsAppShell.cs b/MaiChartManager/Platform/Windows/WindowsAppShell.cs index 5a31f29..81feb66 100644 --- a/MaiChartManager/Platform/Windows/WindowsAppShell.cs +++ b/MaiChartManager/Platform/Windows/WindowsAppShell.cs @@ -1,5 +1,6 @@ #if WINDOWS using System.Windows.Forms; +using Windows.ApplicationModel; namespace MaiChartManager.Platform.Windows; @@ -43,7 +44,7 @@ public async Task SetStartupEnabledAsync(bool enabled) { try { - var startupTask = await Windows.ApplicationModel.StartupTask.GetAsync("MaiChartManagerStartupId"); + var startupTask = await StartupTask.GetAsync("MaiChartManagerStartupId"); if (enabled) await startupTask.RequestEnableAsync(); else diff --git a/MaiChartManager/Utils/ImageConvert.cs b/MaiChartManager/Utils/ImageConvert.cs index 4668d78..f5dce68 100644 --- a/MaiChartManager/Utils/ImageConvert.cs +++ b/MaiChartManager/Utils/ImageConvert.cs @@ -5,6 +5,7 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; +using Image = SixLabors.ImageSharp.Image; namespace MaiChartManager.Utils; From 061db08d135c6a599165691e1ead21adcc9afd99 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 01:48:08 +0800 Subject: [PATCH 11/50] fix submodule --- AquaMai | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AquaMai b/AquaMai index 2b8ec14..2d6056f 160000 --- a/AquaMai +++ b/AquaMai @@ -1 +1 @@ -Subproject commit 2b8ec146c9e7ba787c82187db76244040c640526 +Subproject commit 2d6056fb2e70044d0b00a53da2611ce5fb643f9a From 446fa315d96042bf4a3bbf52411406104c7486f0 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 02:03:52 +0800 Subject: [PATCH 12/50] =?UTF-8?q?fix:=20=E7=94=A8=20SetPictureDataFromBund?= =?UTF-8?q?le=20=E8=A7=A3=E6=9E=90=20bundle=20=E5=86=85=E9=83=A8=20.resS?= =?UTF-8?q?=20=E8=B4=B4=E5=9B=BE=E6=95=B0=E6=8D=AE=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=8E=9F=E5=A7=8B=E5=B0=81=E9=9D=A2=20GetJacketApi=20?= =?UTF-8?q?404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/Utils/ImageConvert.cs | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/MaiChartManager/Utils/ImageConvert.cs b/MaiChartManager/Utils/ImageConvert.cs index f5dce68..223b099 100644 --- a/MaiChartManager/Utils/ImageConvert.cs +++ b/MaiChartManager/Utils/ImageConvert.cs @@ -31,21 +31,11 @@ public static class ImageConvert var baseField = am.GetBaseField(afileInst, info); if (baseField.IsDummy || baseField.TypeName != "Texture2D") continue; - var tex = new TextureFile(); - tex.m_Width = baseField["m_Width"].AsInt; - tex.m_Height = baseField["m_Height"].AsInt; - tex.m_TextureFormat = baseField["m_TextureFormat"].AsInt; - tex.pictureData = baseField["image data"].AsByteArray; - tex.m_StreamData.path = baseField["m_StreamData"]["path"].AsString; - tex.m_StreamData.offset = baseField["m_StreamData"]["offset"].AsULong; - tex.m_StreamData.size = baseField["m_StreamData"]["size"].AsUInt; - - // If picture data is in an external .resS, fill it from the bundle directory - if (tex.pictureData is null || tex.pictureData.Length == 0) - { - tex.pictureData = tex.FillPictureData(Path.GetDirectoryName(inputAbPath) ?? "."); - } - + var tex = TextureFile.ReadTextureFile(baseField); + // 从 bundle 内解析贴图像素数据:同时覆盖 inline "image data" 与 bundle 内部的 .resS streamData。 + // 游戏原始封面的像素数据存在 bundle 内部的 .resS 里,必须用 bundle 解析, + // 不能只读文件系统目录(之前用 FillPictureData(目录) 会导致原始封面解析失败、接口返回 404)。 + tex.SetPictureDataFromBundle(bunInst); if (tex.pictureData is null || tex.pictureData.Length == 0) return null; var bgra = TextureFile.DecodeManagedData( From d31d2bd0f327f8c7bea57d5d55e608bffc52a8d2 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 02:13:54 +0800 Subject: [PATCH 13/50] =?UTF-8?q?docs:=20=E6=B3=A8=E9=87=8A=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=94=B9=E4=B8=BA=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/App/AppLicenseController.cs | 4 +- .../Controllers/Mod/InstallationController.cs | 16 +++--- .../Controllers/Mod/MuModService.cs | 28 +++++----- .../Music/MusicTransferController.cs | 54 +++++++++---------- .../Tools/VideoConvertToolController.cs | 2 +- MaiChartManager/LinuxProgram.cs | 10 ++-- .../Models/MusicXmlWithABJacket.cs | 2 +- MaiChartManager/Platform/IAppShell.cs | 26 ++++----- .../Platform/Linux/HeadlessAppShell.cs | 4 +- .../Platform/Linux/HeadlessDialogService.cs | 4 +- .../Platform/Linux/LinuxShellService.cs | 4 +- .../Platform/Linux/NoopTaskbarProgress.cs | 2 +- .../Platform/Windows/WinFormsDialogService.cs | 4 +- .../Platform/Windows/WindowsAppShell.cs | 8 +-- .../Platform/Windows/WindowsShellService.cs | 2 +- .../Windows/WindowsTaskbarProgress.cs | 2 +- MaiChartManager/ServerManager.cs | 6 +-- MaiChartManager/StaticSettings.cs | 12 ++--- 18 files changed, 95 insertions(+), 95 deletions(-) diff --git a/MaiChartManager/Controllers/App/AppLicenseController.cs b/MaiChartManager/Controllers/App/AppLicenseController.cs index 9279b54..96410d4 100644 --- a/MaiChartManager/Controllers/App/AppLicenseController.cs +++ b/MaiChartManager/Controllers/App/AppLicenseController.cs @@ -36,7 +36,7 @@ public async Task VerifyOfflineKey([FromBody] string key) return true; } #else - // Linux: always licensed — no store/IAP available + // Linux:始终已授权——不支持商店 / IAP public record RequestPurchaseResult(string? ErrorMessage, int Status); [HttpPost] @@ -49,7 +49,7 @@ public Task RequestPurchase() [HttpPost] public Task VerifyOfflineKey([FromBody] string key) { - // No offline key verification on Linux; treat as always licensed + // Linux 不做离线密钥验证;始终视为已授权 return Task.FromResult(true); } #endif diff --git a/MaiChartManager/Controllers/Mod/InstallationController.cs b/MaiChartManager/Controllers/Mod/InstallationController.cs index a42af4a..bedb3c3 100644 --- a/MaiChartManager/Controllers/Mod/InstallationController.cs +++ b/MaiChartManager/Controllers/Mod/InstallationController.cs @@ -196,7 +196,7 @@ public void InstallMelonLoader() { if (IsMelonInstalled()) { - logger.LogInformation("MelonLoader is already installed."); + logger.LogInformation("MelonLoader 已安装,跳过。"); return; } @@ -249,9 +249,9 @@ public async Task InstallAquaMaiOnline(InstallAquaMaiOnlineDto req) "ci" => CI_KEY, "slow" => CI_KEY, "release" => RELEASE_KEY, - _ => throw new ArgumentException("Invalid type", nameof(req.Type)), + _ => throw new ArgumentException("无效的类型", nameof(req.Type)), }; - // Download from url + // 从 URL 下载 using var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(15); Exception? lastException = null; @@ -264,16 +264,16 @@ public async Task InstallAquaMaiOnline(InstallAquaMaiOnlineDto req) if (!VerifyBinary(data, req.Sign, key)) { - throw new InvalidOperationException("Invalid signature"); + throw new InvalidOperationException("签名无效"); } } catch (Exception e) { - logger.LogError(e, "Failed to download AquaMai from {Url}", url); + logger.LogError(e, "从 {Url} 下载 AquaMai 失败", url); lastException = e; continue; } - // Save to Mods folder + // 保存到 Mods 目录 var dest = Path.Combine(StaticSettings.GamePath, "Mods", "AquaMai.dll"); Directory.CreateDirectory(Path.GetDirectoryName(dest)); await System.IO.File.WriteAllBytesAsync(dest, data); @@ -283,7 +283,7 @@ public async Task InstallAquaMaiOnline(InstallAquaMaiOnlineDto req) } return; } - throw new InvalidOperationException("Failed to download AquaMai from all urls", lastException); + throw new InvalidOperationException("从所有 URL 下载 AquaMai 均失败", lastException); } [HttpPost] @@ -307,7 +307,7 @@ public async Task InstallMuMod() } catch (Exception e) { - logger.LogError(e, "Failed to download MuMod cache during install, but DLL was installed successfully"); + logger.LogError(e, "安装期间下载 MuMod 缓存失败,但 DLL 已成功安装"); } } diff --git a/MaiChartManager/Controllers/Mod/MuModService.cs b/MaiChartManager/Controllers/Mod/MuModService.cs index de70c33..5944f37 100644 --- a/MaiChartManager/Controllers/Mod/MuModService.cs +++ b/MaiChartManager/Controllers/Mod/MuModService.cs @@ -51,7 +51,7 @@ public void WriteChannel(string channel) { if (channel != "slow" && channel != "fast") { - throw new ArgumentException("Channel must be slow or fast", nameof(channel)); + throw new ArgumentException("Channel 只能为 slow 或 fast", nameof(channel)); } var config = ReadConfig(); @@ -107,21 +107,21 @@ public async Task EnsureCache(CancellationToken ct = default) if (hasOldCache && string.Equals(NormalizeVersion(oldVersion), targetVersion, StringComparison.OrdinalIgnoreCase)) { - logger.LogInformation("MuMod cache is up-to-date: {Version}", oldVersion); + logger.LogInformation("MuMod 缓存已是最新版本:{Version}", oldVersion); return new EnsureCacheResult(true, oldVersion, null); } var downloadUrls = BuildDownloadUrls(versionInfo, source).ToArray(); if (downloadUrls.Length == 0) { - throw new InvalidOperationException("No valid download urls found in version API response"); + throw new InvalidOperationException("版本 API 响应中未找到有效的下载地址"); } var data = await DownloadFromUrlsAsync(downloadUrls, ct); var verifyResult = AquaMaiSignatureV2.VerifySignature(data); if (verifyResult.Status != AquaMaiSignatureV2.VerifyStatus.Valid) { - throw new InvalidOperationException($"MuMod cache signature verification failed: {verifyResult.Status}"); + throw new InvalidOperationException($"MuMod 缓存签名验证失败:{verifyResult.Status}"); } var cacheDir = Path.GetDirectoryName(cachePath); @@ -145,15 +145,15 @@ public async Task EnsureCache(CancellationToken ct = default) } var finalVersion = ReadProductVersion(cachePath) ?? targetVersion; - logger.LogInformation("MuMod cache updated successfully to version {Version}", finalVersion); + logger.LogInformation("MuMod 缓存已成功更新至版本 {Version}", finalVersion); return new EnsureCacheResult(true, finalVersion, null); } catch (Exception ex) { - logger.LogError(ex, "Failed to ensure MuMod cache"); + logger.LogError(ex, "更新 MuMod 缓存失败"); if (hasOldCache) { - logger.LogWarning("Using existing MuMod cache due to update failure"); + logger.LogWarning("因更新失败,继续使用现有 MuMod 缓存"); return new EnsureCacheResult(true, oldVersion, null); } @@ -183,7 +183,7 @@ private static string ResolveApiType(string channel) { "slow" => "slow", "fast" => "ci", - _ => throw new InvalidOperationException($"Unsupported MuMod channel: {channel}"), + _ => throw new InvalidOperationException($"不支持的 MuMod 频道:{channel}"), }; } @@ -217,26 +217,26 @@ private static string ResolveApiType(string channel) var firstMatch = firstVersions.FirstOrDefault(v => string.Equals(v.Type, apiType, StringComparison.OrdinalIgnoreCase)); if (firstMatch != null) { - logger.LogInformation("MuMod version metadata resolved from {Source}", firstSource); + logger.LogInformation("MuMod 版本元数据已从 {Source} 获取", firstSource); return (firstMatch, firstSource); } - throw new InvalidOperationException($"Version metadata from {firstSource} has no '{apiType}' item"); + throw new InvalidOperationException($"来自 {firstSource} 的版本元数据中没有 '{apiType}' 条目"); } catch (Exception ex) { firstError = ex; - logger.LogWarning(ex, "Failed to use version metadata from {Source}", firstSource); + logger.LogWarning(ex, "使用来自 {Source} 的版本元数据失败", firstSource); } var secondVersions = await secondTask; var secondMatch = secondVersions.FirstOrDefault(v => string.Equals(v.Type, apiType, StringComparison.OrdinalIgnoreCase)); if (secondMatch == null) { - throw new InvalidOperationException($"Version metadata from both sources has no '{apiType}' item", firstError); + throw new InvalidOperationException($"两个来源的版本元数据中均没有 '{apiType}' 条目", firstError); } - logger.LogInformation("MuMod version metadata resolved from fallback source {Source}", secondSource); + logger.LogInformation("MuMod 版本元数据已从备用来源 {Source} 获取", secondSource); return (secondMatch, secondSource); } @@ -298,6 +298,6 @@ private async Task DownloadFromUrlsAsync(IReadOnlyList urls, Can } } - throw new InvalidOperationException("Failed to download MuMod cache from all urls", lastError); + throw new InvalidOperationException("从所有 URL 下载 MuMod 缓存均失败", lastError); } } diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index ba969f2..2892797 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -44,10 +44,10 @@ private static int GetBatchExportMaxConcurrency() } /// - /// Given an AssetBundle jacket path (e.g. ".../AssetBundleImages/jacket/ui_jacket_000123.ab"), - /// compute the companion small jacket path in the sibling "jacket_s" directory - /// (e.g. ".../AssetBundleImages/jacket_s/ui_jacket_000123_s.ab"). - /// Returns null if the path shape is unexpected. + /// 根据 AssetBundle 封面路径(如 ".../AssetBundleImages/jacket/ui_jacket_000123.ab"), + /// 计算同级 "jacket_s" 目录下的小封面路径 + /// (如 ".../AssetBundleImages/jacket_s/ui_jacket_000123_s.ab")。 + /// 若路径格式不符则返回 null。 /// private static string? GetAssetBundleJacketSmallPath(string assetBundleJacketPath) { @@ -177,7 +177,7 @@ private void CopyMusicToDirectory( return; } - // copy music + // 复制音乐数据 var musicDestDir = Path.Combine(musicRootDir, $"music{music.Id:000000}"); CopyDirectoryIfChanged(musicDir, musicDestDir); @@ -222,7 +222,7 @@ private void CopyMusicToDirectory( } } - // copy jacket + // 复制封面 if (music.JacketPath is not null) { var jacketDest = Path.Combine(jacketRootDir, $"ui_jacket_{music.NonDxId:000000}{Path.GetExtension(music.JacketPath)}"); @@ -237,7 +237,7 @@ private void CopyMusicToDirectory( CopySharedFileIfNeeded(music.AssetBundleJacket + ".manifest", Path.Combine(jacketRootDir, jacketFileName + ".manifest"), copiedSharedDestinations); } - // Issue #42: jacket_s lives in a sibling directory, must be exported into AssetBundleImages/jacket_s/ + // Issue #42: jacket_s 位于同级目录,导出时必须写入 AssetBundleImages/jacket_s/ var jacketSPath = GetAssetBundleJacketSmallPath(music.AssetBundleJacket); if (jacketSPath is not null && System.IO.File.Exists(jacketSPath)) { @@ -256,7 +256,7 @@ private void CopyMusicToDirectory( CopySharedFileIfNeeded(music.PseudoAssetBundleJacket, Path.Combine(jacketRootDir, jacketFileName), copiedSharedDestinations); } - // copy acbawb + // 复制 ACB/AWB 音频 #if WINDOWS if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) && acb is not null @@ -270,10 +270,10 @@ private void CopyMusicToDirectory( logger.LogWarning("{message}", BuildAudioResolveErrorMessage(music)); } #else - logger.LogWarning("Audio export not supported on this platform; skipping ACB/AWB for music {Id}.", music.Id); + logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 ACB/AWB。", music.Id); #endif - // copy movie data + // 复制视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) { CopySharedFileIfNeeded(movie, Path.Combine(movieRootDir, $"{music.NonDxId:000000}{Path.GetExtension(movie)}"), copiedSharedDestinations); @@ -364,7 +364,7 @@ public void RequestCopyTo(RequestCopyToRequest request) } catch (OperationCanceledException) { - logger.LogInformation("Batch export cancelled by user."); + logger.LogInformation("批量导出被用户取消。"); } } @@ -384,12 +384,12 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l var zipStream = HttpContext.Response.BodyWriter.AsStream(); using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); - // copy music + // 复制音乐数据 foreach (var file in Directory.EnumerateFiles(musicDir)) { if (Path.GetFileName(file).Equals("Music.xml", StringComparison.InvariantCultureIgnoreCase) && removeEvents) { - logger.LogInformation("Remove events and rights from Music.xml"); + logger.LogInformation("从 Music.xml 中移除 Events 和版权信息"); var xmlDoc = music.GetXmlWithoutEventsAndRights(); var entry = zipArchive.CreateEntry($"music/music{music.Id:000000}/Music.xml"); using var stream = entry.Open(); @@ -427,7 +427,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } } - // copy jacket + // 复制封面 if (music.JacketPath is not null) { zipArchive.CreateEntryFromFile(music.JacketPath, $"AssetBundleImages/jacket/ui_jacket_{music.NonDxId:000000}{Path.GetExtension(music.JacketPath)}"); @@ -440,7 +440,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l zipArchive.CreateEntryFromFile(music.AssetBundleJacket + ".manifest", $"AssetBundleImages/jacket/{Path.GetFileName(music.AssetBundleJacket)}.manifest"); } - // Issue #42: jacket_s lives in a sibling directory, must be exported into AssetBundleImages/jacket_s/ + // Issue #42: jacket_s 位于同级目录,导出时必须写入 AssetBundleImages/jacket_s/ var jacketSPath = GetAssetBundleJacketSmallPath(music.AssetBundleJacket); if (jacketSPath is not null && System.IO.File.Exists(jacketSPath)) { @@ -456,7 +456,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l zipArchive.CreateEntryFromFile(music.PseudoAssetBundleJacket, $"AssetBundleImages/jacket/{Path.GetFileName(music.PseudoAssetBundleJacket)}"); } - // copy acbawb + // 复制 ACB/AWB 音频 #if WINDOWS if (!AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) || acb is null || awb is null) { @@ -467,10 +467,10 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l zipArchive.CreateEntryFromFile(acb, $"SoundData/music{resolvedAudioId:000000}.acb"); zipArchive.CreateEntryFromFile(awb, $"SoundData/music{resolvedAudioId:000000}.awb"); #else - logger.LogWarning("Audio export not supported on this platform; skipping ACB/AWB for music {Id}.", music.Id); + logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 ACB/AWB。", music.Id); #endif - // copy movie data + // 复制视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) { zipArchive.CreateEntryFromFile(movie, $"MovieData/{music.NonDxId:000000}{Path.GetExtension(movie)}"); @@ -589,7 +589,7 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string FileSystem.MoveFile(awb, acbawbTarget + ".awb", UIOption.OnlyErrorDialogs); } - // movie data + // 视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) { logger.LogInformation("Move movie: {movie} -> {movieTarget}", movie, movieTarget); @@ -633,14 +633,14 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) chart.Path = newFileName; } - // xml + // 保存 XML music.Id = newId; music.Save(); Directory.CreateDirectory(Path.Combine(StaticSettings.StreamingAssets, assetDir, "music")); logger.LogInformation("Move music dir: {oldMusicDir} -> {newMusicDir}", oldMusicDir, newMusicDir); FileSystem.MoveDirectory(oldMusicDir, newMusicDir, UIOption.OnlyErrorDialogs); - // rescan all + // 重新扫描全部 await settings.RescanAll(); } @@ -670,7 +670,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa var version = StaticSettings.VersionList.FirstOrDefault(it => it.Id == music.AddVersionId); if (version is not null) simaiFile["version"] = version.GenreName; - // demo_seek + // demo_seek(预览起止时间) #if WINDOWS try { @@ -685,7 +685,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } catch (Exception e) { - logger.LogWarning(e, "ExportAsMaidata: Failed to get audio preview time, ignoring."); + logger.LogWarning(e, "ExportAsMaidata: 获取音频预览时间失败,已忽略。"); } #endif @@ -744,7 +744,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa await maidataStream.WriteAsync(Encoding.UTF8.GetBytes(simaiFile.ToString())); maidataStream.Close(); - // copy jacket + // 复制封面 var img = music.GetMusicJacketPngData(); if (img is not null) { @@ -780,7 +780,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa AudioConvert.ConvertWavToMp3Stream(wav, soundStream, tag); soundStream.Close(); #else - logger.LogWarning("Audio export not supported on this platform; skipping track.mp3 for music {Id}.", music.Id); + logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 track.mp3。", music.Id); #endif if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) @@ -811,7 +811,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } catch (Exception ex) { - logger.LogWarning(ex, "Failed to export pv.mp4 for music {musicId} ({name}), skipping video.", music.Id, music.Name); + logger.LogWarning(ex, "导出音乐 {musicId}({name})的 pv.mp4 失败,跳过视频。", music.Id, music.Name); } finally { @@ -823,7 +823,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } catch { - // ignore cleanup errors + // 忽略清理错误 } } } diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index decef5b..737da3d 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -354,7 +354,7 @@ private async Task WriteSseFrames(ChannelReader reader, CancellationToke } catch (OperationCanceledException) { - // ignore + // 忽略取消异常 } } } diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs index 2eff3b7..e1ce116 100644 --- a/MaiChartManager/LinuxProgram.cs +++ b/MaiChartManager/LinuxProgram.cs @@ -16,8 +16,8 @@ public static void Main(string[] args) } /// - /// Minimal, headless config load for Linux. Mirrors AppMain.InitConfiguration but - /// without the Sentry / MessageBox / WinForms parts (those live in the excluded AppMain.cs). + /// Linux 的最小化无头配置加载。对应 AppMain.InitConfiguration,但去掉了 + /// Sentry / MessageBox / WinForms 相关部分(这些代码在被排除的 AppMain.cs 中)。 /// private static void InitConfiguration() { @@ -34,13 +34,13 @@ private static void InitConfiguration() } catch { - // Corrupted config: drop it and continue with defaults (OOBE flow). + // 配置文件损坏:丢弃并使用默认值继续(进入 OOBE 流程)。 try { File.Delete(cfgFilePath); } catch { /* ignore */ } } } - // Apply persisted locale (AppMain.SetLocale is Windows-only). + // 应用持久化的语言区域(AppMain.SetLocale 仅限 Windows)。 var locale = string.IsNullOrWhiteSpace(StaticSettings.Config.Locale) ? "zh" : StaticSettings.Config.Locale; if (locale != "zh" && locale != "zh-TW" && locale != "en") locale = "zh"; @@ -59,7 +59,7 @@ private static void InitConfiguration() CultureInfo.CurrentUICulture = culture; MuConvert.utils.Utils.SetLocale(new CultureInfo(locale)); - // If a valid game path was persisted, restore it so the app starts in management mode. + // 如果已持久化有效的游戏路径,则恢复它,使应用以管理模式启动。 if (!string.IsNullOrWhiteSpace(StaticSettings.Config.GamePath) && Directory.Exists(StaticSettings.Config.GamePath)) { StaticSettings.GamePath = StaticSettings.Config.GamePath; diff --git a/MaiChartManager/Models/MusicXmlWithABJacket.cs b/MaiChartManager/Models/MusicXmlWithABJacket.cs index b03de6e..81182b4 100644 --- a/MaiChartManager/Models/MusicXmlWithABJacket.cs +++ b/MaiChartManager/Models/MusicXmlWithABJacket.cs @@ -144,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); diff --git a/MaiChartManager/Platform/IAppShell.cs b/MaiChartManager/Platform/IAppShell.cs index 3cd979c..541fcb6 100644 --- a/MaiChartManager/Platform/IAppShell.cs +++ b/MaiChartManager/Platform/IAppShell.cs @@ -1,39 +1,39 @@ namespace MaiChartManager.Platform; /// -/// Desktop-shell / native window operations used by the web controllers. -/// On Windows these delegate to WinForms (AppLifecycleManager / AppMain / Browser / Application / UWP StartupTask). -/// On Linux they no-op or return defaults (Phase 3 Photino wires real behaviour). +/// Web 控制器使用的桌面外壳 / 原生窗口操作接口。 +/// 在 Windows 上委托给 WinForms(AppLifecycleManager / AppMain / Browser / Application / UWP StartupTask)。 +/// 在 Linux 上为空操作或返回默认值(第三阶段 Photino 会接入真正的原生行为)。 /// public interface IAppShell { - /// Show (or focus + refresh) the main browser window for the given loopback url. + /// 显示(或聚焦并刷新)指定回环地址的主浏览器窗口。 void ShowBrowser(string loopbackUrl); - /// Switch to the OOBE / mode-switch window for the given loopback url. + /// 切换到指定回环地址的 OOBE / 模式切换窗口。 void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode"); - /// Close / dispose the OOBE browser window if present. + /// 关闭并释放 OOBE 浏览器窗口(若存在)。 void CloseOobeBrowser(); - /// Inject a (possibly new) backend url into the OOBE browser window. + /// 向 OOBE 浏览器窗口注入(可能已更新的)后端地址。 void InjectOobeBackendUrl(string loopbackUrl); - /// Update the main window title to reflect the current game path. + /// 根据当前游戏路径更新主窗口标题。 void UpdateMainWindowTitle(string gamePath); - /// Show / hide the tray icon (export + startup mode). + /// 显示 / 隐藏托盘图标(导出模式 + 开机启动模式)。 void DisposeTrayIcon(); - /// Enable or disable the OS "run at startup" task. Returns true on success. + /// 启用或禁用系统"开机自启"任务,成功返回 true。 Task SetStartupEnabledAsync(bool enabled); - /// Apply a locale change to native UI (window chrome, embedded libs). + /// 将语言区域变更应用到原生 UI(窗口装饰、内嵌库等)。 void ReloadLocale(string locale); - /// The DPI scale of the main window, used to report UI zoom defaults. + /// 主窗口的 DPI 缩放比例,用于报告默认 UI 缩放值。 double GetTargetDpiScale(); - /// Exit the whole application. + /// 退出整个应用程序。 void ExitApp(); } diff --git a/MaiChartManager/Platform/Linux/HeadlessAppShell.cs b/MaiChartManager/Platform/Linux/HeadlessAppShell.cs index 6e600ad..2b6e62e 100644 --- a/MaiChartManager/Platform/Linux/HeadlessAppShell.cs +++ b/MaiChartManager/Platform/Linux/HeadlessAppShell.cs @@ -4,8 +4,8 @@ namespace MaiChartManager.Platform.Linux; /// -/// Headless app-shell for Linux. Window / tray / startup-task operations are no-ops; -/// Phase 3 Photino wires the real native behaviour. +/// Linux 无头模式的应用外壳。窗口 / 托盘 / 开机启动等操作均为空操作; +/// 第三阶段 Photino 会接入真正的原生行为。 /// public class HeadlessAppShell(ILogger logger) : IAppShell { diff --git a/MaiChartManager/Platform/Linux/HeadlessDialogService.cs b/MaiChartManager/Platform/Linux/HeadlessDialogService.cs index eeb7034..b8681da 100644 --- a/MaiChartManager/Platform/Linux/HeadlessDialogService.cs +++ b/MaiChartManager/Platform/Linux/HeadlessDialogService.cs @@ -4,8 +4,8 @@ namespace MaiChartManager.Platform.Linux; /// -/// Headless dialog service for Linux. Native file pickers are not available in the -/// current headless host; Phase 3 will replace this with Photino dialogs. +/// Linux 无头模式的对话框服务。当前无头宿主不支持原生文件选择对话框; +/// 第三阶段将替换为基于 Photino 的对话框。 /// public class HeadlessDialogService(ILogger logger) : IDesktopDialogService { diff --git a/MaiChartManager/Platform/Linux/LinuxShellService.cs b/MaiChartManager/Platform/Linux/LinuxShellService.cs index 6f4bb86..81d7c67 100644 --- a/MaiChartManager/Platform/Linux/LinuxShellService.cs +++ b/MaiChartManager/Platform/Linux/LinuxShellService.cs @@ -4,12 +4,12 @@ namespace MaiChartManager.Platform.Linux; -/// Shell integration for Linux via xdg-open / xdg-utils. +/// 通过 xdg-open / xdg-utils 实现 Linux 系统集成。 public class LinuxShellService(ILogger logger) : IShellService { public void RevealInFileManager(string path) { - // No portable "select file" on Linux file managers; open the containing directory. + // Linux 文件管理器没有通用的"选中文件"功能;改为打开所在目录。 var target = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path; XdgOpen(target); } diff --git a/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs b/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs index 2e6e72a..321c27a 100644 --- a/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs +++ b/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs @@ -2,7 +2,7 @@ namespace MaiChartManager.Platform.Linux; -/// No-op taskbar progress for Linux (no Windows taskbar). +/// Linux 上的空操作任务栏进度(无 Windows 任务栏)。 public class NoopTaskbarProgress : ITaskbarProgress { public void Set(ulong value, ulong total = 100) { } diff --git a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs index 2d25f68..7fe5e57 100644 --- a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs +++ b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs @@ -5,8 +5,8 @@ namespace MaiChartManager.Platform.Windows; /// -/// WinForms-backed dialog service. Mirrors the original WinUtils.ShowDialog + -/// per-controller FolderBrowserDialog/OpenFileDialog/MessageBox usage. +/// 基于 WinForms 的对话框服务,对应原来 WinUtils.ShowDialog + +/// 各控制器中 FolderBrowserDialog/OpenFileDialog/MessageBox 的用法。 /// public class WinFormsDialogService : IDesktopDialogService { diff --git a/MaiChartManager/Platform/Windows/WindowsAppShell.cs b/MaiChartManager/Platform/Windows/WindowsAppShell.cs index 81feb66..924493c 100644 --- a/MaiChartManager/Platform/Windows/WindowsAppShell.cs +++ b/MaiChartManager/Platform/Windows/WindowsAppShell.cs @@ -5,8 +5,8 @@ namespace MaiChartManager.Platform.Windows; /// -/// Windows app-shell, delegating to AppLifecycleManager / AppMain / Browser / -/// Application / UWP StartupTask exactly as the original controllers did. +/// Windows 应用外壳,委托给 AppLifecycleManager / AppMain / Browser / +/// Application / UWP StartupTask,与原来各控制器的实现完全一致。 /// public class WindowsAppShell : IAppShell { @@ -59,8 +59,8 @@ public async Task SetStartupEnabledAsync(bool enabled) public void ReloadLocale(string locale) { - // Locale state (CurrentLocale/Config/Culture) is applied by LocaleController in a - // platform-independent way. Nothing extra to refresh on the WinForms shell for now. + // 语言区域状态(CurrentLocale/Config/Culture)已由 LocaleController 以平台无关的方式应用。 + // WinForms 外壳目前不需要额外刷新任何内容。 } public double GetTargetDpiScale() => Browser.TargetDpiScale; diff --git a/MaiChartManager/Platform/Windows/WindowsShellService.cs b/MaiChartManager/Platform/Windows/WindowsShellService.cs index 2228ddb..e65ba16 100644 --- a/MaiChartManager/Platform/Windows/WindowsShellService.cs +++ b/MaiChartManager/Platform/Windows/WindowsShellService.cs @@ -3,7 +3,7 @@ namespace MaiChartManager.Platform.Windows; -/// Windows shell integration via explorer.exe / ShellExecute. +/// 通过 explorer.exe / ShellExecute 实现 Windows 系统集成。 public class WindowsShellService : IShellService { public void RevealInFileManager(string path) diff --git a/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs b/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs index 4bd4266..bc64115 100644 --- a/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs +++ b/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs @@ -3,7 +3,7 @@ namespace MaiChartManager.Platform.Windows; -/// Windows taskbar progress, delegating to the existing Vanara-backed WinUtils helpers. +/// Windows 任务栏进度,委托给基于 Vanara 的 WinUtils 辅助方法。 public class WindowsTaskbarProgress : ITaskbarProgress { public void Set(ulong value, ulong total = 100) => WinUtils.SetTaskbarProgress(value, total); diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index 6b3b0a1..b2ee22f 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -79,10 +79,10 @@ public static void StartApp(bool export, Action? onStart = null) 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 => diff --git a/MaiChartManager/StaticSettings.cs b/MaiChartManager/StaticSettings.cs index ea83d61..7e09824 100644 --- a/MaiChartManager/StaticSettings.cs +++ b/MaiChartManager/StaticSettings.cs @@ -140,7 +140,7 @@ public void ScanMusicList() } } - _logger.LogInformation("Scan music list, found {0} music.", _musicList.Count); + _logger.LogInformation("扫描音乐列表,共找到 {0} 首音乐。", _musicList.Count); } public void ScanGenre() @@ -176,7 +176,7 @@ public void ScanGenre() } } - _logger.LogInformation("Scan genre list, found {0} genre.", GenreList.Count); + _logger.LogInformation("扫描流派列表,共找到 {0} 个流派。", GenreList.Count); } public void ScanVersionList() @@ -211,7 +211,7 @@ public void ScanVersionList() } } - _logger.LogInformation("Scan version list, found {VersionListCount} version.", VersionList.Count); + _logger.LogInformation("扫描版本列表,共找到 {VersionListCount} 个版本。", VersionList.Count); } public void ScanAssetBundles() @@ -233,7 +233,7 @@ public void ScanAssetBundles() } } - _logger.LogInformation($"Scan AssetBundles, found {AssetBundleJacketMap.Count} AssetBundles."); + _logger.LogInformation($"扫描 AssetBundle,共找到 {AssetBundleJacketMap.Count} 个 AssetBundle。"); } public void ScanSoundData() @@ -248,7 +248,7 @@ public void ScanSoundData() } } - _logger.LogInformation($"Scan SoundData, found {AcbAwb.Count} SoundData."); + _logger.LogInformation($"扫描 SoundData,共找到 {AcbAwb.Count} 个音频文件。"); } public void ScanMovieData() @@ -264,7 +264,7 @@ public void ScanMovieData() } } - _logger.LogInformation($"Scan MovieData, found {MovieDataMap.Count} MovieData."); + _logger.LogInformation($"扫描 MovieData,共找到 {MovieDataMap.Count} 个视频文件。"); } public void GetGameVersion() From 4f0e46bc62c122369f9e923924950718e9d85003 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 02:34:23 +0800 Subject: [PATCH 14/50] =?UTF-8?q?feat(linux):=20Photino=20=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E2=80=94=E2=80=94=E8=BF=9B=E7=A8=8B=E5=86=85=20Kestre?= =?UTF-8?q?l=20=E5=90=8C=E6=BA=90=E4=BC=BA=E6=9C=8D=20SPA=20+=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E7=AA=97=E5=8F=A3=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- MaiChartManager/LinuxProgram.cs | 33 ++++++++++++++++++++++++-- MaiChartManager/MaiChartManager.csproj | 8 +++++++ MaiChartManager/ServerManager.cs | 7 ++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs index e1ce116..de27512 100644 --- a/MaiChartManager/LinuxProgram.cs +++ b/MaiChartManager/LinuxProgram.cs @@ -1,6 +1,7 @@ #if !WINDOWS using System.Globalization; using System.Text.Json; +using Photino.NET; namespace MaiChartManager; @@ -11,8 +12,36 @@ public static void Main(string[] args) Directory.CreateDirectory(StaticSettings.appData); Directory.CreateDirectory(StaticSettings.tempPath); InitConfiguration(); - ServerManager.StartApp(false, url => Console.WriteLine($"MaiChartManager backend listening at {url}")); - Thread.Sleep(Timeout.Infinite); + + // 启动进程内 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}"); + + // Photino 必须在主线程创建并显示窗口。Linux 下底层走系统 WebKitGTK。 + // 加载 Kestrel 的 loopback 根地址:SPA 与 API 同源,前端无需注入 backendUrl。 + new PhotinoWindow() + .SetTitle("MaiChartManager") + .SetUseOsDefaultSize(false) + .SetSize(1280, 800) + .Center() + .Load(new Uri(backendUrl)) + .WaitForClose(); } /// diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index c7fda57..8465cf9 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -122,6 +122,14 @@ + + + + + + diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index b2ee22f..ec7ab8a 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -73,7 +73,9 @@ 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(); @@ -197,7 +199,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), From 00dd114036e815c8868f15cdbff00ecf339a4bdd Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 02:39:01 +0800 Subject: [PATCH 15/50] =?UTF-8?q?feat(linux):=20=E7=94=A8=20Photino=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=96=87=E4=BB=B6/=E6=96=87=E4=BB=B6=E5=A4=B9/=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=A1=86=EF=BC=88=E6=9B=BF=E6=8D=A2=20headless=20stub?= =?UTF-8?q?=EF=BC=89=EF=BC=8COOBE=20=E9=80=89=E7=9B=AE=E5=BD=95=E5=8F=AF?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- MaiChartManager/LinuxProgram.cs | 10 +- .../Platform/Linux/HeadlessDialogService.cs | 35 ----- .../Platform/Linux/PhotinoDialogService.cs | 146 ++++++++++++++++++ .../Platform/Linux/PhotinoWindowHolder.cs | 13 ++ MaiChartManager/ServerManager.cs | 3 +- 5 files changed, 168 insertions(+), 39 deletions(-) delete mode 100644 MaiChartManager/Platform/Linux/HeadlessDialogService.cs create mode 100644 MaiChartManager/Platform/Linux/PhotinoDialogService.cs create mode 100644 MaiChartManager/Platform/Linux/PhotinoWindowHolder.cs diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs index de27512..b476078 100644 --- a/MaiChartManager/LinuxProgram.cs +++ b/MaiChartManager/LinuxProgram.cs @@ -35,13 +35,17 @@ public static void Main(string[] args) // Photino 必须在主线程创建并显示窗口。Linux 下底层走系统 WebKitGTK。 // 加载 Kestrel 的 loopback 根地址:SPA 与 API 同源,前端无需注入 backendUrl。 - new PhotinoWindow() + var window = new PhotinoWindow() .SetTitle("MaiChartManager") .SetUseOsDefaultSize(false) .SetSize(1280, 800) .Center() - .Load(new Uri(backendUrl)) - .WaitForClose(); + .Load(new Uri(backendUrl)); + + // 把窗口实例交给平台服务持有者,供 Linux 的对话框服务(PhotinoDialogService)使用。 + Platform.Linux.PhotinoWindowHolder.Current = window; + + window.WaitForClose(); } /// diff --git a/MaiChartManager/Platform/Linux/HeadlessDialogService.cs b/MaiChartManager/Platform/Linux/HeadlessDialogService.cs deleted file mode 100644 index b8681da..0000000 --- a/MaiChartManager/Platform/Linux/HeadlessDialogService.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MaiChartManager.Platform; -using Microsoft.Extensions.Logging; - -namespace MaiChartManager.Platform.Linux; - -/// -/// Linux 无头模式的对话框服务。当前无头宿主不支持原生文件选择对话框; -/// 第三阶段将替换为基于 Photino 的对话框。 -/// -public class HeadlessDialogService(ILogger logger) : IDesktopDialogService -{ - public string? PickFolder(string? title = null) - { - logger.LogWarning("PickFolder is not supported on this platform (headless). title={Title}", title); - return null; - } - - public string? PickFile(string? title = null, string? filter = null) - { - logger.LogWarning("PickFile is not supported on this platform (headless). title={Title}", title); - return null; - } - - public bool Confirm(string message, string title, bool defaultResult = false) - { - logger.LogWarning("Confirm dialog not supported on this platform (headless), returning default {Default}. title={Title} message={Message}", - defaultResult, title, message); - return defaultResult; - } - - public void ShowError(string message, string title) - { - logger.LogError("ShowError ({Title}): {Message}", title, message); - } -} diff --git a/MaiChartManager/Platform/Linux/PhotinoDialogService.cs b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs new file mode 100644 index 0000000..00be144 --- /dev/null +++ b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs @@ -0,0 +1,146 @@ +#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(); + // 必须在 UI 线程调用 ShowOpenFolder。 + window.Invoke(() => + { + try + { + // ShowOpenFolder(string title, string defaultPath, bool multiSelect) + result = window.ShowOpenFolder(title ?? "", null, false); + } + catch (Exception e) + { + logger.LogError(e, "PickFolder:弹出文件夹选择对话框失败。"); + } + finally + { + done.Set(); + } + }); + done.Wait(); + return result is { Length: > 0 } ? result[0] : null; + } + + 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 0000000..4af1c39 --- /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/ServerManager.cs b/MaiChartManager/ServerManager.cs index ec7ab8a..d25c163 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -131,7 +131,8 @@ public static void StartApp(bool export, Action? onStart = null, bool se builder.Services.AddSingleton(); builder.Services.AddSingleton(); #else - builder.Services.AddSingleton(); + // 使用 Photino 原生对话框(替换原 HeadlessDialogService 占位实现),让 OOBE 选目录可用。 + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From 9bb69de0922d0700172f65d13cca2e04cb1de78c Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 02:50:10 +0800 Subject: [PATCH 16/50] =?UTF-8?q?fix(linux):=20OOBE=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=20+=20=E5=8D=95=E7=AA=97=E5=8F=A3=E5=AF=BC=E8=88=AA=20+=20Game?= =?UTF-8?q?Path=20=E7=A9=BA=E5=80=BC=E5=85=9C=E5=BA=95=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=9C=AA=E9=85=8D=E7=BD=AE=E6=B8=B8=E6=88=8F=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=97=B6=E7=9A=84=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GamePath 默认空串而非 null,AssetsDirs 在 StreamingAssets 不存在时返回空,避免 Path.Combine/枚举抛异常 - LinuxProgram 未配置游戏目录时加载 #/oobe 引导页(对齐 Windows AppMain 逻辑) - PhotinoAppShell 替换 HeadlessAppShell:用 PhotinoWindowHolder 导航单窗口,使 OOBE 完成后能切到主界面 --- MaiChartManager/LinuxProgram.cs | 13 ++- .../Platform/Linux/HeadlessAppShell.cs | 43 ------- .../Platform/Linux/PhotinoAppShell.cs | 106 ++++++++++++++++++ MaiChartManager/ServerManager.cs | 2 +- MaiChartManager/StaticSettings.cs | 9 +- 5 files changed, 123 insertions(+), 50 deletions(-) delete mode 100644 MaiChartManager/Platform/Linux/HeadlessAppShell.cs create mode 100644 MaiChartManager/Platform/Linux/PhotinoAppShell.cs diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs index b476078..40b5975 100644 --- a/MaiChartManager/LinuxProgram.cs +++ b/MaiChartManager/LinuxProgram.cs @@ -33,16 +33,23 @@ public static void Main(string[] args) 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。 + // 加载 Kestrel 的 loopback 地址:SPA 与 API 同源,前端无需注入 backendUrl。 var window = new PhotinoWindow() .SetTitle("MaiChartManager") .SetUseOsDefaultSize(false) .SetSize(1280, 800) .Center() - .Load(new Uri(backendUrl)); + .Load(new Uri(startUrl)); - // 把窗口实例交给平台服务持有者,供 Linux 的对话框服务(PhotinoDialogService)使用。 + // 把窗口实例交给平台服务持有者,供 Linux 的对话框服务与应用外壳(导航/标题等)使用。 Platform.Linux.PhotinoWindowHolder.Current = window; window.WaitForClose(); diff --git a/MaiChartManager/Platform/Linux/HeadlessAppShell.cs b/MaiChartManager/Platform/Linux/HeadlessAppShell.cs deleted file mode 100644 index 2b6e62e..0000000 --- a/MaiChartManager/Platform/Linux/HeadlessAppShell.cs +++ /dev/null @@ -1,43 +0,0 @@ -using MaiChartManager.Platform; -using Microsoft.Extensions.Logging; - -namespace MaiChartManager.Platform.Linux; - -/// -/// Linux 无头模式的应用外壳。窗口 / 托盘 / 开机启动等操作均为空操作; -/// 第三阶段 Photino 会接入真正的原生行为。 -/// -public class HeadlessAppShell(ILogger logger) : IAppShell -{ - public void ShowBrowser(string loopbackUrl) - => logger.LogInformation("ShowBrowser (headless no-op): {Url}", loopbackUrl); - - public void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode") - => logger.LogInformation("GoToModeSwitch (headless no-op): {Url}{Hash}", loopbackUrl, hash); - - public void CloseOobeBrowser() - => logger.LogInformation("CloseOobeBrowser (headless no-op)"); - - public void InjectOobeBackendUrl(string loopbackUrl) - => logger.LogInformation("InjectOobeBackendUrl (headless no-op): {Url}", loopbackUrl); - - public void UpdateMainWindowTitle(string gamePath) - => logger.LogDebug("UpdateMainWindowTitle (headless no-op): {GamePath}", gamePath); - - public void DisposeTrayIcon() - => logger.LogDebug("DisposeTrayIcon (headless no-op)"); - - public Task SetStartupEnabledAsync(bool enabled) - { - logger.LogInformation("SetStartupEnabledAsync (headless no-op): {Enabled}", enabled); - return Task.FromResult(false); - } - - public void ReloadLocale(string locale) - => logger.LogDebug("ReloadLocale (headless no-op): {Locale}", locale); - - public double GetTargetDpiScale() => 1.0; - - public void ExitApp() - => logger.LogInformation("ExitApp (headless no-op)"); -} diff --git a/MaiChartManager/Platform/Linux/PhotinoAppShell.cs b/MaiChartManager/Platform/Linux/PhotinoAppShell.cs new file mode 100644 index 0000000..aee4026 --- /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/ServerManager.cs b/MaiChartManager/ServerManager.cs index d25c163..f0d7469 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -135,7 +135,7 @@ public static void StartApp(bool export, Action? onStart = null, bool se builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); #endif diff --git a/MaiChartManager/StaticSettings.cs b/MaiChartManager/StaticSettings.cs index 7e09824..b3a33f7 100644 --- a/MaiChartManager/StaticSettings.cs +++ b/MaiChartManager/StaticSettings.cs @@ -69,11 +69,14 @@ public async Task InitializeGameData() [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)) + : []; public int gameVersion; private List _musicList = []; From 0ebc92f4a435989fa32108eee66a1e75d203c87f Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 03:09:49 +0800 Subject: [PATCH 17/50] =?UTF-8?q?fix(linux):=20=E8=BF=81=E7=A7=BB=E6=89=80?= =?UTF-8?q?=E6=9C=89=20VisualBasic.FileIO=20=E6=96=87=E4=BB=B6=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=88=B0=E8=B7=A8=E5=B9=B3=E5=8F=B0=20PlatformFile?= =?UTF-8?q?=EF=BC=88=E4=BF=AE=E5=A4=8D=20Linux=20=E5=88=A0=E9=99=A4/?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8/=E5=A4=8D=E5=88=B6=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=B4=A9=E6=BA=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AssetDir/AssetDirController.cs | 7 +++---- .../AssetDir/CheckConflictController.cs | 4 ++-- .../Controllers/Mod/InstallationController.cs | 8 +++---- .../Controllers/Music/MusicController.cs | 1 - .../Music/MusicTransferController.cs | 21 +++++++++---------- .../Tools/VideoConvertToolController.cs | 1 - .../Models/MusicXmlWithABJacket.cs | 18 ++++++++-------- MaiChartManager/Utils/VideoConvert.cs | 6 +++--- 8 files changed, 31 insertions(+), 35 deletions(-) diff --git a/MaiChartManager/Controllers/AssetDir/AssetDirController.cs b/MaiChartManager/Controllers/AssetDir/AssetDirController.cs index ad4d1f7..41f6990 100644 --- a/MaiChartManager/Controllers/AssetDir/AssetDirController.cs +++ b/MaiChartManager/Controllers/AssetDir/AssetDirController.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Net.Http.Headers; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.AssetDir; @@ -21,7 +20,7 @@ public void CreateAssetDir([FromBody] string dir) [HttpDelete] public void DeleteAssetDir([FromBody] string dir) { - FileSystem.DeleteDirectory(Path.Combine(StaticSettings.StreamingAssets, dir), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteDirectory(Path.Combine(StaticSettings.StreamingAssets, dir), showDialog: true); } public record GetAssetsDirsResult(string DirName, IEnumerable SubFiles, string Version); @@ -92,7 +91,7 @@ public string GetAssetDirTxtValue([FromBody] GetAssetDirTxtValueRequest req) [HttpDelete] public void DeleteAssetDirTxt([FromBody] GetAssetDirTxtValueRequest req) { - FileSystem.DeleteFile(Path.Combine(StaticSettings.StreamingAssets, req.DirName, req.FileName), UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(Path.Combine(StaticSettings.StreamingAssets, req.DirName, req.FileName)); } public record PutAssetDirTxtValueRequest(string DirName, string FileName, string Content); @@ -127,7 +126,7 @@ public async Task RequestLocalImportDir() var dest = Path.Combine(StaticSettings.StreamingAssets, destName); logger.LogInformation("Src: {src} Dest: {dest}", src, dest); - FileSystem.CopyDirectory(src, dest, UIOption.AllDialogs); + PlatformFile.CopyDirectory(src, dest); await settings.RescanAll(); } diff --git a/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs b/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs index c98d901..1042df9 100644 --- a/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs +++ b/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using MaiChartManager.Models; +using MaiChartManager.Platform; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.AssetDir; @@ -116,7 +116,7 @@ public void DeleteAssets([FromBody] DeleteAssetRequest[] requests) }; logger.LogInformation("Delete file {path}", Path.Combine(path, req.FileName)); - FileSystem.DeleteFile(Path.Combine(path, req.FileName), UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(Path.Combine(path, req.FileName)); } } } diff --git a/MaiChartManager/Controllers/Mod/InstallationController.cs b/MaiChartManager/Controllers/Mod/InstallationController.cs index bedb3c3..af1f6e4 100644 --- a/MaiChartManager/Controllers/Mod/InstallationController.cs +++ b/MaiChartManager/Controllers/Mod/InstallationController.cs @@ -1,9 +1,9 @@ using System.Diagnostics; using System.IO.Compression; using System.Security.Cryptography; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.Mod; @@ -123,7 +123,7 @@ public void DeleteHidConflict() { if (System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, mod))) { - FileSystem.DeleteFile(Path.Combine(StaticSettings.GamePath, mod), UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(Path.Combine(StaticSettings.GamePath, mod)); } } } @@ -316,7 +316,7 @@ public void DeleteAquaMai() { if (System.IO.File.Exists(ModPaths.AquaMaiDllInstalledPath)) { - FileSystem.DeleteFile(ModPaths.AquaMaiDllInstalledPath, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(ModPaths.AquaMaiDllInstalledPath); } } @@ -325,7 +325,7 @@ public void DeleteMuMod() { if (System.IO.File.Exists(ModPaths.MuModDllInstalledPath)) { - FileSystem.DeleteFile(ModPaths.MuModDllInstalledPath, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(ModPaths.MuModDllInstalledPath); } } diff --git a/MaiChartManager/Controllers/Music/MusicController.cs b/MaiChartManager/Controllers/Music/MusicController.cs index d29b50b..fb23614 100644 --- a/MaiChartManager/Controllers/Music/MusicController.cs +++ b/MaiChartManager/Controllers/Music/MusicController.cs @@ -2,7 +2,6 @@ using MaiChartManager.Models; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; using MusicXml = MaiChartManager.Models.MusicXml; namespace MaiChartManager.Controllers.Music; diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 2892797..66b2cf3 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -7,7 +7,6 @@ using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; using MuConvert.mai; using NAudio.Lame; @@ -484,22 +483,22 @@ private void DeleteIfExists(params string[] path) if (Directory.Exists(p)) { logger.LogInformation("Delete directory: {p}", p); - FileSystem.DeleteDirectory(p, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteDirectory(p); } if (System.IO.File.Exists(p)) { logger.LogInformation("Delete file: {p}", p); - FileSystem.DeleteFile(p, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(p); } } } private void DeleteAb(string abPath) { - FileSystem.DeleteFile(abPath, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(abPath); if (System.IO.File.Exists(abPath + ".manifest")) - FileSystem.DeleteFile(abPath + ".manifest", UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(abPath + ".manifest"); } private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string assetDir) @@ -526,7 +525,7 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string var localJacketTarget = Path.Combine(abiDir, $"ui_jacket_{newNonDxId:000000}{Path.GetExtension(jacketSourcePath)}"); DeleteIfExists(localJacketTarget); logger.LogInformation("Move jacket: {jacketSourcePng} -> {localJacketTarget}", jacketSourcePath, localJacketTarget); - FileSystem.MoveFile(jacketSourcePath, localJacketTarget, UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(jacketSourcePath, localJacketTarget); } else if (music.AssetBundleJacket is not null) { @@ -580,20 +579,20 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.acb", out var acb)) { logger.LogInformation("Move acb: {acb} -> {acbawbTarget}.acb", acb, acbawbTarget); - FileSystem.MoveFile(acb, acbawbTarget + ".acb", UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(acb, acbawbTarget + ".acb"); } if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.awb", out var awb)) { logger.LogInformation("Move awb: {awb} -> {acbawbTarget}.awb", awb, acbawbTarget); - FileSystem.MoveFile(awb, acbawbTarget + ".awb", UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(awb, acbawbTarget + ".awb"); } // 视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) { logger.LogInformation("Move movie: {movie} -> {movieTarget}", movie, movieTarget); - FileSystem.MoveFile(movie, movieTarget + Path.GetExtension(movie), UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(movie, movieTarget + Path.GetExtension(movie)); } #endregion } @@ -629,7 +628,7 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) if (!System.IO.File.Exists(Path.Combine(oldMusicDir, chart.Path))) continue; var newFileName = $"{newId:000000}_0{i}.ma2"; logger.LogInformation("Move chart: {chart.Path} -> {newFileName}", chart.Path, newFileName); - FileSystem.MoveFile(Path.Combine(oldMusicDir, chart.Path), Path.Combine(oldMusicDir, newFileName)); + PlatformFile.MoveFile(Path.Combine(oldMusicDir, chart.Path), Path.Combine(oldMusicDir, newFileName)); chart.Path = newFileName; } @@ -638,7 +637,7 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) music.Save(); Directory.CreateDirectory(Path.Combine(StaticSettings.StreamingAssets, assetDir, "music")); logger.LogInformation("Move music dir: {oldMusicDir} -> {newMusicDir}", oldMusicDir, newMusicDir); - FileSystem.MoveDirectory(oldMusicDir, newMusicDir, UIOption.OnlyErrorDialogs); + PlatformFile.MoveDirectory(oldMusicDir, newMusicDir); // 重新扫描全部 await settings.RescanAll(); diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index 737da3d..45cb340 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -3,7 +3,6 @@ using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.Tools; diff --git a/MaiChartManager/Models/MusicXmlWithABJacket.cs b/MaiChartManager/Models/MusicXmlWithABJacket.cs index 81182b4..d6e1f56 100644 --- a/MaiChartManager/Models/MusicXmlWithABJacket.cs +++ b/MaiChartManager/Models/MusicXmlWithABJacket.cs @@ -1,6 +1,6 @@ using System.Xml; +using MaiChartManager.Platform; using MaiChartManager.Utils; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Models; @@ -131,9 +131,9 @@ internal bool DeleteJacket() 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 { @@ -158,9 +158,9 @@ internal bool DeleteJacket() 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 { @@ -181,7 +181,7 @@ public void Delete() Console.WriteLine("删除 acb: " + acb); try { - FileSystem.DeleteFile(acb); + PlatformFile.DeleteFile(acb); } catch { @@ -194,7 +194,7 @@ public void Delete() Console.WriteLine("删除 awb: " + awb); try { - FileSystem.DeleteFile(awb); + PlatformFile.DeleteFile(awb); } catch { @@ -207,7 +207,7 @@ public void Delete() Console.WriteLine("删除 movieData: " + movieData); try { - FileSystem.DeleteFile(movieData); + PlatformFile.DeleteFile(movieData); } catch { @@ -218,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/Utils/VideoConvert.cs b/MaiChartManager/Utils/VideoConvert.cs index d0dfc0a..e035436 100644 --- a/MaiChartManager/Utils/VideoConvert.cs +++ b/MaiChartManager/Utils/VideoConvert.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualBasic.FileIO; +using MaiChartManager.Platform; using Xabe.FFmpeg; namespace MaiChartManager.Utils; @@ -147,7 +147,7 @@ public static async Task ConvertVideo(VideoConvertOptions options) // 第二步:VP9 直接打包到目标 USM,避免中间 USM 文件再复制。 if (options.UseH264) { - FileSystem.CopyFile(intermediateFile, options.OutputPath, true); + PlatformFile.CopyFile(intermediateFile, options.OutputPath); } else { @@ -342,7 +342,7 @@ public static async Task ConvertUsmToMp4(string inputPath, string outputPath, Ac var movieUsm = Path.Combine(tmpDir.FullName, "movie.usm"); onProgress?.Invoke(10); - FileSystem.CopyFile(inputPath, movieUsm, UIOption.OnlyErrorDialogs); + PlatformFile.CopyFile(inputPath, movieUsm); // 解包 USM onProgress?.Invoke(30); From 09a685ad74c60ca974f9332fc43424e34cdacb8f Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 03:12:35 +0800 Subject: [PATCH 18/50] =?UTF-8?q?fix(linux):=20=E7=89=88=E6=9C=AC/?= =?UTF-8?q?=E6=B5=81=E6=B4=BE=E6=89=AB=E6=8F=8F=E5=A4=A7=E5=B0=8F=E5=86=99?= =?UTF-8?q?=E4=B8=8D=E6=95=8F=E6=84=9F=20+=20=E8=A1=A5=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=20PlatformFile=EF=BC=88=E4=BF=AE=E5=A4=8D=200ebc92f=20?= =?UTF-8?q?=E6=BC=8F=E6=8F=90=E4=BA=A4=E5=AF=BC=E8=87=B4=E7=9A=84=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E7=BC=BA=E5=A4=B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PlatformFile.cs(上一提交迁移了调用点但漏提交了类本身,导致分支无法编译) - StaticSettings 扫描用 ResolveSubDir 大小写不敏感解析子目录,去掉 Linux 区分大小写的 glob - PathUtils.ResolveIgnoreCase 逐段大小写不敏感解析路径 - VersionXml/GenreXml 的 FilePath 用 ResolveIgnoreCase,删除改用 PlatformFile --- MaiChartManager/Models/GenreXml.cs | 8 +-- MaiChartManager/Models/VersionXml.cs | 8 +-- MaiChartManager/Platform/PlatformFile.cs | 64 ++++++++++++++++++++++++ MaiChartManager/StaticSettings.cs | 57 +++++++++++++++------ MaiChartManager/Utils/PathUtils.cs | 31 ++++++++++++ 5 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 MaiChartManager/Platform/PlatformFile.cs diff --git a/MaiChartManager/Models/GenreXml.cs b/MaiChartManager/Models/GenreXml.cs index 5d757b6..1a3cbe8 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/VersionXml.cs b/MaiChartManager/Models/VersionXml.cs index 81a3333..2ecccd6 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/PlatformFile.cs b/MaiChartManager/Platform/PlatformFile.cs new file mode 100644 index 0000000..5007daf --- /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/StaticSettings.cs b/MaiChartManager/StaticSettings.cs index b3a33f7..8c5456c 100644 --- a/MaiChartManager/StaticSettings.cs +++ b/MaiChartManager/StaticSettings.cs @@ -78,6 +78,20 @@ public async Task InitializeGameData() ? 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 = []; public static List GenreList { get; set; } = []; @@ -123,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)) { @@ -152,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); @@ -187,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); @@ -223,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); @@ -244,8 +267,9 @@ 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; } @@ -259,8 +283,9 @@ 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; diff --git a/MaiChartManager/Utils/PathUtils.cs b/MaiChartManager/Utils/PathUtils.cs index 05d71b8..5e48b63 100644 --- a/MaiChartManager/Utils/PathUtils.cs +++ b/MaiChartManager/Utils/PathUtils.cs @@ -6,4 +6,35 @@ public static class PathUtils public static bool ContainsSegment(string? path, string segment) => path is not null && path.Replace('\\', '/').Contains($"/{segment}/", System.StringComparison.InvariantCultureIgnoreCase); + + /// + /// 大小写不敏感地逐段解析路径,返回文件系统中实际存在的真实大小写路径。 + /// 用于兼容 Linux 大小写敏感文件系统:游戏目录/文件大小写可能与代码硬编码的不一致 + /// (如 musicVersion / musicversion、MusicVersion000001 / musicversion000001)。 + /// 某一段在文件系统中不存在时(例如写入尚不存在的新文件/目录),该段及之后按给定大小写直接拼接。 + /// + public static string ResolveIgnoreCase(string basePath, params string[] segments) + { + var current = basePath; + foreach (var seg in segments) + { + var exact = Path.Combine(current, seg); + if (File.Exists(exact) || Directory.Exists(exact)) + { + current = exact; + continue; + } + + string? match = null; + if (Directory.Exists(current)) + { + match = Directory.EnumerateFileSystemEntries(current) + .FirstOrDefault(e => string.Equals(Path.GetFileName(e), seg, System.StringComparison.OrdinalIgnoreCase)); + } + + current = match ?? exact; + } + + return current; + } } From 498999279e2c95b35b86f27b7df52137e02613d4 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 03:29:57 +0800 Subject: [PATCH 19/50] =?UTF-8?q?fix(linux/web):=20WebKitGTK=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E2=80=94=E2=80=94=E5=8D=95=E6=96=87=E4=BB=B6=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E6=94=B9=E7=94=A8=20input[type=3Dfile]=EF=BC=8C?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=93=8D=E4=BD=9C=E6=8C=89=20isLocalHost=20?= =?UTF-8?q?=E8=B5=B0=E5=90=8E=E7=AB=AF=E5=8E=9F=E7=94=9F=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=EF=BC=8C=E6=B6=88=E9=99=A4=E4=B8=8D=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- MaiChartManager/Front/src/client/api.ts | 4 ++ .../DragDropDispatcher/ReplaceChartModal.tsx | 41 ++++++++---------- .../Front/src/components/JacketBox.tsx | 23 ++++------ MaiChartManager/Front/src/utils/pickFile.ts | 16 +++++++ .../src/views/BatchAction/ChooseAction.tsx | 6 +-- .../AssetDirsManager/ImportLocalButton.tsx | 4 +- .../src/views/Charts/CopyToButton/index.tsx | 4 +- .../src/views/Charts/MusicEdit/AcbAwb.tsx | 42 ++++++------------- .../views/Charts/MusicEdit/SetMovieButton.tsx | 26 +++++------- .../GenreVersionManager/SetImageButton.tsx | 19 ++------- MaiChartManager/Front/src/views/Index.tsx | 5 ++- 11 files changed, 84 insertions(+), 106 deletions(-) create mode 100644 MaiChartManager/Front/src/utils/pickFile.ts diff --git a/MaiChartManager/Front/src/client/api.ts b/MaiChartManager/Front/src/client/api.ts index e31578e..7f8ea7b 100644 --- a/MaiChartManager/Front/src/client/api.ts +++ b/MaiChartManager/Front/src/client/api.ts @@ -7,6 +7,10 @@ declare global { // 在 WebView2 环境中,域名是 mcm.invalid,backendUrl 会通过 PostWebMessageAsString 注入 // 在远程浏览器(export 模式)中,直接用相对路径(当前 origin) export const isWebView = location.hostname === 'mcm.invalid'; +// 本地桌面宿主:Windows WebView2(mcm.invalid) 或 Photino/本机浏览器(loopback)。 +// 这些情况下后端在同机,文件/目录操作走后端原生对话框,而不是浏览器的 File System Access API +//(WebKitGTK 不支持后者)。远程浏览器(局域网 IP 访问 export 模式)则为 false。 +export const isLocalHost = isWebView || ['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname); const getBaseUrl = () => (globalThis as any).backendUrl ?? (isWebView ? undefined : ''); export const apiClient = new Api({ diff --git a/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx b/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx index b729525..cbe1c0f 100644 --- a/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx +++ b/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx @@ -11,6 +11,7 @@ import { Chart, ImportChartCheckResult, ImportChartResult, ShiftMethod } from "@ import ImportAlert from "@/views/Charts/ImportCreateChartButton/ImportChartButton/ImportAlert"; import { defaultTempOptions, ImportChartMessageEx, TempOptions } from "@/views/Charts/ImportCreateChartButton/ImportChartButton/types"; import ShiftModeSelector from "@/views/Charts/ImportCreateChartButton/ImportChartButton/ShiftModeSelector"; +import { pickFile } from "@/utils/pickFile"; // noinspection JSUnusedLocalSymbols export let prepareReplaceChart = async (fileHandle?: FileSystemFileHandle) => { @@ -20,7 +21,7 @@ export default defineComponent({ setup() { const checking = ref(false); - const fileHandle = shallowRef(null); + const file = shallowRef(null); const show = ref<"" | "ma2" | "maidata" | "failed">(""); const apiResp = ref(null) @@ -35,30 +36,24 @@ export default defineComponent({ // 注:本功能的逻辑是,如果选择的是ma2文件,则只替换指定难度的谱面;如果选择的是maidata,则替换整首歌的所有难度。 prepareReplaceChart = async (fHandle?: FileSystemFileHandle) => { + let selectedFile: File; if (!fHandle) { - [fHandle] = await window.showOpenFilePicker({ - id: 'chart', - startIn: 'downloads', - types: [ - { - description: t('music.edit.supportedFileTypes'), - accept: { - "application/x-supported": [".ma2", ".txt"], // 没办法限定只匹配maidata.txt,就只好先把一切txt都作为匹配 - }, - }, - ], - }); + // 换谱面文件(maidata.txt 或 ma2),使用通用单文件选择(兼容 WebKitGTK) + // 没办法限定只匹配maidata.txt,就只好先把一切txt都作为匹配 + const picked = await pickFile('.ma2,.txt'); + if (!picked) return; // 用户未选择文件 + selectedFile = picked; + } else { + selectedFile = await fHandle.getFile(); } - if (!fHandle) return; // 用户未选择文件 - fileHandle.value = fHandle + file.value = selectedFile; - const name = fHandle.name; + const name = selectedFile.name; // 对maidata.txt和ma2分类讨论,前者执行ImportCheck if (name === "maidata.txt") { try { checking.value = true; - const file = await fHandle.getFile(); - const r = (await api.ImportChartCheck({file, isReplacement: true})).data; + const r = (await api.ImportChartCheck({file: selectedFile, isReplacement: true})).data; if (!checking.value) return; // 说明检查期间用户点击了关闭按钮、取消了操作。则不再执行后续流程。 apiResp.value = r; @@ -77,13 +72,13 @@ export default defineComponent({ } const replaceChart = async () => { - if (!fileHandle.value) return; + if (!file.value) return; try { - const file = await fileHandle.value.getFile(); - fileHandle.value = null; + const uploadFile = file.value; + file.value = null; const level = show.value === "maidata" ? -1 : selectedLevel.value; show.value = ""; - const result = (await api.ReplaceChart(selectMusicId.value, level, selectedADir.value, { file, shift: tempOption.value.shift })).data; + const result = (await api.ReplaceChart(selectMusicId.value, level, selectedADir.value, { file: uploadFile, shift: tempOption.value.shift })).data; if (!result.fatal) { addToast({ type:'success', message: t('music.edit.replaceChartSuccess') }) } else { @@ -116,7 +111,7 @@ export default defineComponent({ default: () =>
{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 74e55dc..e1b2e4f 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/pickFile.ts b/MaiChartManager/Front/src/utils/pickFile.ts new file mode 100644 index 0000000..c694515 --- /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/views/BatchAction/ChooseAction.tsx b/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx index 2bfc599..3104893 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 } 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'}); diff --git a/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx b/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx index 794a334..638a9f3 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 5762d9d..c8bf9a6 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 } 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,7 +28,7 @@ export default defineComponent({ const copy = async (type: CopyType) => { wait.value = true; - if (!isWebView || type === CopyType.exportMaidata || type === CopyType.exportMaidataIgnoreVideo) { + if (!isLocalHost || type === CopyType.exportMaidata || type === CopyType.exportMaidataIgnoreVideo) { // 浏览器模式,使用 zip.js 获取并解压 let folderHandle: FileSystemDirectoryHandle; try { diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx index 11a225d..527b597 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/SetMovieButton.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx index fcc9089..afbde10 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; diff --git a/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx b/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx index 77b4541..027d244 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 2a8ee4d..014c31e 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'), From 1032e665392866429a19cbddfd24644998511203 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 03:50:36 +0800 Subject: [PATCH 20/50] =?UTF-8?q?feat(linux):=20=E6=96=B0=E5=A2=9E=20Reque?= =?UTF-8?q?stExportMaidata=20=E2=80=94=E2=80=94=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E9=80=89=E7=9B=AE=E5=BD=95=20+=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=86=99=20maidata=EF=BC=8C=E6=94=AF=E6=8C=81=20WebKitGTK=20?= =?UTF-8?q?=E4=B8=8B=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Music/MusicTransferController.cs | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index 66b2cf3..d1e0212 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -23,6 +23,9 @@ public partial class MusicTransferController( { public record RequestCopyToRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool removeEvents, bool legacyFormat); + // 原生选目录导出 maidata 的请求体:music 为要导出的歌曲列表,ignoreVideo 控制是否跳过 PV 视频。 + public record RequestExportMaidataRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool ignoreVideo = false); + private static int[] GetAudioCandidateIds(MusicXmlWithABJacket music) { return [music.CueId, music.Id, music.NonDxId]; @@ -829,6 +832,273 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } } + // 把单首歌导出为 maidata 文件(maidata.txt + 封面 + 音频)写入 targetDir。 + // 该方法供原生选目录导出复用;与 zip 版 ExportAsMaidata 产物保持一致(Linux 下不导出音频)。 + private async Task WriteMaidataToDirectory(int id, string assetDir, string targetDir, bool ignoreVideo) + { + var music = settings.GetMusic(id, assetDir); + if (music is null) return; + var musicDir = Path.GetDirectoryName(music.FilePath); + if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) + { + var message = $"Invalid source directory for music {music.Id}: {music.FilePath}"; + logger.LogError("{message}", message); + throw new DirectoryNotFoundException(message); + } + + Directory.CreateDirectory(targetDir); + + var simaiFile = new Maidata(); + simaiFile.Title = music.Name; + simaiFile.Artist = music.Artist; + simaiFile.WholeBpm = music.Bpm; + simaiFile.First = 0; + simaiFile["shortid"] = music.Id.ToString(); + simaiFile["genreid"] = music.GenreId.ToString(); + var genre = StaticSettings.GenreList.FirstOrDefault(it => it.Id == music.GenreId); + if (genre is not null) simaiFile["genre"] = genre.GenreName; + simaiFile["versionid"] = music.AddVersionId.ToString(); + var version = StaticSettings.VersionList.FirstOrDefault(it => it.Id == music.AddVersionId); + if (version is not null) simaiFile["version"] = version.GenreName; + + // demo_seek(预览起止时间),依赖 CriUtils,仅 Windows 可用 +#if WINDOWS + try + { + if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var previewAcb, out _) && previewAcb is not null) + { + var previewTime = CriUtils.GetAudioPreviewTime(previewAcb); + if (previewTime.StartTime >= 0 && previewTime.EndTime > previewTime.StartTime) + { + simaiFile.Demo = ((float)previewTime.StartTime, (float)(previewTime.EndTime - previewTime.StartTime)); + } + } + } + catch (Exception e) + { + logger.LogWarning(e, "WriteMaidataToDirectory: 获取音频预览时间失败,已忽略。"); + } +#endif + + for (var i = 0; i < music.Charts.Length; i++) + { + var chart = music.Charts[i]; + if (chart is null || !chart.Enable || string.IsNullOrWhiteSpace(chart.Path)) continue; + + var chartPath = Path.Combine(musicDir, chart.Path); + if (!System.IO.File.Exists(chartPath)) + { + var fallbackPath = Path.Combine(musicDir, chart.Path.Replace(".ma2", "_L.ma2", StringComparison.OrdinalIgnoreCase)); + if (!System.IO.File.Exists(fallbackPath)) continue; + chartPath = fallbackPath; + } + + try + { + if (StaticSettings.Config.UseLegacyMaiLib) + { + simaiFile["ChartConvertTool"] = $"MaiLib"; + var parser = new MaiLib.Ma2Parser(); + var ma2Content = await System.IO.File.ReadAllLinesAsync(chartPath); + var ma2 = parser.ChartOfToken(ma2Content); + var simai = ma2.Compose(MaiLib.ChartEnum.ChartVersion.SimaiFes); + + var lvStr = $"{chart.Level}.{chart.LevelDecimal}"; + simaiFile.AddLevel(i + 2, new MaidataLevel(simai, lvStr, chart.Designer), false); + } + else + { + var ma2Content = await System.IO.File.ReadAllTextAsync(chartPath); + var (cvtChart, _) = new MA2Parser().Parse(ma2Content); + var (simai, _) = new SimaiGenerator().Generate(cvtChart); + + var lvStr = $"{chart.Level}.{chart.LevelDecimal}"; + simaiFile.AddLevel(i + 2, new MaidataLevel(simai, lvStr, chart.Designer)); + simaiFile.ClockCount = cvtChart.ClockCount; // 通过多次写入,自然实现取最后一个有效难度的clockCount,作为写入maidata中的 + } + } + catch (Exception e) + { + logger.LogError("WriteMaidataToDirectory FAILED! {title}, {filename}: {e}", music.Name, chartPath, e); + throw; + } + } + + var appVersion = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; + simaiFile["chartconverter"] = $"MaiChartManager v{appVersion}"; + + // 写 maidata.txt + await System.IO.File.WriteAllTextAsync(Path.Combine(targetDir, "maidata.txt"), simaiFile.ToString(), Encoding.UTF8); + + // 写封面 bg{ext} + var img = music.GetMusicJacketPngData(); + if (img is not null) + { + var imgExt = (Path.GetExtension(music.RealJacketPath) ?? ".png").ToLowerInvariant(); + if (imgExt == ".ab") imgExt = ".png"; + await System.IO.File.WriteAllBytesAsync(Path.Combine(targetDir, $"bg{imgExt}"), img); + } + + // 导出音频 track.mp3,依赖 AudioConvert/CriUtils,仅 Windows 可用 +#if WINDOWS + var tag = new ID3TagData + { + Title = music.Name, + Artist = music.Artist, + Album = genre?.GenreName, + Track = music.Id.ToString(), + Comment = version?.GenreName, + AlbumArt = img, + }; + + if (!AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var acbPath, out var awbPath) || acbPath is null || awbPath is null) + { + var message = BuildAudioResolveErrorMessage(music); + logger.LogError("{message}", message); + throw new FileNotFoundException(message); + } + var wav = Audio.AcbToWav(acbPath); + await using (var soundStream = System.IO.File.Create(Path.Combine(targetDir, "track.mp3"))) + { + AudioConvert.ConvertWavToMp3Stream(wav, soundStream, tag); + } +#else + logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 track.mp3。", music.Id); +#endif + + // 导出 PV 视频 pv.mp4(与 zip 版保持一致,未加 #if WINDOWS 限制) + if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) + { + DirectoryInfo? tmpDir = null; + try + { + string? pvMp4Path = null; + var ext = Path.GetExtension(movieUsmPath).ToLowerInvariant(); + + if (ext == ".dat" || ext == ".usm") + { + tmpDir = Directory.CreateTempSubdirectory(); + logger.LogInformation("Temp dir: {tmpDir}", tmpDir.FullName); + pvMp4Path = Path.Combine(tmpDir.FullName, "pv.mp4"); + + await VideoConvert.ConvertUsmToMp4(movieUsmPath, pvMp4Path); + } + else if (ext == ".mp4") + { + pvMp4Path = movieUsmPath; + } + + if (pvMp4Path is not null && System.IO.File.Exists(pvMp4Path)) + { + System.IO.File.Copy(pvMp4Path, Path.Combine(targetDir, "pv.mp4"), true); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "导出音乐 {musicId}({name})的 pv.mp4 失败,跳过视频。", music.Id, music.Name); + } + finally + { + if (tmpDir is not null) + { + try + { + tmpDir.Delete(true); + } + catch + { + // 忽略清理错误 + } + } + } + } + } + + // 把文件名中的非法字符替换为下划线,空结果回退到 fallback。 + private static string SanitizeFileNameSegment(string name, string fallback) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(name.Length); + foreach (var ch in name) + { + builder.Append(Array.IndexOf(invalidChars, ch) >= 0 ? '_' : ch); + } + + var result = builder.ToString().Trim(); + return string.IsNullOrEmpty(result) ? fallback : result; + } + + // 原生选目录导出 maidata:弹出系统目录选择对话框,对每首歌在子目录中写入 maidata。 + // 用于没有 File System Access API 的环境(Linux/Photino/WebKitGTK)。 + [HttpPost] + [Route("/MaiChartManagerServlet/[action]Api")] + public async Task RequestExportMaidata([FromBody] RequestExportMaidataRequest request) + { + var dest = dialogService.PickFolder(Locale.SelectTargetLocation); + if (dest is null) return; + logger.LogInformation("ExportMaidata: {dest}", dest); + + if (request.music.Length == 0) return; + + var showProgress = request.music.Length > 1; + using var progress = showProgress + ? progressController.Begin(Locale.Exporting, string.Format(Locale.ExportingMultipleMusic, request.music.Length), Locale.Cancelling) + : null; + progress?.Report(0, (ulong)request.music.Length); + + // 记录已使用的子目录名,避免不同歌曲互相覆盖 + var usedDirNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var done = 0; + var total = request.music.Length; + + foreach (var musicId in request.music) + { + if (progress?.IsCancelled == true) + { + logger.LogInformation("批量导出 maidata 被用户取消。"); + break; + } + + var music = settings.GetMusic(musicId.Id, musicId.AssetDir); + if (music is null) + { + logger.LogWarning("Skip export: music {id} in {assetDir} not found.", musicId.Id, musicId.AssetDir); + done++; + progress?.Report((ulong)done, (ulong)total); + continue; + } + + try + { + // 子目录命名:安全化歌名 + DX 后缀(与前端 remoteExport 保持一致);为空回退到 id。 + var suffix = music.Id is > 10000 and < 20000 ? " [DX]" : ""; + var baseName = SanitizeFileNameSegment(music.Name ?? "", music.Id.ToString()) + suffix; + + // 处理重名:追加 id,再不行追加序号 + var dirName = baseName; + if (!usedDirNames.Add(dirName)) + { + dirName = $"{baseName}_{music.Id}"; + var n = 1; + while (!usedDirNames.Add(dirName)) + { + dirName = $"{baseName}_{music.Id}_{n++}"; + } + } + + var targetDir = Path.Combine(dest, dirName); + await WriteMaidataToDirectory(musicId.Id, musicId.AssetDir, targetDir, request.ignoreVideo); + } + catch (Exception e) + { + logger.LogError(e, "导出音乐 {id}({name})的 maidata 失败,已跳过。", music.Id, music.Name); + } + + done++; + progress?.Report((ulong)done, (ulong)total, music.Name); + } + } + [GeneratedRegex(@"VERSION\t0.00.00\t1.(\d\d).00")] private static partial Regex MA2VersionRegex(); } \ No newline at end of file From c385e40e6c9d40f0007993572f5e489839ef6a64 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 03:56:47 +0800 Subject: [PATCH 21/50] =?UTF-8?q?feat(linux/web):=20maidata=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E8=B5=B0=E5=90=8E=E7=AB=AF=20RequestExportMaidata?= =?UTF-8?q?=E3=80=81=E5=AF=BC=E5=85=A5=E7=94=A8=20webkitdirectory=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=8C=E6=94=AF=E6=8C=81=20WebKit?= =?UTF-8?q?GTK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- MaiChartManager/Front/src/client/api.ts | 20 ++++ .../Front/src/utils/importDirectory.ts | 22 +++++ .../Front/src/utils/pickDirectory.ts | 60 ++++++++++++ MaiChartManager/Front/src/utils/tryGetFile.ts | 7 +- .../Front/src/utils/webkitDirectoryAdapter.ts | 93 +++++++++++++++++++ .../src/views/BatchAction/ChooseAction.tsx | 20 +++- .../src/views/Charts/CopyToButton/index.tsx | 25 +++-- .../ImportChartButton/index.tsx | 30 ++++-- 8 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 MaiChartManager/Front/src/utils/importDirectory.ts create mode 100644 MaiChartManager/Front/src/utils/pickDirectory.ts create mode 100644 MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts diff --git a/MaiChartManager/Front/src/client/api.ts b/MaiChartManager/Front/src/client/api.ts index 7f8ea7b..bafe981 100644 --- a/MaiChartManager/Front/src/client/api.ts +++ b/MaiChartManager/Front/src/client/api.ts @@ -38,3 +38,23 @@ export const getUrl = (suffix: string) => { // @ts-ignore return `${globalThis.backendUrl ?? ''}/MaiChartManagerServlet/${suffix}`; } + +// 本地宿主(Photino/WebKitGTK、WebView2)专用的 maidata 导出: +// 后端弹原生选目录对话框,并把每首歌的 maidata 写进所选目录(每首一个子目录)。 +// 这里手写 fetch 而不是用生成的 apiGen,是因为本环境无法连接后端重新生成 client。 +// 接口:POST /MaiChartManagerServlet/RequestExportMaidataApi +// body: { music: [{ id, assetDir }], ignoreVideo?: boolean } +export const requestExportMaidata = async ( + music: { id: number; assetDir: string }[], + ignoreVideo = false, +) => { + const res = await fetch(getUrl('RequestExportMaidataApi'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ music, ignoreVideo }), + }); + if (!res.ok) { + throw new Error(`RequestExportMaidata 失败: ${res.status} ${res.statusText}`); + } + return res; +} diff --git a/MaiChartManager/Front/src/utils/importDirectory.ts b/MaiChartManager/Front/src/utils/importDirectory.ts new file mode 100644 index 0000000..37847f3 --- /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 0000000..05be0e8 --- /dev/null +++ b/MaiChartManager/Front/src/utils/pickDirectory.ts @@ -0,0 +1,60 @@ +import { ImportDirectory } from "@/utils/importDirectory"; +import { buildDirectoryFromFileList } from "@/utils/webkitDirectoryAdapter"; + +// 通用选目录: +// - 若浏览器支持 window.showDirectoryPicker(Chromium / WebView2 / 远程 Chrome)→ 返回真实 handle, +// 行为与原先完全一致; +// - 否则(WebKitGTK / Photino)→ 用 选目录, +// 把扁平 FileList 交给适配器,返回实现 ImportDirectory 接口的「句柄」。 +// 用户取消时返回 null(与原先 showDirectoryPicker 抛 AbortError 的处理在 startProcess 里都被 catch)。 +export function pickDirectory( + options?: { id?: string; startIn?: string }, +): Promise { + // 真实 File System Access API + if (typeof window.showDirectoryPicker === 'function') { + // 真实 FileSystemDirectoryHandle 在结构上满足 ImportDirectory + return window.showDirectoryPicker(options as any) as unknown as Promise; + } + + // WebKitGTK 回退: + return new Promise((resolve, reject) => { + const input = document.createElement('input'); + input.type = 'file'; + // webkitdirectory 不是标准 TS 属性,这里用 setAttribute 兼容 + input.setAttribute('webkitdirectory', ''); + input.multiple = true; + input.style.display = 'none'; + document.body.appendChild(input); + + let settled = false; + const cleanup = () => input.remove(); + + input.addEventListener('change', () => { + settled = true; + const files = input.files; + if (!files || files.length === 0) { + cleanup(); + // 没选到任何文件,按取消处理:抛 AbortError,与 showDirectoryPicker 取消语义一致 + const err = new Error('用户取消选择目录'); + err.name = 'AbortError'; + reject(err); + return; + } + const dir = buildDirectoryFromFileList(files); + cleanup(); + resolve(dir); + }); + + // 取消时多数内核不触发 change;用 window focus 兜底判定取消 + window.addEventListener('focus', () => setTimeout(() => { + if (!settled) { + cleanup(); + const err = new Error('用户取消选择目录'); + err.name = 'AbortError'; + reject(err); + } + }, 500), { once: true }); + + input.click(); + }); +} diff --git a/MaiChartManager/Front/src/utils/tryGetFile.ts b/MaiChartManager/Front/src/utils/tryGetFile.ts index 4d35c53..cf64d34 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 0000000..e6d393b --- /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 3104893..c4f2efb 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, { isLocalHost } 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"; @@ -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/CopyToButton/index.tsx b/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx index c8bf9a6..8b4550b 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, isLocalHost } 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 (!isLocalHost || 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 71f85b7..57a7d16 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); From 3f72f50f1ddc75a89c5619a8d396b8d40b5bc9cf Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 04:09:32 +0800 Subject: [PATCH 22/50] =?UTF-8?q?fix(linux):=20=E9=85=8D=E7=BD=AE=20Xabe.F?= =?UTF-8?q?Fmpeg=20=E4=BD=BF=E7=94=A8=E7=B3=BB=E7=BB=9F=20ffmpeg=EF=BC=88P?= =?UTF-8?q?ATH=20=E8=A7=A3=E6=9E=90=20+=20=E6=98=BE=E5=BC=8F=E5=8F=AF?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=90=8D=EF=BC=89=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=97=B6=E6=89=BE=20ffmpeg.exe=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=EF=BC=9B=E5=B9=B6=E5=9C=A8=20Linux=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E6=A3=80=E6=B5=8B=E7=A1=AC=E4=BB=B6=E5=8A=A0?= =?UTF-8?q?=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (本提交同时包含此前未提交的窗口尺寸 1600x800 改动) --- MaiChartManager/LinuxProgram.cs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs index 40b5975..a10bcb6 100644 --- a/MaiChartManager/LinuxProgram.cs +++ b/MaiChartManager/LinuxProgram.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text.Json; using Photino.NET; +using Xabe.FFmpeg; namespace MaiChartManager; @@ -12,6 +13,7 @@ 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 开窗。 @@ -45,7 +47,7 @@ public static void Main(string[] args) var window = new PhotinoWindow() .SetTitle("MaiChartManager") .SetUseOsDefaultSize(false) - .SetSize(1280, 800) + .SetSize(1600, 800) .Center() .Load(new Uri(startUrl)); @@ -55,6 +57,32 @@ public static void Main(string[] args) window.WaitForClose(); } + /// + /// 配置 Xabe.FFmpeg 使用系统 ffmpeg/ffprobe。 + /// Windows 版在 AppMain 里指向内置的 ffmpeg.exe;Linux 不内置,改用系统 PATH 里的 ffmpeg。 + /// 必须显式传可执行名 "ffmpeg"/"ffprobe",否则 Xabe 在 Linux 上仍会去找 .exe 后缀的文件。 + /// + private static void ConfigureFfmpeg() + { + var dir = ResolveExecutableDir("ffmpeg") ?? "/usr/bin"; + FFmpeg.SetExecutablesPath(dir, "ffmpeg", "ffprobe"); + // 检测硬件加速(与 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 中)。 From 26530b14ee472fb6ed366be1d271d0e87e243f13 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 04:18:32 +0800 Subject: [PATCH 23/50] =?UTF-8?q?feat(linux):=20maidata=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=94=B9=E7=94=A8=E5=90=8E=E7=AB=AF=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E9=80=89=E7=9B=AE=E5=BD=95=20+=20HTTP=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=86=85=E5=AE=B9=EF=BC=88WebKitGTK=20?= =?UTF-8?q?=E6=97=A0=20showDirectoryPicker/webkitdirectory=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../AssetDir/ImportBrowseController.cs | 75 +++++++++++++++++++ .../Front/src/utils/httpImportDirectory.ts | 67 +++++++++++++++++ .../Front/src/utils/pickDirectory.ts | 73 +++++++----------- 3 files changed, 169 insertions(+), 46 deletions(-) create mode 100644 MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs create mode 100644 MaiChartManager/Front/src/utils/httpImportDirectory.ts diff --git a/MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs b/MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs new file mode 100644 index 0000000..c547a22 --- /dev/null +++ b/MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs @@ -0,0 +1,75 @@ +using MaiChartManager.Platform; +using Microsoft.AspNetCore.Mvc; + +namespace MaiChartManager.Controllers.AssetDir; + +// maidata 导入用的后端目录浏览接口。 +// 背景:WebKitGTK(Linux/Photino)既没有 showDirectoryPicker, 也只能选单文件, +// 所以前端没法在浏览器侧拿到目录内容。改为:后端弹原生选文件夹对话框,再通过下面 3 个接口 +// 把所选目录的内容提供给前端的 ImportDirectory 适配器(见 Front/src/utils/httpImportDirectory.ts)。 +// +// 安全说明:这些接口可以读取任意本地路径,仅限「本地桌面 + 仅 loopback」场景使用。 +// 切勿在 export / 远程模式下启用——否则等于把整个文件系统暴露给局域网。 +// 因此每个接口都先校验 !StaticSettings.Config.Export。 +[ApiController] +[Route("MaiChartManagerServlet/[action]Api")] +public class ImportBrowseController(IDesktopDialogService dialogService, ILogger logger) : ControllerBase +{ + // 子项列表的返回结构:name 显示名,path 子项绝对路径,isDirectory 是否为目录 + public record ImportDirEntry(string Name, string Path, bool IsDirectory); + + // 弹原生选文件夹对话框,返回选中的绝对路径;取消返回 null + [HttpGet] + public ActionResult PickImportFolder() + { + // 仅本地桌面场景,export / 远程模式下禁止 + if (StaticSettings.Config.Export) return Forbid(); + var path = dialogService.PickFolder(); + logger.LogInformation("PickImportFolder: {path}", path); + // 取消时 PickFolder 返回 null,这里原样返回(前端按取消处理) + return Ok(path); + } + + // 列出目录下的直接子项(不递归)。path 不存在或不是目录时返回空数组。 + [HttpGet] + public ActionResult> ListImportDir([FromQuery] string path) + { + // 仅本地桌面场景,export / 远程模式下禁止 + if (StaticSettings.Config.Export) return Forbid(); + if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) + { + return Ok(Array.Empty()); + } + + var result = new List(); + // 子目录 + foreach (var dir in Directory.EnumerateDirectories(path)) + { + result.Add(new ImportDirEntry(Path.GetFileName(dir), dir, true)); + } + // 子文件 + foreach (var file in Directory.EnumerateFiles(path)) + { + result.Add(new ImportDirEntry(Path.GetFileName(file), file, false)); + } + return Ok(result); + } + + // 读取文件内容。 + // 支持两种调用方式(解决跨平台路径分隔符问题): + // ReadImportFileApi?path=<文件完整路径> → 直接读 path + // ReadImportFileApi?path=<目录>&name=<文件名> → 后端 Path.Combine(path, name) 后再读 + // 这样前端 getFileHandle 不用自己拼路径。文件不存在返回 404。 + [HttpGet] + public IActionResult ReadImportFile([FromQuery] string path, [FromQuery] string? name = null) + { + // 仅本地桌面场景,export / 远程模式下禁止 + if (StaticSettings.Config.Export) return Forbid(); + var fullPath = string.IsNullOrEmpty(name) ? path : Path.Combine(path, name); + if (string.IsNullOrEmpty(fullPath) || !System.IO.File.Exists(fullPath)) + { + return NotFound(); + } + return PhysicalFile(Path.GetFullPath(fullPath), "application/octet-stream"); + } +} diff --git a/MaiChartManager/Front/src/utils/httpImportDirectory.ts b/MaiChartManager/Front/src/utils/httpImportDirectory.ts new file mode 100644 index 0000000..9d6344d --- /dev/null +++ b/MaiChartManager/Front/src/utils/httpImportDirectory.ts @@ -0,0 +1,67 @@ +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); + } + 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 res = await fetch(getUrl('ListImportDirApi') + '?path=' + encodeURIComponent(absPath)); + 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/pickDirectory.ts b/MaiChartManager/Front/src/utils/pickDirectory.ts index 05be0e8..39b3b19 100644 --- a/MaiChartManager/Front/src/utils/pickDirectory.ts +++ b/MaiChartManager/Front/src/utils/pickDirectory.ts @@ -1,13 +1,23 @@ import { ImportDirectory } from "@/utils/importDirectory"; -import { buildDirectoryFromFileList } from "@/utils/webkitDirectoryAdapter"; +import { httpImportDirectory } from "@/utils/httpImportDirectory"; +import { getUrl, isLocalHost } from "@/client/api"; + +// 抛一个 AbortError,与 showDirectoryPicker 取消时的语义一致(startProcess 里会 catch 掉) +function abort(message = '用户取消选择目录'): never { + const err = new Error(message); + err.name = 'AbortError'; + throw err; +} // 通用选目录: -// - 若浏览器支持 window.showDirectoryPicker(Chromium / WebView2 / 远程 Chrome)→ 返回真实 handle, +// - 浏览器支持 window.showDirectoryPicker(Chromium / WebView2 / 远程 Chrome)→ 返回真实 handle, // 行为与原先完全一致; -// - 否则(WebKitGTK / Photino)→ 用 选目录, -// 把扁平 FileList 交给适配器,返回实现 ImportDirectory 接口的「句柄」。 -// 用户取消时返回 null(与原先 showDirectoryPicker 抛 AbortError 的处理在 startProcess 里都被 catch)。 -export function pickDirectory( +// - 否则是本地桌面宿主(isLocalHost,含 Photino/WebKitGTK 的 loopback 与 WebView2 的 mcm.invalid)→ +// 走后端:弹原生选文件夹对话框,再用 httpImportDirectory 适配器通过 HTTP 提供目录内容。 +// (WebKitGTK 没有 showDirectoryPicker, 又只能选单文件,所以一律走后端。) +// - 其余情况(远程浏览器且不支持 File System Access API)→ 不支持,按取消处理。 +// 用户取消时抛 AbortError。 +export async function pickDirectory( options?: { id?: string; startIn?: string }, ): Promise { // 真实 File System Access API @@ -16,45 +26,16 @@ export function pickDirectory( return window.showDirectoryPicker(options as any) as unknown as Promise; } - // WebKitGTK 回退: - return new Promise((resolve, reject) => { - const input = document.createElement('input'); - input.type = 'file'; - // webkitdirectory 不是标准 TS 属性,这里用 setAttribute 兼容 - input.setAttribute('webkitdirectory', ''); - input.multiple = true; - input.style.display = 'none'; - document.body.appendChild(input); - - let settled = false; - const cleanup = () => input.remove(); - - input.addEventListener('change', () => { - settled = true; - const files = input.files; - if (!files || files.length === 0) { - cleanup(); - // 没选到任何文件,按取消处理:抛 AbortError,与 showDirectoryPicker 取消语义一致 - const err = new Error('用户取消选择目录'); - err.name = 'AbortError'; - reject(err); - return; - } - const dir = buildDirectoryFromFileList(files); - cleanup(); - resolve(dir); - }); - - // 取消时多数内核不触发 change;用 window focus 兜底判定取消 - window.addEventListener('focus', () => setTimeout(() => { - if (!settled) { - cleanup(); - const err = new Error('用户取消选择目录'); - err.name = 'AbortError'; - reject(err); - } - }, 500), { once: true }); + // 本地桌面宿主:后端原生选目录 + HTTP 提供目录内容 + if (isLocalHost) { + const res = await fetch(getUrl('PickImportFolderApi')); + if (!res.ok) abort('选择目录失败'); + // 后端返回 JSON:选中的绝对路径字符串,取消时为 null + const path: string | null = await res.json(); + if (!path) abort(); + return httpImportDirectory(path); + } - input.click(); - }); + // 远程浏览器且无 File System Access API:不支持,按取消处理 + abort('当前环境不支持选择目录'); } From 3969f592006a4d4ef5d1e058f474c59ffe8e37e4 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 04:30:57 +0800 Subject: [PATCH 24/50] =?UTF-8?q?feat(linux):=20=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E8=B0=B1=E9=9D=A2=E7=94=A8=20Photino=20=E5=86=85=E7=BD=AE=20we?= =?UTF-8?q?bview=20=E5=AD=90=E7=AA=97=E5=8F=A3=EF=BC=88=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=20sendMessage=E2=86=92=E5=AE=BF=E4=B8=BB=E5=BC=80=E5=AD=90?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LinuxProgram 注册 WebMessage handler,收到 open-window 消息时开一个 PhotinoWindow 子窗口加载预览页 - 前端 PreviewChartButton 在 Photino(isPhotino) 下走 window.external.sendMessage 而非 window.open - 附带新增 ShellController.OpenExternalUrl(系统浏览器打开 http/https,备外部链接用) --- .../Controllers/App/ShellController.cs | 38 +++++++++++++++++++ MaiChartManager/Front/src/client/api.ts | 17 +++++++++ .../Charts/MusicEdit/PreviewChartButton.tsx | 14 +++++++ MaiChartManager/LinuxProgram.cs | 38 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 MaiChartManager/Controllers/App/ShellController.cs diff --git a/MaiChartManager/Controllers/App/ShellController.cs b/MaiChartManager/Controllers/App/ShellController.cs new file mode 100644 index 0000000..6645b5f --- /dev/null +++ b/MaiChartManager/Controllers/App/ShellController.cs @@ -0,0 +1,38 @@ +using MaiChartManager.Platform; +using Microsoft.AspNetCore.Mvc; + +namespace MaiChartManager.Controllers.App; + +/// +/// 外壳相关接口(用系统能力打开 URL 等)。 +/// 主要给 Linux/Photino 用:WebKitGTK 不支持 window.open 弹新窗口, +/// 预览谱面等"开新窗口"的场景改为用系统浏览器(xdg-open)打开 loopback 上的对应页面。 +/// +[ApiController] +[Route("MaiChartManagerServlet/[action]Api")] +public class ShellController(IShellService shellService, ILogger logger) : ControllerBase +{ + public record OpenExternalUrlRequest(string Url); + + [HttpPost] + public IActionResult OpenExternalUrl([FromBody] OpenExternalUrlRequest request) + { + // 只允许 http/https,避免被诱导打开任意协议 + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return BadRequest("Only http/https URLs are allowed"); + } + + try + { + shellService.OpenUrl(request.Url); + return Ok(); + } + catch (Exception e) + { + logger.LogError(e, "打开外部 URL 失败:{Url}", request.Url); + return StatusCode(500, e.Message); + } + } +} diff --git a/MaiChartManager/Front/src/client/api.ts b/MaiChartManager/Front/src/client/api.ts index bafe981..b6528ed 100644 --- a/MaiChartManager/Front/src/client/api.ts +++ b/MaiChartManager/Front/src/client/api.ts @@ -39,6 +39,23 @@ export const getUrl = (suffix: string) => { return `${globalThis.backendUrl ?? ''}/MaiChartManagerServlet/${suffix}`; } +// 是否运行在 Photino(WebKitGTK) 宿主:本地宿主但不是 Windows WebView2。 +// WebKitGTK 不支持 window.open 弹新窗口,需要走后端用系统浏览器打开。 +export const isPhotino = isLocalHost && !isWebView; + +// 用系统浏览器打开一个 http/https URL(后端 xdg-open 等)。 +// 给 Photino 用:WebKitGTK 弹不出 window.open 的新窗口,预览谱面等改为外部浏览器打开。 +export const openExternalUrl = async (url: string) => { + const res = await fetch(getUrl('OpenExternalUrlApi'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + if (!res.ok) { + throw new Error(`OpenExternalUrl 失败: ${res.status} ${res.statusText}`); + } +} + // 本地宿主(Photino/WebKitGTK、WebView2)专用的 maidata 导出: // 后端弹原生选目录对话框,并把每首歌的 maidata 写进所选目录(每首一个子目录)。 // 这里手写 fetch 而不是用生成的 apiGen,是因为本环境无法连接后端重新生成 client。 diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx index c2123c4..2dc520f 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/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs index a10bcb6..6e2943d 100644 --- a/MaiChartManager/LinuxProgram.cs +++ b/MaiChartManager/LinuxProgram.cs @@ -54,9 +54,47 @@ public static void Main(string[] args) // 把窗口实例交给平台服务持有者,供 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}"); + } + } + /// /// 配置 Xabe.FFmpeg 使用系统 ffmpeg/ffprobe。 /// Windows 版在 AppMain 里指向内置的 ffmpeg.exe;Linux 不内置,改用系统 PATH 里的 ffmpeg。 From b343aaa651882d1aaf5d016eced597fa977f1b60 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 07:49:22 +0800 Subject: [PATCH 25/50] =?UTF-8?q?fix(web):=20getUrl=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=BB=9D=E5=AF=B9=E5=9C=B0=E5=9D=80=EF=BC=88location.origin=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=EF=BC=89=EF=BC=8C=E9=81=BF=E5=85=8D=E7=9B=B8?= =?UTF-8?q?=E5=AF=B9=E5=9C=B0=E5=9D=80=E5=9C=A8=20WebKitGTK=20=E4=B8=8B?= =?UTF-8?q?=E8=A2=AB=20new=20URL=20=E8=A7=A3=E6=9E=90=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/Front/src/client/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MaiChartManager/Front/src/client/api.ts b/MaiChartManager/Front/src/client/api.ts index b6528ed..2655781 100644 --- a/MaiChartManager/Front/src/client/api.ts +++ b/MaiChartManager/Front/src/client/api.ts @@ -35,8 +35,12 @@ export const aquaMaiVersionConfig = new AquaMaiVersionConfigApi({ }).api export const getUrl = (suffix: string) => { + // 必须返回绝对地址:部分代码(如 fetchEventSource)内部会 new URL(getUrl(...)), + // 相对地址在 WebKitGTK 上会抛 "The string did not match the expected pattern"。 + // WebView2 下 backendUrl 已注入为绝对地址;Photino/远程浏览器/export 下回退到当前 origin(同源)。 // @ts-ignore - return `${globalThis.backendUrl ?? ''}/MaiChartManagerServlet/${suffix}`; + const base = (globalThis.backendUrl as string | undefined) ?? location.origin; + return `${base}/MaiChartManagerServlet/${suffix}`; } // 是否运行在 Photino(WebKitGTK) 宿主:本地宿主但不是 Windows WebView2。 From 3c594afb9637700590d6dd04d5e6bbce17d33e9b Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 08:04:32 +0800 Subject: [PATCH 26/50] =?UTF-8?q?fix(linux):=20=E5=AF=BC=E5=85=A5=E9=80=89?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=9C=A8=20Photino=20=E4=B8=8B=E4=BC=98?= =?UTF-8?q?=E5=85=88=E8=B5=B0=E5=90=8E=E7=AB=AF=EF=BC=8C=E7=BB=95=E5=BC=80?= =?UTF-8?q?=20WebKitGTK=20=E5=9D=8F=E6=8E=89=E7=9A=84=20showDirectoryPicke?= =?UTF-8?q?r=EF=BC=88native=20=E6=8A=9B=E6=97=A0=E6=A0=88=20SyntaxError?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Front/src/utils/pickDirectory.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/MaiChartManager/Front/src/utils/pickDirectory.ts b/MaiChartManager/Front/src/utils/pickDirectory.ts index 39b3b19..0b4bb80 100644 --- a/MaiChartManager/Front/src/utils/pickDirectory.ts +++ b/MaiChartManager/Front/src/utils/pickDirectory.ts @@ -1,6 +1,6 @@ import { ImportDirectory } from "@/utils/importDirectory"; import { httpImportDirectory } from "@/utils/httpImportDirectory"; -import { getUrl, isLocalHost } from "@/client/api"; +import { getUrl, isLocalHost, isPhotino } from "@/client/api"; // 抛一个 AbortError,与 showDirectoryPicker 取消时的语义一致(startProcess 里会 catch 掉) function abort(message = '用户取消选择目录'): never { @@ -9,31 +9,40 @@ function abort(message = '用户取消选择目录'): never { throw err; } +// 走后端:弹原生选文件夹对话框,再用 httpImportDirectory 适配器通过 HTTP 提供目录内容。 +async function pickViaBackend(): Promise { + const res = await fetch(getUrl('PickImportFolderApi')); + if (!res.ok) abort('选择目录失败'); + // 后端返回 JSON:选中的绝对路径字符串,取消时为 null + const path: string | null = await res.json(); + if (!path) abort(); + return httpImportDirectory(path); +} + // 通用选目录: -// - 浏览器支持 window.showDirectoryPicker(Chromium / WebView2 / 远程 Chrome)→ 返回真实 handle, -// 行为与原先完全一致; -// - 否则是本地桌面宿主(isLocalHost,含 Photino/WebKitGTK 的 loopback 与 WebView2 的 mcm.invalid)→ -// 走后端:弹原生选文件夹对话框,再用 httpImportDirectory 适配器通过 HTTP 提供目录内容。 -// (WebKitGTK 没有 showDirectoryPicker, 又只能选单文件,所以一律走后端。) -// - 其余情况(远程浏览器且不支持 File System Access API)→ 不支持,按取消处理。 -// 用户取消时抛 AbortError。 +// - 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 { - // 真实 File System Access API + // 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; } - // 本地桌面宿主:后端原生选目录 + HTTP 提供目录内容 + // 其它本地桌面宿主(无 showDirectoryPicker):后端原生选目录 if (isLocalHost) { - const res = await fetch(getUrl('PickImportFolderApi')); - if (!res.ok) abort('选择目录失败'); - // 后端返回 JSON:选中的绝对路径字符串,取消时为 null - const path: string | null = await res.json(); - if (!path) abort(); - return httpImportDirectory(path); + return pickViaBackend(); } // 远程浏览器且无 File System Access API:不支持,按取消处理 From b9d872dc43f1eab67d61108ae3c88bc354ba7797 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 08:11:36 +0800 Subject: [PATCH 27/50] =?UTF-8?q?debug(import):=20=E6=89=93=E5=8D=B0=20fet?= =?UTF-8?q?ch=20URL=20=E4=B8=8E=E5=A4=B1=E8=B4=A5=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E7=9A=84=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=97=A5=E5=BF=97=EF=BC=88?= =?UTF-8?q?WebKitGTK=20console=20=E6=97=A0=E6=B3=95=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=AF=B9=E8=B1=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/Front/src/utils/httpImportDirectory.ts | 5 ++++- .../ImportCreateChartButton/ImportChartButton/index.tsx | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/MaiChartManager/Front/src/utils/httpImportDirectory.ts b/MaiChartManager/Front/src/utils/httpImportDirectory.ts index 9d6344d..3c10762 100644 --- a/MaiChartManager/Front/src/utils/httpImportDirectory.ts +++ b/MaiChartManager/Front/src/utils/httpImportDirectory.ts @@ -22,6 +22,7 @@ async function readFile(path: string, displayName: string, name?: string): Promi 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); @@ -47,7 +48,9 @@ export function httpImportDirectory(absPath: string, name?: string): ImportDirec // 迭代目录直接子项:目录递归构造适配器,文件构造文件句柄 async *values(): AsyncIterableIterator { - const res = await fetch(getUrl('ListImportDirApi') + '?path=' + encodeURIComponent(absPath)); + 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) { diff --git a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx index 57a7d16..1b62d2a 100644 --- a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx @@ -280,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) From 2abe59aacb6633ceabe40ecf6d005c650af054bc Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 08:13:28 +0800 Subject: [PATCH 28/50] =?UTF-8?q?feat:=20=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=AF=B9=E8=AF=9D=E6=A1=86=E8=AE=B0=E4=BD=8F?= =?UTF-8?q?=E4=B8=8A=E6=AC=A1=E7=9B=AE=E5=BD=95=EF=BC=88Config.LastDialogF?= =?UTF-8?q?older=EF=BC=8CPhotino=20+=20WinForms=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/Config.cs | 3 +++ .../Platform/Linux/PhotinoDialogService.cs | 13 +++++++++++-- .../Platform/Windows/WinFormsDialogService.cs | 9 ++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/MaiChartManager/Config.cs b/MaiChartManager/Config.cs index b3d1a09..f4aee2b 100644 --- a/MaiChartManager/Config.cs +++ b/MaiChartManager/Config.cs @@ -30,6 +30,9 @@ public class Config public bool ConvertJacketToAssetBundle { get; set; } = true; public int UiZoom { get; set; } = 0; + // 记住上次文件夹选择对话框选中的目录,下次打开时从这里开始(而不是每次都回到 Documents)。 + public string? LastDialogFolder { get; set; } = null; + public void Save() { var json = JsonSerializer.Serialize(this); diff --git a/MaiChartManager/Platform/Linux/PhotinoDialogService.cs b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs index 00be144..89a59a2 100644 --- a/MaiChartManager/Platform/Linux/PhotinoDialogService.cs +++ b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs @@ -28,13 +28,15 @@ public class PhotinoDialogService(ILogger logger) : IDeskt 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 ?? "", null, false); + result = window.ShowOpenFolder(title ?? "", startDir, false); } catch (Exception e) { @@ -46,7 +48,14 @@ public class PhotinoDialogService(ILogger logger) : IDeskt } }); done.Wait(); - return result is { Length: > 0 } ? result[0] : null; + 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) diff --git a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs index 7fe5e57..38e0267 100644 --- a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs +++ b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs @@ -17,7 +17,14 @@ public class WinFormsDialogService : IDesktopDialogService ShowNewFolderButton = false, }; if (title is not null) dialog.Description = title; - return WinUtils.ShowDialog(dialog) == DialogResult.OK ? dialog.SelectedPath : null; + // 上次选过的目录作为初始目录(没有则用系统默认) + if (StaticSettings.Config.LastDialogFolder is { Length: > 0 } last && Directory.Exists(last)) + dialog.SelectedPath = last; + if (WinUtils.ShowDialog(dialog) != DialogResult.OK) return null; + // 记住本次目录 + StaticSettings.Config.LastDialogFolder = dialog.SelectedPath; + try { StaticSettings.Config.Save(); } catch { /* 保存失败忽略 */ } + return dialog.SelectedPath; } public string? PickFile(string? title = null, string? filter = null) From fb33bd75f9358405240ea26a57fe6bc0a7083d68 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 11:36:45 +0800 Subject: [PATCH 29/50] =?UTF-8?q?debug(import):=20pickViaBackend=20?= =?UTF-8?q?=E7=BB=86=E7=B2=92=E5=BA=A6=E6=97=A5=E5=BF=97=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=20SyntaxError=EF=BC=9B=E6=92=A4=E9=94=80=20WinForms=20?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E7=9B=AE=E5=BD=95=EF=BC=88Windows=20?= =?UTF-8?q?=E6=9C=AC=E5=B0=B1=E8=AE=B0=E4=BD=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/Front/src/utils/pickDirectory.ts | 10 ++++++++-- .../Platform/Windows/WinFormsDialogService.cs | 10 ++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/MaiChartManager/Front/src/utils/pickDirectory.ts b/MaiChartManager/Front/src/utils/pickDirectory.ts index 0b4bb80..bc891ff 100644 --- a/MaiChartManager/Front/src/utils/pickDirectory.ts +++ b/MaiChartManager/Front/src/utils/pickDirectory.ts @@ -11,10 +11,16 @@ function abort(message = '用户取消选择目录'): never { // 走后端:弹原生选文件夹对话框,再用 httpImportDirectory 适配器通过 HTTP 提供目录内容。 async function pickViaBackend(): Promise { - const res = await fetch(getUrl('PickImportFolderApi')); + const pickUrl = getUrl('PickImportFolderApi'); + console.log('[imp] pickViaBackend origin=' + location.origin + ' href=' + location.href + ' pickUrl=' + pickUrl); + const res = await fetch(pickUrl); + console.log('[imp] PickImportFolder responded ok=' + res.ok + ' status=' + res.status + ' ct=' + res.headers.get('content-type')); if (!res.ok) abort('选择目录失败'); // 后端返回 JSON:选中的绝对路径字符串,取消时为 null - const path: string | null = await res.json(); + const text = await res.text(); + console.log('[imp] PickImportFolder raw body=' + JSON.stringify(text)); + const path: string | null = text ? JSON.parse(text) : null; + console.log('[imp] picked path=' + JSON.stringify(path)); if (!path) abort(); return httpImportDirectory(path); } diff --git a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs index 38e0267..82a628c 100644 --- a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs +++ b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs @@ -17,14 +17,8 @@ public class WinFormsDialogService : IDesktopDialogService ShowNewFolderButton = false, }; if (title is not null) dialog.Description = title; - // 上次选过的目录作为初始目录(没有则用系统默认) - if (StaticSettings.Config.LastDialogFolder is { Length: > 0 } last && Directory.Exists(last)) - dialog.SelectedPath = last; - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) return null; - // 记住本次目录 - StaticSettings.Config.LastDialogFolder = dialog.SelectedPath; - try { StaticSettings.Config.Save(); } catch { /* 保存失败忽略 */ } - return dialog.SelectedPath; + // Windows 的 Vista 风格文件夹对话框本身会记住上次目录,无需额外处理 + return WinUtils.ShowDialog(dialog) == DialogResult.OK ? dialog.SelectedPath : null; } public string? PickFile(string? title = null, string? filter = null) From 2a32f30f151cd386376234d6b18c360b5bc1679b Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 11:40:36 +0800 Subject: [PATCH 30/50] =?UTF-8?q?fix(import):=20PickImportFolder=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=9A=84=E6=98=AF=20text/plain=20=E8=A3=B8?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=EF=BC=8C=E5=89=8D=E7=AB=AF=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20res.text()=20=E8=80=8C=E9=9D=9E=20res.json()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/Front/src/utils/pickDirectory.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/MaiChartManager/Front/src/utils/pickDirectory.ts b/MaiChartManager/Front/src/utils/pickDirectory.ts index bc891ff..b267c56 100644 --- a/MaiChartManager/Front/src/utils/pickDirectory.ts +++ b/MaiChartManager/Front/src/utils/pickDirectory.ts @@ -11,16 +11,11 @@ function abort(message = '用户取消选择目录'): never { // 走后端:弹原生选文件夹对话框,再用 httpImportDirectory 适配器通过 HTTP 提供目录内容。 async function pickViaBackend(): Promise { - const pickUrl = getUrl('PickImportFolderApi'); - console.log('[imp] pickViaBackend origin=' + location.origin + ' href=' + location.href + ' pickUrl=' + pickUrl); - const res = await fetch(pickUrl); - console.log('[imp] PickImportFolder responded ok=' + res.ok + ' status=' + res.status + ' ct=' + res.headers.get('content-type')); + const res = await fetch(getUrl('PickImportFolderApi')); if (!res.ok) abort('选择目录失败'); - // 后端返回 JSON:选中的绝对路径字符串,取消时为 null - const text = await res.text(); - console.log('[imp] PickImportFolder raw body=' + JSON.stringify(text)); - const path: string | null = text ? JSON.parse(text) : null; - console.log('[imp] picked path=' + JSON.stringify(path)); + // 后端 Ok(string) 走 ASP.NET 的 string 特例,以 text/plain 返回裸路径(不是 JSON),取消时为空。 + // 所以必须用 res.text() 而不是 res.json()。 + const path = (await res.text()) || null; if (!path) abort(); return httpImportDirectory(path); } From 4d250b861e57304e8a1977762c9d3d7e12a0fed5 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 12:51:34 +0800 Subject: [PATCH 31/50] =?UTF-8?q?feat(linux):=20=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E7=AE=A1=E9=81=93=E6=8E=A5=E5=85=A5=20Linux=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=BC=95=20AcbCore=20+=20SonicAudioLib(netstandard?= =?UTF-8?q?2.0)=EF=BC=8CMediaFoundation=20=E6=8D=A2=20ffmpeg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XV2-Tools submodule 切到 MuNET-OSS fork(含跨平台 AcbCore:ACB+VGAudio) - SonicAudioTools submodule 用 netstandard2.0 的 SonicAudioLib - 主项目两平台统一引 AcbCore + SonicAudioLib,弃用 net47 的 LB_Common/Xv2CoreLib - Audio.cs 的 MediaFoundation 解码改用系统 ffmpeg - 取消 Audio/CriUtils/AudioConvert + ImportChart/CueConvert/VrcProcess/AudioConvertTool 等的 Linux 排除,恢复音频 #if WINDOWS 守卫 - AudioConvertTool 改用 IDesktopDialogService.PickFile(跨平台) Co-Authored-By: Claude Opus 4.8 --- .gitmodules | 2 +- .../Controllers/Music/CueConvertController.cs | 2 + .../Music/MusicTransferController.cs | 26 ++------ .../Tools/AudioConvertToolController.cs | 16 ++--- MaiChartManager/MaiChartManager.csproj | 15 ++--- MaiChartManager/Utils/Audio.cs | 65 ++++++++++++++++--- SonicAudioTools | 2 +- XV2-Tools | 2 +- 8 files changed, 77 insertions(+), 53 deletions(-) diff --git a/.gitmodules b/.gitmodules index 2e0a610..4c23ec6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,7 +9,7 @@ url = git@github.com:clansty/SonicAudioTools.git [submodule "XV2-Tools"] path = XV2-Tools - url = git@github.com:clansty/XV2-Tools.git + url = git@github.com:MuNET-OSS/XV2-Tools.git [submodule "AquaMai"] path = AquaMai url = git@github.com:MewoLab/AquaMai.git diff --git a/MaiChartManager/Controllers/Music/CueConvertController.cs b/MaiChartManager/Controllers/Music/CueConvertController.cs index 364f4ce..ac971aa 100644 --- a/MaiChartManager/Controllers/Music/CueConvertController.cs +++ b/MaiChartManager/Controllers/Music/CueConvertController.cs @@ -53,7 +53,9 @@ public record SetAudioPreviewRequest(double StartTime, double EndTime); [HttpPost] public async Task SetAudioPreview(int id, [FromBody] SetAudioPreviewRequest request, string assetDir) { +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) return; +#endif id %= 10000; var cachePath = await AudioConvert.GetCachedWavPath(id); var targetAcbPath = StaticSettings.AcbAwb[$"music{id:000000}.acb"]; diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index d1e0212..6372b90 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -259,7 +259,6 @@ private void CopyMusicToDirectory( } // 复制 ACB/AWB 音频 -#if WINDOWS if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) && acb is not null && awb is not null) @@ -271,9 +270,6 @@ private void CopyMusicToDirectory( { logger.LogWarning("{message}", BuildAudioResolveErrorMessage(music)); } -#else - logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 ACB/AWB。", music.Id); -#endif // 复制视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) @@ -459,7 +455,6 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } // 复制 ACB/AWB 音频 -#if WINDOWS if (!AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) || acb is null || awb is null) { var message = BuildAudioResolveErrorMessage(music); @@ -468,9 +463,6 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } zipArchive.CreateEntryFromFile(acb, $"SoundData/music{resolvedAudioId:000000}.acb"); zipArchive.CreateEntryFromFile(awb, $"SoundData/music{resolvedAudioId:000000}.awb"); -#else - logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 ACB/AWB。", music.Id); -#endif // 复制视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) @@ -673,7 +665,6 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa if (version is not null) simaiFile["version"] = version.GenreName; // demo_seek(预览起止时间) -#if WINDOWS try { if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var previewAcb, out _) && previewAcb is not null) @@ -689,7 +680,6 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa { logger.LogWarning(e, "ExportAsMaidata: 获取音频预览时间失败,已忽略。"); } -#endif for (var i = 0; i < music.Charts.Length; i++) { @@ -759,7 +749,6 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } // 导出音频 -#if WINDOWS var soundEntry = zipArchive.CreateEntry("track.mp3"); await using var soundStream = soundEntry.Open(); var tag = new ID3TagData @@ -781,9 +770,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa var wav = Audio.AcbToWav(acbPath); AudioConvert.ConvertWavToMp3Stream(wav, soundStream, tag); soundStream.Close(); -#else - logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 track.mp3。", music.Id); -#endif + if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) { @@ -861,8 +848,7 @@ private async Task WriteMaidataToDirectory(int id, string assetDir, string targe var version = StaticSettings.VersionList.FirstOrDefault(it => it.Id == music.AddVersionId); if (version is not null) simaiFile["version"] = version.GenreName; - // demo_seek(预览起止时间),依赖 CriUtils,仅 Windows 可用 -#if WINDOWS + // demo_seek(预览起止时间),依赖 CriUtils try { if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var previewAcb, out _) && previewAcb is not null) @@ -878,7 +864,6 @@ private async Task WriteMaidataToDirectory(int id, string assetDir, string targe { logger.LogWarning(e, "WriteMaidataToDirectory: 获取音频预览时间失败,已忽略。"); } -#endif for (var i = 0; i < music.Charts.Length; i++) { @@ -939,8 +924,7 @@ private async Task WriteMaidataToDirectory(int id, string assetDir, string targe await System.IO.File.WriteAllBytesAsync(Path.Combine(targetDir, $"bg{imgExt}"), img); } - // 导出音频 track.mp3,依赖 AudioConvert/CriUtils,仅 Windows 可用 -#if WINDOWS + // 导出音频 track.mp3,依赖 AudioConvert/CriUtils var tag = new ID3TagData { Title = music.Name, @@ -962,9 +946,7 @@ private async Task WriteMaidataToDirectory(int id, string assetDir, string targe { AudioConvert.ConvertWavToMp3Stream(wav, soundStream, tag); } -#else - logger.LogWarning("当前平台不支持音频导出,跳过音乐 {Id} 的 track.mp3。", music.Id); -#endif + // 导出 PV 视频 pv.mp4(与 zip 版保持一致,未加 #if WINDOWS 限制) if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) diff --git a/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs b/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs index 40d46ca..2a9b20d 100644 --- a/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs @@ -1,25 +1,21 @@ -using MaiChartManager.Utils; +using MaiChartManager.Platform; +using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.Tools; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class AudioConvertToolController : ControllerBase +public class AudioConvertToolController(IDesktopDialogService dialogService) : ControllerBase { [HttpPost] public IActionResult AudioConvertTool() { - var dialog = new OpenFileDialog() - { - Title = Locale.SelectAudioToConvert, - Filter = Locale.AudioFileFilter, - }; - - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) + // 用平台原生文件对话框选音频文件(Windows=WinForms,Linux=Photino),转换逻辑跨平台。 + var inputFile = dialogService.PickFile(Locale.SelectAudioToConvert, Locale.AudioFileFilter); + if (inputFile is null) return BadRequest(Locale.FileNotSelected); - var inputFile = dialog.FileName; var extension = Path.GetExtension(inputFile).ToLowerInvariant(); var directory = Path.GetDirectoryName(inputFile); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(inputFile); diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index 8465cf9..ecef1dd 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -65,9 +65,11 @@ - - - + + + + @@ -145,13 +147,6 @@ - - - - - - - diff --git a/MaiChartManager/Utils/Audio.cs b/MaiChartManager/Utils/Audio.cs index 75cbaff..7916f7f 100644 --- a/MaiChartManager/Utils/Audio.cs +++ b/MaiChartManager/Utils/Audio.cs @@ -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 { @@ -120,7 +126,7 @@ private static MemoryStream ConvertMp3ToWavViaFfmpeg(Stream src) conversion.Start().GetAwaiter().GetResult(); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) - throw new InvalidOperationException("ffmpeg produced empty wav file from mp3 input."); + throw new InvalidOperationException("ffmpeg produced empty wav file from input."); return new MemoryStream(File.ReadAllBytes(outputPath)); } @@ -149,7 +155,20 @@ public static byte[] ConvertFile( if (options.Loop) options.LoopEnd = int.MaxValue; - byte[] track = ConvertStream.ConvertFile(options, s, encodeType, convertToType); + // AcbCore 的 ConvertStream.ConvertFile 要求传入 MemoryStream;若来源不是则拷贝一份 + MemoryStream ms; + if (s is MemoryStream existing) + { + ms = existing; + } + else + { + ms = new MemoryStream(); + s.CopyTo(ms); + ms.Position = 0; + } + + byte[] track = ConvertStream.ConvertFile(options, ms, encodeType, convertToType); //if (convertToType == FileType.Hca && loop) // track = HCA.EncodeLoop(track, loop); @@ -194,11 +213,41 @@ public static byte[] AcbToWav(string acbPath) // 从MP4视频文件中提取音频轨道并保存为WAV文件 public static void ExtractAudioFromMp4(string mp4Path, string outputWavPath) { - using (var reader = new MediaFoundationReader(mp4Path)) + // 原本用 Windows-only 的 MediaFoundationReader 解码 mp4 内的音频流, + // 跨平台改为先用 ffmpeg 把 mp4 的音频解码成 16bit PCM wav,再用 NAudio 读取写出。 + var wavPath = ConvertMp4AudioToWavViaFfmpeg(mp4Path); + try { - // MediaFoundationReader 会自动解码视频中的音频流(如AAC)为PCM + using var reader = new WaveFileReader(wavPath); WaveFileWriter.CreateWaveFile(outputWavPath, reader); } + finally + { + File.Delete(wavPath); + } + } + + // 用 ffmpeg 把 mp4(或其它视频容器)里的音频流解码成 16bit PCM wav,返回临时 wav 文件路径(调用方负责删除)。 + private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path) + { + Directory.CreateDirectory(StaticSettings.tempPath); + var outputPath = Path.Combine(StaticSettings.tempPath, $"ExtractMp4Audio_{Guid.NewGuid():N}.wav"); + + var conversion = FFmpeg.Conversions.New() + .AddParameter("-i " + FFmpegHelper.Escape(mp4Path)) + .AddParameter("-vn") // 丢弃视频流,只要音频 + .AddParameter("-c:a pcm_s16le") // 转为16-bit little-endian PCM + .SetOutput(outputPath) + .SetOverwriteOutput(true); + conversion.Start().GetAwaiter().GetResult(); + + if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) + { + File.Delete(outputPath); + throw new InvalidOperationException("ffmpeg produced empty wav file from mp4 input."); + } + + return outputPath; } // 将 WAV 字节数据转换为 MP3 文件 diff --git a/SonicAudioTools b/SonicAudioTools index b189275..d0a3822 160000 --- a/SonicAudioTools +++ b/SonicAudioTools @@ -1 +1 @@ -Subproject commit b189275fab93f0662d6ba2f8634671ef865e1add +Subproject commit d0a38228f859bf84d1c18d9397b449a26ff222c1 diff --git a/XV2-Tools b/XV2-Tools index 07e54ef..c759483 160000 --- a/XV2-Tools +++ b/XV2-Tools @@ -1 +1 @@ -Subproject commit 07e54ef9abbe00817200cd41680a9a9ac0e23108 +Subproject commit c759483425b873ba8d580049c18c3e0cbb76f1de From aabcd62b89836f55b344c04188ad9b9b2265c5f5 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 13:05:47 +0800 Subject: [PATCH 32/50] =?UTF-8?q?fix(linux):=20=E9=9F=B3=E9=A2=91=20ffmpeg?= =?UTF-8?q?=20=E8=A7=A3=E7=A0=81=E6=94=B9=E7=94=A8=20Process+ArgumentList?= =?UTF-8?q?=EF=BC=8C=E8=A7=84=E9=81=BF=20Xabe=20=E5=9C=A8=20Linux=20?= =?UTF-8?q?=E6=8A=8A=E5=BC=95=E5=8F=B7=E5=AD=97=E9=9D=A2=E4=BC=A0=E7=BB=99?= =?UTF-8?q?=20ffmpeg=20=E5=AF=BC=E8=87=B4=E7=9A=84=20'Couldn't=20initializ?= =?UTF-8?q?e=20muxer'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConvertToWavViaFfmpeg / ConvertMp4AudioToWavViaFfmpeg 不再走 Xabe,直接 Process.Start - ArgumentList 每个参数独立传递,跨平台正确处理带空格路径、无引号问题 - FfmpegExePath: Windows 用内置 ffmpeg.exe,Linux 从 PATH 解析系统 ffmpeg Co-Authored-By: Claude Opus 4.8 --- MaiChartManager/Utils/Audio.cs | 60 ++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/MaiChartManager/Utils/Audio.cs b/MaiChartManager/Utils/Audio.cs index 7916f7f..3f91082 100644 --- a/MaiChartManager/Utils/Audio.cs +++ b/MaiChartManager/Utils/Audio.cs @@ -1,5 +1,6 @@ using NAudio.Lame; using NAudio.Wave; +using System.Diagnostics; using Xabe.FFmpeg; using VGAudio; using VGAudio.Cli; @@ -118,12 +119,10 @@ private static MemoryStream ConvertToWavViaFfmpeg(Stream src, string ext) 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(); + // 用 Process + ArgumentList 直接调 ffmpeg(每个参数独立、不拼引号)。 + // Xabe 在 Linux 上会把路径里的引号字面传给 ffmpeg,导致输出名变成 ...wav" → "Couldn't initialize muxer"; + // ArgumentList 既能正确处理带空格的路径(Windows),又不引入引号问题(Linux)。 + RunFfmpeg("-y", "-i", inputPath, "-c:a", "pcm_s16le", outputPath); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) throw new InvalidOperationException("ffmpeg produced empty wav file from input."); @@ -233,13 +232,8 @@ private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path) Directory.CreateDirectory(StaticSettings.tempPath); var outputPath = Path.Combine(StaticSettings.tempPath, $"ExtractMp4Audio_{Guid.NewGuid():N}.wav"); - var conversion = FFmpeg.Conversions.New() - .AddParameter("-i " + FFmpegHelper.Escape(mp4Path)) - .AddParameter("-vn") // 丢弃视频流,只要音频 - .AddParameter("-c:a pcm_s16le") // 转为16-bit little-endian PCM - .SetOutput(outputPath) - .SetOverwriteOutput(true); - conversion.Start().GetAwaiter().GetResult(); + // 同 ConvertToWavViaFfmpeg:用 Process + ArgumentList 直接调,避免 Xabe 在 Linux 上的引号问题。 + RunFfmpeg("-y", "-i", mp4Path, "-vn", "-c:a", "pcm_s16le", outputPath); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) { @@ -250,6 +244,46 @@ private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path) return outputPath; } + /// + /// 直接用 Process + ArgumentList 调系统/内置 ffmpeg,每个参数作为独立 argv(不拼引号)。 + /// 跨平台正确处理带空格的路径,且规避 Xabe.FFmpeg 在 Linux 上把引号字面传给 ffmpeg 的问题。 + /// + private static void RunFfmpeg(params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = FfmpegExePath(), + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + + using var p = Process.Start(psi) ?? throw new InvalidOperationException("无法启动 ffmpeg 进程"); + var err = p.StandardError.ReadToEnd(); + p.WaitForExit(); + if (p.ExitCode != 0) + throw new InvalidOperationException($"ffmpeg 转换失败(exit {p.ExitCode}):{err}"); + } + + /// 解析 ffmpeg 可执行文件路径:Windows 用内置 ffmpeg.exe,Linux 在 PATH 里找系统 ffmpeg。 + private static string FfmpegExePath() + { +#if WINDOWS + return Path.Combine(StaticSettings.exeDir, "ffmpeg.exe"); +#else + var path = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var d in path.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(d)) continue; + var candidate = Path.Combine(d, "ffmpeg"); + if (File.Exists(candidate)) return candidate; + } + return "ffmpeg"; +#endif + } + // 将 WAV 字节数据转换为 MP3 文件 public static void ConvertWavBytesToMp3(byte[] wavData, string mp3Path) { From 124ae0c409e4a7d13061a2eba3fb1b4799c520f5 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 13:15:06 +0800 Subject: [PATCH 33/50] =?UTF-8?q?fix(linux):=20=E5=B0=81=E9=9D=A2=20AB=20?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E8=BF=94=E5=9B=9E=E5=B0=8F=E5=86=99=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=E8=B7=AF=E5=BE=84=EF=BC=8C=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E5=86=99=E5=87=BA=E7=9A=84=E5=B0=8F=E5=86=99?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=88Linux=20=E5=A4=A7=E5=B0=8F=E5=86=99?= =?UTF-8?q?=E6=95=8F=E6=84=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MaiChartManager/Utils/AssetBundleCreator.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/MaiChartManager/Utils/AssetBundleCreator.cs b/MaiChartManager/Utils/AssetBundleCreator.cs index 411b721..b36bbd8 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等参数重新打包。 From 546f23a32fbff47f134b6208a458ba78400510c5 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 13:39:38 +0800 Subject: [PATCH 34/50] =?UTF-8?q?=E5=B0=86=20ffmpeg=20=E5=B0=81=E8=A3=85?= =?UTF-8?q?=E4=BB=8E=20Xabe.FFmpeg=20=E8=BF=81=E7=A7=BB=E5=88=B0=20FFMpegC?= =?UTF-8?q?ore=20=E5=B9=B6=E7=A7=BB=E9=99=A4=20Xabe=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xabe.FFmpeg 在 Linux 上会把路径字面加引号传给 ffmpeg(issue #356), 导致 "Couldn't initialize muxer"。改用 FFMpegCore(参数数组,无引号问题), 迁移前后保持 ffmpeg 命令行参数等价。 - 用 GlobalFFOptions.Configure 替换 FFmpeg.SetExecutablesPath (Windows 指向内置 ffmpeg.exe,Linux 用 PATH 解析的系统 ffmpeg 目录) - VideoConvert/AudioConvert/Audio 全部改用 FFMpegArguments; GetMediaInfo→FFProbe,OnProgress→NotifyOnProgress,硬件加速检测照原逻辑 - 删除只服务于 Xabe 的 FFmpegHelper.Escape - 修复 Linux 视频转换回归:-hwaccel dxva2 是 Windows 专有 DXVA 解码, Linux 上会让 ffmpeg 直接报错中断(No device available for decoder), 故改为仅 #if WINDOWS 启用,Linux 走软件解码 Co-Authored-By: Claude Opus 4.8 --- MaiChartManager/AppMain.cs | 10 +- .../Controllers/Music/VrcProcessController.cs | 28 +- MaiChartManager/LinuxProgram.cs | 14 +- MaiChartManager/MaiChartManager.csproj | 2 +- MaiChartManager/Utils/Audio.cs | 63 +--- MaiChartManager/Utils/AudioConvert.cs | 65 ++-- MaiChartManager/Utils/FFmpegHelper.cs | 17 - MaiChartManager/Utils/FfmpegDiagnostics.cs | 31 +- MaiChartManager/Utils/VideoConvert.cs | 302 ++++++++++++------ 9 files changed, 321 insertions(+), 211 deletions(-) delete mode 100644 MaiChartManager/Utils/FFmpegHelper.cs diff --git a/MaiChartManager/AppMain.cs b/MaiChartManager/AppMain.cs index b499b80..a01617a 100644 --- a/MaiChartManager/AppMain.cs +++ b/MaiChartManager/AppMain.cs @@ -8,7 +8,7 @@ using Microsoft.Web.WebView2.Core; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; -using Xabe.FFmpeg; +using FFMpegCore; namespace MaiChartManager; @@ -75,7 +75,13 @@ public void Run() ApplicationConfiguration.Initialize(); SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); UiContext = SynchronizationContext.Current; - FFmpeg.SetExecutablesPath(StaticSettings.exeDir); + // FFMpegCore:Windows 内置 ffmpeg.exe/ffprobe.exe 在 exeDir,临时文件用 tempPath。 + // FFMpegCore 会按 OS 自动给可执行名补 .exe 后缀。 + GlobalFFOptions.Configure(o => + { + o.BinaryFolder = StaticSettings.exeDir; + o.TemporaryFilesFolder = StaticSettings.tempPath; + }); VideoConvert.CheckHardwareAcceleration(); Directory.CreateDirectory(StaticSettings.appData); diff --git a/MaiChartManager/Controllers/Music/VrcProcessController.cs b/MaiChartManager/Controllers/Music/VrcProcessController.cs index 0a693b8..71a2e27 100644 --- a/MaiChartManager/Controllers/Music/VrcProcessController.cs +++ b/MaiChartManager/Controllers/Music/VrcProcessController.cs @@ -1,6 +1,6 @@ using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Xabe.FFmpeg; +using FFMpegCore; namespace MaiChartManager.Controllers.Music; @@ -38,11 +38,21 @@ public void GenAllMusicPreviewMp3ForVrc([FromForm] string targetDir, [FromForm] } var mp3Path = Path.Combine(targetDir, $"{musicId}.mp3"); // logger.LogInformation("转换中 {musicId}", musicId); - var conversion = await FFmpeg.Conversions.FromSnippet - .Split(wav, mp3Path, TimeSpan.FromSeconds(previewTime.StartTime), TimeSpan.FromSeconds(previewTime.EndTime - previewTime.StartTime)); - await conversion.SetOutputFormat(Format.mp3) - .Start(); + // 原本用 Xabe 的 FFmpeg.Conversions.FromSnippet.Split(...).SetOutputFormat(Format.mp3), + // 它对每个音视频流加 PostInput 的 -ss/-t(从源截取 [start, start+duration)), + // 加 -map,再设输出格式 mp3。这里输入是单音轨 wav(无 SetCodec → 不显式指定 -c:a, + // 由 ffmpeg 按 mp3 容器选默认编码器)。 + // 等价命令行:ffmpeg -i -ss -t -map 0:0 -f mp3 + // 时间格式沿用 Xabe 的 H:MM:SS.mmm,保证参数一致。 + var start = TimeSpan.FromSeconds(previewTime.StartTime); + var duration = TimeSpan.FromSeconds(previewTime.EndTime - previewTime.StartTime); + + await FFMpegArguments + .FromFileInput(wav, verifyExists: false) + .OutputToFile(mp3Path, overwrite: true, o => o.WithCustomArgument( + $"-ss {FormatFFmpegTime(start)} -t {FormatFFmpegTime(duration)} -map 0:0 -f mp3")) + .ProcessAsynchronously(); } catch (Exception ex) { @@ -58,4 +68,12 @@ await conversion.SetOutputFormat(Format.mp3) // await Task.WhenAll(tasks); }); } + + /// + /// 把 TimeSpan 格式化为 ffmpeg 时间字符串 H:MM:SS.mmm, + /// 与原 Xabe TimeExtensions.ToFFmpeg 行为一致("{0:D}:{1:D2}:{2:D2}.{3:D3}", + /// 参数为 (int)TotalHours, Minutes, Seconds, Milliseconds)。 + /// + private static string FormatFFmpegTime(TimeSpan time) => + $"{(int)time.TotalHours:D}:{time.Minutes:D2}:{time.Seconds:D2}.{time.Milliseconds:D3}"; } \ No newline at end of file diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs index 6e2943d..504fd8a 100644 --- a/MaiChartManager/LinuxProgram.cs +++ b/MaiChartManager/LinuxProgram.cs @@ -2,7 +2,7 @@ using System.Globalization; using System.Text.Json; using Photino.NET; -using Xabe.FFmpeg; +using FFMpegCore; namespace MaiChartManager; @@ -96,14 +96,18 @@ private static void HandleWebMessage(PhotinoWindow parent, string message) } /// - /// 配置 Xabe.FFmpeg 使用系统 ffmpeg/ffprobe。 - /// Windows 版在 AppMain 里指向内置的 ffmpeg.exe;Linux 不内置,改用系统 PATH 里的 ffmpeg。 - /// 必须显式传可执行名 "ffmpeg"/"ffprobe",否则 Xabe 在 Linux 上仍会去找 .exe 后缀的文件。 + /// 配置 FFMpegCore 使用系统 ffmpeg/ffprobe。 + /// Windows 版在 AppMain 里指向内置的 ffmpeg.exe;Linux 不内置,改用系统 PATH 里的 ffmpeg 所在目录。 + /// FFMpegCore 用参数数组传给 ffmpeg(无引号问题),按 OS 自动补可执行名后缀。 /// private static void ConfigureFfmpeg() { var dir = ResolveExecutableDir("ffmpeg") ?? "/usr/bin"; - FFmpeg.SetExecutablesPath(dir, "ffmpeg", "ffprobe"); + GlobalFFOptions.Configure(o => + { + o.BinaryFolder = dir; + o.TemporaryFilesFolder = StaticSettings.tempPath; + }); // 检测硬件加速(与 Windows 的 AppMain 一致,失败不影响主流程) _ = MaiChartManager.Utils.VideoConvert.CheckHardwareAcceleration(); } diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index ecef1dd..9900646 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -96,7 +96,7 @@ - + diff --git a/MaiChartManager/Utils/Audio.cs b/MaiChartManager/Utils/Audio.cs index 3f91082..e592bd2 100644 --- a/MaiChartManager/Utils/Audio.cs +++ b/MaiChartManager/Utils/Audio.cs @@ -1,7 +1,6 @@ using NAudio.Lame; using NAudio.Wave; -using System.Diagnostics; -using Xabe.FFmpeg; +using FFMpegCore; using VGAudio; using VGAudio.Cli; using Xv2CoreLib.ACB; @@ -119,10 +118,14 @@ private static MemoryStream ConvertToWavViaFfmpeg(Stream src, string ext) src.CopyTo(inputFile); } - // 用 Process + ArgumentList 直接调 ffmpeg(每个参数独立、不拼引号)。 - // Xabe 在 Linux 上会把路径里的引号字面传给 ffmpeg,导致输出名变成 ...wav" → "Couldn't initialize muxer"; - // ArgumentList 既能正确处理带空格的路径(Windows),又不引入引号问题(Linux)。 - RunFfmpeg("-y", "-i", inputPath, "-c:a", "pcm_s16le", outputPath); + // 用 FFMpegCore 把任意输入解码成 16bit PCM wav。 + // FFMpegCore 用参数数组传给 ffmpeg,既能正确处理带空格的路径(Windows), + // 又不引入 Xabe 在 Linux 上把引号字面传给 ffmpeg 的问题。 + // 等价命令行:ffmpeg -y -i -c:a pcm_s16le + FFMpegArguments + .FromFileInput(inputPath, verifyExists: false) + .OutputToFile(outputPath, overwrite: true, o => o.WithCustomArgument("-c:a pcm_s16le")) + .ProcessSynchronously(); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) throw new InvalidOperationException("ffmpeg produced empty wav file from input."); @@ -232,8 +235,12 @@ private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path) Directory.CreateDirectory(StaticSettings.tempPath); var outputPath = Path.Combine(StaticSettings.tempPath, $"ExtractMp4Audio_{Guid.NewGuid():N}.wav"); - // 同 ConvertToWavViaFfmpeg:用 Process + ArgumentList 直接调,避免 Xabe 在 Linux 上的引号问题。 - RunFfmpeg("-y", "-i", mp4Path, "-vn", "-c:a", "pcm_s16le", outputPath); + // 用 FFMpegCore 把 mp4(或其它视频容器)里的音频流解码成 16bit PCM wav。 + // 等价命令行:ffmpeg -y -i -vn -c:a pcm_s16le + FFMpegArguments + .FromFileInput(mp4Path, verifyExists: false) + .OutputToFile(outputPath, overwrite: true, o => o.WithCustomArgument("-vn -c:a pcm_s16le")) + .ProcessSynchronously(); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) { @@ -244,46 +251,6 @@ private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path) return outputPath; } - /// - /// 直接用 Process + ArgumentList 调系统/内置 ffmpeg,每个参数作为独立 argv(不拼引号)。 - /// 跨平台正确处理带空格的路径,且规避 Xabe.FFmpeg 在 Linux 上把引号字面传给 ffmpeg 的问题。 - /// - private static void RunFfmpeg(params string[] args) - { - var psi = new ProcessStartInfo - { - FileName = FfmpegExePath(), - UseShellExecute = false, - RedirectStandardError = true, - RedirectStandardOutput = true, - CreateNoWindow = true, - }; - foreach (var a in args) psi.ArgumentList.Add(a); - - using var p = Process.Start(psi) ?? throw new InvalidOperationException("无法启动 ffmpeg 进程"); - var err = p.StandardError.ReadToEnd(); - p.WaitForExit(); - if (p.ExitCode != 0) - throw new InvalidOperationException($"ffmpeg 转换失败(exit {p.ExitCode}):{err}"); - } - - /// 解析 ffmpeg 可执行文件路径:Windows 用内置 ffmpeg.exe,Linux 在 PATH 里找系统 ffmpeg。 - private static string FfmpegExePath() - { -#if WINDOWS - return Path.Combine(StaticSettings.exeDir, "ffmpeg.exe"); -#else - var path = Environment.GetEnvironmentVariable("PATH") ?? ""; - foreach (var d in path.Split(Path.PathSeparator)) - { - if (string.IsNullOrWhiteSpace(d)) continue; - var candidate = Path.Combine(d, "ffmpeg"); - if (File.Exists(candidate)) return candidate; - } - return "ffmpeg"; -#endif - } - // 将 WAV 字节数据转换为 MP3 文件 public static void ConvertWavBytesToMp3(byte[] wavData, string mp3Path) { diff --git a/MaiChartManager/Utils/AudioConvert.cs b/MaiChartManager/Utils/AudioConvert.cs index bd186c9..dbffa9d 100644 --- a/MaiChartManager/Utils/AudioConvert.cs +++ b/MaiChartManager/Utils/AudioConvert.cs @@ -1,6 +1,6 @@ using NAudio.Lame; using Standart.Hash.xxHash; -using Xabe.FFmpeg; +using FFMpegCore; namespace MaiChartManager.Utils; @@ -84,36 +84,45 @@ public static void ConvertWavToMp3Stream(byte[] wav, Stream mp3Stream, ID3TagDat Directory.CreateDirectory(StaticSettings.tempPath); File.WriteAllBytes(inputPath, wav); - var conversion = FFmpeg.Conversions.New() - .AddParameter($"-i " + FFmpegHelper.Escape(inputPath)); + // 输出参数(PostInput):按原 Xabe 调用顺序拼装,保持 ffmpeg 命令行等价。 + // FFMpegCore 用 ArgumentList 传递,不会像 Xabe 那样产生引号问题; + // 但 -metadata 的值可能含空格,这里仍用 Escape() 把值包成带引号的单个 token, + // 与原先 FFmpegHelper.Escape 行为一致。 + var output = new List(); if (tagData != null) { - if (tagData.AlbumArt != null && tagData.AlbumArt.Length > 0) - { - // 把专辑封面写到临时文件,然后让ffmpeg把它嵌入mp3 - albumArtPath = Path.Combine(StaticSettings.tempPath, $"ConvertToMp3_{tempFileGuid:N}.png"); - File.WriteAllBytes(albumArtPath, tagData.AlbumArt); - conversion.AddParameter($"-i {FFmpegHelper.Escape(albumArtPath)}"); - } // 顺序不能换!这个必须在第一个,因为-i必须在任何其他参数之前。 - if (!string.IsNullOrEmpty(tagData.Title)) conversion.AddParameter($"-metadata title=" + FFmpegHelper.Escape(tagData.Title)); - if (!string.IsNullOrEmpty(tagData.Artist)) conversion.AddParameter($"-metadata artist=" + FFmpegHelper.Escape(tagData.Artist)); - if (!string.IsNullOrEmpty(tagData.Album)) conversion.AddParameter($"-metadata album=" + FFmpegHelper.Escape(tagData.Album)); - if (!string.IsNullOrEmpty(tagData.Year)) conversion.AddParameter($"-metadata date=" + FFmpegHelper.Escape(tagData.Year)); - if (!string.IsNullOrEmpty(tagData.Comment)) conversion.AddParameter($"-metadata comment=" + FFmpegHelper.Escape(tagData.Comment)); - if (!string.IsNullOrEmpty(tagData.Genre)) conversion.AddParameter($"-metadata genre=" + FFmpegHelper.Escape(tagData.Genre)); - if (!string.IsNullOrEmpty(tagData.Track)) conversion.AddParameter($"-metadata track=" + FFmpegHelper.Escape(tagData.Track)); + // 注意:第二个输入(专辑封面)由 AddFileInput 处理,必须在所有输出参数之前, + // 对应原注释“-i 必须在任何其他参数之前”。 + if (!string.IsNullOrEmpty(tagData.Title)) output.Add("-metadata title=" + Escape(tagData.Title)); + if (!string.IsNullOrEmpty(tagData.Artist)) output.Add("-metadata artist=" + Escape(tagData.Artist)); + if (!string.IsNullOrEmpty(tagData.Album)) output.Add("-metadata album=" + Escape(tagData.Album)); + if (!string.IsNullOrEmpty(tagData.Year)) output.Add("-metadata date=" + Escape(tagData.Year)); + if (!string.IsNullOrEmpty(tagData.Comment)) output.Add("-metadata comment=" + Escape(tagData.Comment)); + if (!string.IsNullOrEmpty(tagData.Genre)) output.Add("-metadata genre=" + Escape(tagData.Genre)); + if (!string.IsNullOrEmpty(tagData.Track)) output.Add("-metadata track=" + Escape(tagData.Track)); } - - conversion.AddParameter("-c:a libmp3lame -b:a 256k"); // 把wav编码为256kbps的LAME mp3 + output.Add("-c:a libmp3lame -b:a 256k"); // 把wav编码为256kbps的LAME mp3 + + if (tagData?.AlbumArt is { Length: > 0 }) + { + // 把专辑封面写到临时文件,然后让ffmpeg把它嵌入mp3 + albumArtPath = Path.Combine(StaticSettings.tempPath, $"ConvertToMp3_{tempFileGuid:N}.png"); + File.WriteAllBytes(albumArtPath, tagData.AlbumArt); + // 如果有专辑封面,还需要加一堆参数以写入专辑封面 + output.Add("-map 0:a -map 1:v -c:v copy -disposition:v attached_pic"); + } + + var args = FFMpegArguments.FromFileInput(inputPath, verifyExists: false); if (albumArtPath != null) - { // 如果有专辑封面,还需要加一堆参数以写入专辑封面 - conversion.AddParameter("-map 0:a -map 1:v -c:v copy -disposition:v attached_pic"); + { + args = args.AddFileInput(albumArtPath, verifyExists: false); } - - conversion.SetOutput(outputPath).SetOverwriteOutput(true); - conversion.Start().GetAwaiter().GetResult(); + + args + .OutputToFile(outputPath, overwrite: true, o => o.WithCustomArgument(string.Join(" ", output))) + .ProcessSynchronously(); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) { @@ -130,4 +139,12 @@ public static void ConvertWavToMp3Stream(byte[] wav, Stream mp3Stream, ID3TagDat if (albumArtPath != null) File.Delete(albumArtPath); } } + + /// + /// 将字符串转义后用双引号包裹,作为单个 ffmpeg 参数 token。 + /// 等价于原 FFmpegHelper.Escape:正确转义内容中的反斜杠和双引号。 + /// 用于 -metadata 值,避免含空格的值被拆成多个参数。 + /// + private static string Escape(string value) => + "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; } diff --git a/MaiChartManager/Utils/FFmpegHelper.cs b/MaiChartManager/Utils/FFmpegHelper.cs deleted file mode 100644 index d6a3d60..0000000 --- a/MaiChartManager/Utils/FFmpegHelper.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace MaiChartManager.Utils; - -public static class FFmpegHelper -{ - /// - /// 将字符串转义后用双引号包裹,用于安全地拼接 ffmpeg 参数。 - /// 与 Xabe.FFmpeg 自带的 Escape() 不同,这里会正确转义内容中的反斜杠和双引号。 - /// - public static string? Escape(string? value) - { - if (value == null) return value; - return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - } -} - diff --git a/MaiChartManager/Utils/FfmpegDiagnostics.cs b/MaiChartManager/Utils/FfmpegDiagnostics.cs index b83a439..bc4dd76 100644 --- a/MaiChartManager/Utils/FfmpegDiagnostics.cs +++ b/MaiChartManager/Utils/FfmpegDiagnostics.cs @@ -1,24 +1,27 @@ using System.Collections.Concurrent; -using Xabe.FFmpeg; -using Xabe.FFmpeg.Exceptions; namespace MaiChartManager.Utils; +/// +/// 收集 ffmpeg 运行期间输出到 stderr 的日志行(ffmpeg 几乎所有诊断信息都走 stderr)。 +/// 迁移到 FFMpegCore 后,不再有 Xabe 的 IConversion / OnDataReceived 事件, +/// 改为把 直接挂到 FFMpegArgumentProcessor 的 NotifyOnError 上。 +/// public sealed class FfmpegLogCollector { private const int MaxLines = 400; private readonly ConcurrentQueue lines = new(); - public void Attach(IConversion conversion) + /// + /// 接收一行 ffmpeg 输出。用法:processor.NotifyOnError(collector.AddLine)。 + /// + public void AddLine(string? data) { - conversion.OnDataReceived += (_, args) => + if (string.IsNullOrWhiteSpace(data)) return; + lines.Enqueue(data); + while (lines.Count > MaxLines && lines.TryDequeue(out _)) { - if (string.IsNullOrWhiteSpace(args.Data)) return; - lines.Enqueue(args.Data); - while (lines.Count > MaxLines && lines.TryDequeue(out var _)) - { - } - }; + } } public string GetLog() => string.Join(Environment.NewLine, lines); @@ -30,13 +33,7 @@ public static string CreateDetail(Exception exception, string? ffmpegLog = null) { var parts = new List(); - if (exception is ConversionException conversionException && - !string.IsNullOrWhiteSpace(conversionException.InputParameters)) - { - parts.Add("FFmpeg parameters:"); - parts.Add(conversionException.InputParameters); - } - + // FFMpegCore 的异常不携带 Xabe 的 InputParameters,关键信息全在收集到的 ffmpeg 日志里。 if (!string.IsNullOrWhiteSpace(ffmpegLog)) { parts.Add("FFmpeg output:"); diff --git a/MaiChartManager/Utils/VideoConvert.cs b/MaiChartManager/Utils/VideoConvert.cs index e035436..5e0fb1d 100644 --- a/MaiChartManager/Utils/VideoConvert.cs +++ b/MaiChartManager/Utils/VideoConvert.cs @@ -1,5 +1,6 @@ using MaiChartManager.Platform; -using Xabe.FFmpeg; +using FFMpegCore; +using FFMpegCore.Enums; namespace MaiChartManager.Utils; @@ -20,6 +21,19 @@ public enum HardwareAccelerationStatus Math.Max(1, Environment.ProcessorCount / 4), Math.Max(1, Environment.ProcessorCount / 4)); + /// + /// 等价于 Xabe 的 UseMultiThread(true):渲染为 "-threads {Min(ProcessorCount, 16)}"。 + /// + private static string MultiThreadArg => $"-threads {Math.Min(Environment.ProcessorCount, 16)}"; + + /// + /// 把 TimeSpan 格式化为 ffmpeg 时间字符串 H:MM:SS.mmm, + /// 与原 Xabe TimeExtensions.ToFFmpeg 行为一致("{0:D}:{1:D2}:{2:D2}.{3:D3}", + /// 参数为 (int)TotalHours, Minutes, Seconds, Milliseconds)。 + /// + private static string FormatFFmpegTime(TimeSpan time) => + $"{(int)time.TotalHours:D}:{time.Minutes:D2}:{time.Seconds:D2}.{time.Milliseconds:D3}"; + /// /// 检测硬件加速支持 /// @@ -29,15 +43,14 @@ public static async Task CheckHardwareAcceleration() try { // 测试 VP9 QSV 硬件加速 + // 等价 Xabe 命令行:-t 0:00:02.000 -f lavfi -i color=c=black:s=720x720:r=1 -c:v vp9_qsv -threads N var blankPath = Path.Combine(tmpDir.FullName, "blank.ivf"); - await FFmpeg.Conversions.New() - .SetOutputTime(TimeSpan.FromSeconds(2)) - .SetInputFormat(Format.lavfi) - .AddParameter("-i color=c=black:s=720x720:r=1") - .AddParameter("-c:v vp9_qsv") - .UseMultiThread(true) - .SetOutput(blankPath) - .Start(); + await FFMpegArguments + .FromFileInput("color=c=black:s=720x720:r=1", verifyExists: false, + opt => opt.WithCustomArgument($"-t {FormatFFmpegTime(TimeSpan.FromSeconds(2))} -f lavfi")) + .OutputToFile(blankPath, overwrite: true, + opt => opt.WithCustomArgument($"-c:v vp9_qsv {MultiThreadArg}")) + .ProcessAsynchronously(); HardwareAcceleration = HardwareAccelerationStatus.Enabled; } catch @@ -50,15 +63,14 @@ await FFmpeg.Conversions.New() { try { + // 等价 Xabe 命令行:-t 0:00:02.000 -f lavfi -i color=c=black:s=720x720:r=1 -c:v -threads N var blankPath = Path.Combine(tmpDir.FullName, $"{encoder}.mp4"); - await FFmpeg.Conversions.New() - .SetOutputTime(TimeSpan.FromSeconds(2)) - .SetInputFormat(Format.lavfi) - .AddParameter("-i color=c=black:s=720x720:r=1") - .AddParameter($"-c:v {encoder}") - .UseMultiThread(true) - .SetOutput(blankPath) - .Start(); + await FFMpegArguments + .FromFileInput("color=c=black:s=720x720:r=1", verifyExists: false, + opt => opt.WithCustomArgument($"-t {FormatFFmpegTime(TimeSpan.FromSeconds(2))} -f lavfi")) + .OutputToFile(blankPath, overwrite: true, + opt => opt.WithCustomArgument($"-c:v {encoder} {MultiThreadArg}")) + .ProcessAsynchronously(); H264Encoder = encoder; break; } @@ -184,20 +196,19 @@ public static async Task ConvertVideo(VideoConvertOptions options) private static async Task ConvertToVp9OrH264(VideoConvertOptions options, string outputPath, string tmpDir) { - var srcMedia = await FFmpeg.GetMediaInfo(options.InputPath); + var srcMedia = await FFProbe.AnalyseAsync(options.InputPath); var codec = options.UseH264 ? H264Encoder : Vp9Encoding; - var firstStream = srcMedia.VideoStreams.First().SetCodec(codec); - var conversion = FFmpeg.Conversions.New() - .AddStream(firstStream); + var srcWidth = srcMedia.PrimaryVideoStream!.Width; + var srcHeight = srcMedia.PrimaryVideoStream!.Height; + var srcDuration = srcMedia.Duration; + var logCollector = new FfmpegLogCollector(); - logCollector.Attach(conversion); // 处理图片输入 - if (options.ContentType?.StartsWith("image/") == true) + var isImage = options.ContentType?.StartsWith("image/") == true; + if (isImage) { options.Padding = 0; - conversion.AddParameter("-r 1 -t 2"); - conversion.AddParameter("-loop 1", ParameterPosition.PreInput); } // 处理极小的 padding @@ -214,71 +225,199 @@ private static async Task ConvertToVp9OrH264(VideoConvertOptions options, string vf = $"scale={scale}:-1,pad={scale}:{scale}:({scale}-iw)/2:({scale}-ih)/2:black"; } - // 处理 padding - if (options.Padding < 0) + // 与 Xabe 行为一致:源视频流默认通过 -map 0:0 选取并以 -c:v 编码。 + // PreInput 参数(如 -loop 1 / -hwaccel dxva2)放到 FromFileInput 的 input options; + // PostInput 参数(-c:v / -t / -vf / -threads 等)放到 OutputToFile 的 output options, + // 顺序严格对应原 Xabe 的拼装顺序: + // [streams PostInput: -c:v -map 0:0] [user PostInput...] + + if (options.Padding > 0) { - // 负数:裁剪开头 - conversion.SetSeek(TimeSpan.FromSeconds(-options.Padding)); + // 正数:添加前置空白,先生成 blank,再 concat。 + // 等价 Xabe:-t -f lavfi -i color=c=black:s=WxH:r=30 -threads N + var blankPath = Path.Combine(tmpDir, "blank.mp4"); + await FFMpegArguments + .FromFileInput($"color=c=black:s={srcWidth}x{srcHeight}:r=30", verifyExists: false, + opt => opt.WithCustomArgument($"-t {FormatFFmpegTime(TimeSpan.FromSeconds(options.Padding))} -f lavfi")) + .OutputToFile(blankPath, overwrite: true, + opt => opt.WithCustomArgument(MultiThreadArg)) + .NotifyOnError(logCollector.AddLine) + .ProcessAsynchronously(); + + await RunConcatenate(vf, codec, [blankPath, options.InputPath], outputPath, options, logCollector, srcDuration); + return; } - else if (options.Padding > 0) + + // 非 padding>0 的常规路径 + // PostInput 顺序(对应原代码): + // -c:v (来自 stream.SetCodec) + // -map 0:0(来自 stream) + // [图片] -r 1 -t 2 + // -threads N(UseMultiThread) + // [VP9] -cpu-used 5 [+ -pix_fmt yuv420p] + // [缩放] -vf + var postArgs = new List { - // 正数:添加前置空白 - var blankPath = Path.Combine(tmpDir, "blank.mp4"); - var blank = FFmpeg.Conversions.New() - .SetOutputTime(TimeSpan.FromSeconds(options.Padding)) - .SetInputFormat(Format.lavfi) - .AddParameter($"-i color=c=black:s={srcMedia.VideoStreams.First().Width}x{srcMedia.VideoStreams.First().Height}:r=30") - .UseMultiThread(true) - .SetOutput(blankPath); - logCollector.Attach(blank); - await blank.Start(); - var blankVideoInfo = await FFmpeg.GetMediaInfo(blankPath); - conversion = Concatenate(vf, blankVideoInfo, srcMedia); - logCollector.Attach(conversion); - conversion.AddParameter($"-c:v {codec}"); + $"-c:v {codec}", + "-map 0:0", + }; + + if (isImage) + { + // 原 Xabe:AddParameter("-r 1 -t 2")(PostInput) + postArgs.Add("-r 1 -t 2"); + } + + // PreInput 参数:图片用 -loop 1,Windows 上再加 -hwaccel dxva2。 + // 原 Xabe 添加顺序:先 -loop 1(图片时),后 -hwaccel dxva2。 + var preArgs = new List(); + if (isImage) + { + preArgs.Add("-loop 1"); } +#if WINDOWS + // dxva2 是 Windows 专有的 DirectX 视频加速。Linux 上没有该设备,ffmpeg 会直接报错 + //(No device available for decoder: device type dxva2 needed),整个转换中断; + // 故仅 Windows 启用以保持原行为,Linux 走软件解码(唯一可用方式)。 + preArgs.Add("-hwaccel dxva2"); +#endif - // 基本参数 - conversion - .SetOutput(outputPath) - .AddParameter("-hwaccel dxva2", ParameterPosition.PreInput) - .UseMultiThread(true); + // -threads N(UseMultiThread) + postArgs.Add(MultiThreadArg); // VP9 特定参数 if (!options.UseH264) { - conversion.AddParameter("-cpu-used 5"); + postArgs.Add("-cpu-used 5"); if (options.UseYuv420p) - conversion.AddParameter("-pix_fmt yuv420p"); + postArgs.Add("-pix_fmt yuv420p"); + } + + // 负数 padding:裁剪开头,对应 Xabe 的 SetSeek → PostInput 的 -ss - private static void ConfigureFfmpeg() + public static void ConfigureFfmpeg() { var dir = ResolveExecutableDir("ffmpeg") ?? "/usr/bin"; GlobalFFOptions.Configure(o => @@ -128,8 +129,9 @@ private static void ConfigureFfmpeg() /// /// Linux 的最小化无头配置加载。对应 AppMain.InitConfiguration,但去掉了 /// Sentry / MessageBox / WinForms 相关部分(这些代码在被排除的 AppMain.cs 中)。 + /// 公开以便 CLI 等其它 Linux 入口复用。 /// - private static void InitConfiguration() + public static void InitConfiguration() { var cfgFilePath = Path.Combine(StaticSettings.appData, "config.json"); if (File.Exists(cfgFilePath)) diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index 4c89b8e..e7dfa55 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -36,10 +36,9 @@ linux-x64 MaiChartManager.LinuxProgram + 仍保留 RID 以带上 Photino.Native.so 等 RID 专属原生库。开启 ReadyToRun 预编译 + 应用程序集,显著加快冷启动(框架依赖下仍有效)。 --> false - false diff --git a/MuConvert b/MuConvert index 47834d4..ae6aeba 160000 --- a/MuConvert +++ b/MuConvert @@ -1 +1 @@ -Subproject commit 47834d4dcb4b64dfc2f783821146b257074f5d99 +Subproject commit ae6aeba291c6af33cd0202e16eef3ce18f530130 diff --git a/Packaging/arch/PKGBUILD b/Packaging/arch/PKGBUILD index e761016..5ef6639 100644 --- a/Packaging/arch/PKGBUILD +++ b/Packaging/arch/PKGBUILD @@ -4,7 +4,7 @@ # 直接用当前工作树构建。请在仓库内 `Packaging/arch/` 目录执行 `makepkg -si`。 pkgname=maichartmanager -pkgver=26.3.r46.gf68ebc1 +pkgver=26.3.r47.gf7a8d6e pkgrel=1 pkgdesc="maimai 谱面管理工具(Linux 原生,Photino + WebKitGTK)" arch=('x86_64') @@ -72,9 +72,11 @@ EOF cd "$root/MaiChartManager/Front" pnpm run build - # 2) 框架依赖发布后端到 srcdir/out(不打包运行时,依赖系统 aspnet-runtime) + # 2) 框架依赖发布到 srcdir/out(不打包运行时,依赖系统 aspnet-runtime)。 + # 发布 CLI 项目即可:它引用主项目,产物是超集——同时含 GUI 主程序 apphost + # (MaiChartManager)、CLI (MaiChartManager.CLI)、MuConvert、wwwroot 与全部共享 dll。 cd "$root" - dotnet publish MaiChartManager/MaiChartManager.csproj \ + dotnet publish MaiChartManager.CLI/MaiChartManager.CLI.csproj \ -c LinuxRelease \ -o "$srcdir/out" \ --no-self-contained \ @@ -88,22 +90,33 @@ package() { install -dm755 "$pkgdir/opt/$pkgname" cp -a "$srcdir/out/." "$pkgdir/opt/$pkgname/" - # 启动器:apphost 以自身路径定位 exeDir/wwwroot,故直接 exec 即可 + # 三个命令(apphost 各自以自身路径定位 exeDir,故直接 exec): + # maichartmanager —— 启动 GUI 主程序 + # mcm —— 命令行工具 + # muconvert —— 音游转谱器 install -dm755 "$pkgdir/usr/bin" - cat > "$pkgdir/usr/bin/$pkgname" < "$pkgdir/usr/bin/maichartmanager" < "$pkgdir/usr/bin/mcm" < "$pkgdir/usr/bin/muconvert" < "$pkgdir/usr/share/applications/$pkgname.desktop" < Date: Fri, 19 Jun 2026 16:26:51 +0800 Subject: [PATCH 48/50] =?UTF-8?q?=E9=87=8D=E6=96=B0=E5=8A=A0=E5=85=A5=20Di?= =?UTF-8?q?rectory.Build.props=EF=BC=88=E6=9D=A1=E4=BB=B6=E5=8C=96=20MuCon?= =?UTF-8?q?vert=20=E8=87=AA=E5=8C=85=E5=90=AB=EF=BC=8C=E4=BB=85=20Windows?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交因 .gitignore 忽略而漏加,导致文件被误删。-f 强制加回。 Co-Authored-By: Claude Opus 4.8 --- Directory.Build.props | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..417c696 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + win-x64 + true + + From 2efba1d905097b2e00b74dd1a5fdef193c5a8f84 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 16:55:54 +0800 Subject: [PATCH 49/50] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20GUI=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=8D=A1=2025=20=E7=A7=92=EF=BC=9A=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=20ContentRoot=20=E4=B8=BA=20exeDir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebApplication.CreateBuilder 默认用 cwd 当 ContentRoot,从用户 HOME 启动时 host 会对 HOME 海量文件做文件监视/扫描,CreateBuilder 卡 25 秒+。显式指向 exeDir 解决(dotnet run 从项目目录启动所以不慢,掩盖了这个问题)。 wwwroot 伺服走独立 PhysicalFileProvider,不受影响。 Co-Authored-By: Claude Opus 4.8 --- MaiChartManager/ServerManager.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index f0d7469..705b240 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -77,8 +77,14 @@ private static int GetAvailablePort() // 但不开放 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) => { // 指定 Sentry 项目,将事件发送到对应的项目: From 7b00474fe29ad02cba3978e1e087594655759869 Mon Sep 17 00:00:00 2001 From: Clansty Date: Fri, 19 Jun 2026 17:00:57 +0800 Subject: [PATCH 50/50] =?UTF-8?q?PKGBUILD:=20=E6=9B=B4=E6=96=B0=20pkgver?= =?UTF-8?q?=20=E7=BC=93=E5=AD=98=E5=80=BC=EF=BC=88makepkg=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packaging/arch/PKGBUILD b/Packaging/arch/PKGBUILD index 5ef6639..fa24b22 100644 --- a/Packaging/arch/PKGBUILD +++ b/Packaging/arch/PKGBUILD @@ -4,7 +4,7 @@ # 直接用当前工作树构建。请在仓库内 `Packaging/arch/` 目录执行 `makepkg -si`。 pkgname=maichartmanager -pkgver=26.3.r47.gf7a8d6e +pkgver=26.3.r49.g16f9f82 pkgrel=1 pkgdesc="maimai 谱面管理工具(Linux 原生,Photino + WebKitGTK)" arch=('x86_64')