Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions MaiChartManager/Controllers/App/AppVersionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,24 @@ namespace MaiChartManager.Controllers.App;
public class AppVersionController(StaticSettings settings, ILogger<AppVersionController> logger) : ControllerBase
{
#if WINDOWS
public record AppVersionResult(string Version, int GameVersion, IapManager.LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale);
public record AppVersionResult(string Version, int GameVersion, IapManager.LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale, string Platform, bool Export);

[HttpGet]
public AppVersionResult GetAppVersion()
{
return new AppVersionResult(Application.ProductVersion, settings.gameVersion, IapManager.License, VideoConvert.HardwareAcceleration, VideoConvert.H264Encoder, StaticSettings.CurrentLocale);
return new AppVersionResult(
Application.ProductVersion,
settings.gameVersion,
IapManager.License,
VideoConvert.HardwareAcceleration,
VideoConvert.H264Encoder,
StaticSettings.CurrentLocale,
OperatingSystem.IsWindows() ? "Windows" : "Linux",
StaticSettings.Config.Export);
}
#else
public enum LicenseStatus { Pending, Active, Inactive }
public record AppVersionResult(string Version, int GameVersion, LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale);
public record AppVersionResult(string Version, int GameVersion, LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale, string Platform, bool Export);

[HttpGet]
public AppVersionResult GetAppVersion()
Expand All @@ -29,7 +37,15 @@ public AppVersionResult GetAppVersion()
var info = (System.Reflection.AssemblyInformationalVersionAttribute?)System.Attribute
.GetCustomAttribute(asm, typeof(System.Reflection.AssemblyInformationalVersionAttribute));
var version = info?.InformationalVersion?.Split('+')[0] ?? "linux";
return new AppVersionResult(version, settings.gameVersion, LicenseStatus.Active, VideoConvert.HardwareAcceleration, VideoConvert.H264Encoder, StaticSettings.CurrentLocale);
return new AppVersionResult(
version,
settings.gameVersion,
LicenseStatus.Active,
VideoConvert.HardwareAcceleration,
VideoConvert.H264Encoder,
StaticSettings.CurrentLocale,
OperatingSystem.IsWindows() ? "Windows" : "Linux",
StaticSettings.Config.Export);
}
#endif
}
4 changes: 4 additions & 0 deletions MaiChartManager/Controllers/App/OobeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public class OobeController(
IAppShell appShell,
IDesktopDialogService dialogService) : ControllerBase
{
private bool IsLoopbackRequest()
=> HttpContext.Connection.RemoteIpAddress is { } remoteIp && IPAddress.IsLoopback(remoteIp);

[HttpGet]
public string? GetGamePath()
{
Expand Down Expand Up @@ -92,6 +95,7 @@ public List<string> GetLanAddresses()
[HttpPost]
public async Task<IActionResult> CompleteSetup([FromBody] CompleteSetupRequest request)
{
if (!IsLoopbackRequest()) return StatusCode(StatusCodes.Status403Forbidden);
var exportChanged = request.Export != StaticSettings.Config.Export;
StaticSettings.Config.Export = request.Export;
StaticSettings.Config.UseAuth = request.UseAuth;
Expand Down
18 changes: 9 additions & 9 deletions MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net;
using MaiChartManager.Platform;
using Microsoft.AspNetCore.Mvc;

Expand All @@ -8,22 +9,23 @@ namespace MaiChartManager.Controllers.AssetDir;
// 所以前端没法在浏览器侧拿到目录内容。改为:后端弹原生选文件夹对话框,再通过下面 3 个接口
// 把所选目录的内容提供给前端的 ImportDirectory 适配器(见 Front/src/utils/httpImportDirectory.ts)。
//
// 安全说明:这些接口可以读取任意本地路径,仅限「本地桌面 + 仅 loopback」场景使用。
// 切勿在 export / 远程模式下启用——否则等于把整个文件系统暴露给局域网。
// 因此每个接口都先校验 !StaticSettings.Config.Export。
// 安全说明:这些接口可以读取任意本地路径,仅限 loopback 连接使用。
// 授权必须依据实际连接来源,不能依赖可变的 export 配置,否则模式切换期间会产生竞态窗口。
[ApiController]
[Route("MaiChartManagerServlet/[action]Api")]
public class ImportBrowseController(IDesktopDialogService dialogService, ILogger<ImportBrowseController> logger) : ControllerBase
{
private bool IsLoopbackRequest()
=> HttpContext.Connection.RemoteIpAddress is { } remoteIp && IPAddress.IsLoopback(remoteIp);

// 子项列表的返回结构:name 显示名,path 子项绝对路径,isDirectory 是否为目录
public record ImportDirEntry(string Name, string Path, bool IsDirectory);

// 弹原生选文件夹对话框,返回选中的绝对路径;取消返回 null
[HttpGet]
public ActionResult<string?> PickImportFolder()
{
// 仅本地桌面场景,export / 远程模式下禁止
if (StaticSettings.Config.Export) return Forbid();
if (!IsLoopbackRequest()) return StatusCode(StatusCodes.Status403Forbidden);
var path = dialogService.PickFolder();
logger.LogInformation("PickImportFolder: {path}", path);
// 取消时 PickFolder 返回 null,这里原样返回(前端按取消处理)
Expand All @@ -34,8 +36,7 @@ public record ImportDirEntry(string Name, string Path, bool IsDirectory);
[HttpGet]
public ActionResult<IEnumerable<ImportDirEntry>> ListImportDir([FromQuery] string path)
{
// 仅本地桌面场景,export / 远程模式下禁止
if (StaticSettings.Config.Export) return Forbid();
if (!IsLoopbackRequest()) return StatusCode(StatusCodes.Status403Forbidden);
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
{
return Ok(Array.Empty<ImportDirEntry>());
Expand Down Expand Up @@ -63,8 +64,7 @@ public ActionResult<IEnumerable<ImportDirEntry>> ListImportDir([FromQuery] strin
[HttpGet]
public IActionResult ReadImportFile([FromQuery] string path, [FromQuery] string? name = null)
{
// 仅本地桌面场景,export / 远程模式下禁止
if (StaticSettings.Config.Export) return Forbid();
if (!IsLoopbackRequest()) return StatusCode(StatusCodes.Status403Forbidden);
var fullPath = string.IsNullOrEmpty(name) ? path : Path.Combine(path, name);
if (string.IsNullOrEmpty(fullPath) || !System.IO.File.Exists(fullPath))
{
Expand Down
9 changes: 6 additions & 3 deletions MaiChartManager/Front/src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ export const getUrl = (suffix: string) => {
return `${base}/MaiChartManagerServlet/${suffix}`;
}

// 是否运行在 Photino(WebKitGTK) 宿主:本地宿主但不是 Windows WebView2。
// WebKitGTK 不支持 window.open 弹新窗口,需要走后端用系统浏览器打开。
export const isPhotino = isLocalHost && !isWebView;
// 是否运行在 Photino(WebKitGTK) 宿主。
// 不能仅用 isLocalHost && !isWebView:因为Export 模式下用本机浏览器访问 localhost 也满足该条件。
// Photino 暴露 window.external.sendMessage(见 PreviewChartButton),普通浏览器没有。
export const isPhotino =
isLocalHost && !isWebView &&
typeof (window as any).external?.sendMessage === 'function';

// 用系统浏览器打开一个 http/https URL(后端 xdg-open 等)。
// 给 Photino 用:WebKitGTK 弹不出 window.open 的新窗口,预览谱面等改为外部浏览器打开。
Expand Down
13 changes: 11 additions & 2 deletions MaiChartManager/Front/src/client/apiGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export enum VerifyStatus {
Valid = "Valid",
}

export enum StorePurchaseStatus {
Succeeded = "Succeeded",
AlreadyPurchased = "AlreadyPurchased",
NotPurchased = "NotPurchased",
NetworkError = "NetworkError",
ServerError = "ServerError",
}

export enum ShiftMethod {
Legacy = "Legacy",
Bar = "Bar",
Expand Down Expand Up @@ -70,6 +78,8 @@ export interface AppVersionResult {
hardwareAcceleration?: HardwareAccelerationStatus;
h264Encoder?: string | null;
locale?: string | null;
platform?: string | null;
export?: boolean;
}

export interface AudioPreviewTime {
Expand Down Expand Up @@ -385,8 +395,7 @@ export interface RequestExportMaidataRequest {

export interface RequestPurchaseResult {
errorMessage?: string | null;
/** @format int32 */
status?: number;
status?: StorePurchaseStatus;
}

export interface Section {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { computed, defineComponent, PropType, useId, watch } from "vue";
import { Button, CheckBox, Modal, NumberInput, Popover, Section, Select } from "@munet/ui";
import type { ImportChartMessageEx, ImportMeta, SavedOptions, TempOptions } from "./types";
import noJacket from '@/assets/noJacket.webp';
import { addVersionList, genreList, showNeedPurchaseDialog } from "@/store/refs";
import { addVersionList, genreList, showNeedPurchaseDialog, version } from "@/store/refs";
import GenreInput from "@/components/GenreInput";
import VersionInput from "@/components/VersionInput";
import { UTAGE_GENRE } from "@/consts";
Expand Down Expand Up @@ -76,15 +76,15 @@ export default defineComponent({
</CheckBox>
<Section title={t('chart.import.option.advancedOptions')}>
<ShiftModeSelector tempOptions={props.tempOptions}></ShiftModeSelector>
<div class="flex items-center gap-1" style="margin-top: 0.25rem">
{version.value?.platform === 'Windows' && <div class="flex items-center gap-1" style="margin-top: 0.25rem">
<CheckBox v-model:value={props.tempOptions.ignoreGapless}>{t('chart.import.option.ignoreGapless')}</CheckBox>
<Popover trigger="hover">
{{
trigger: () => <div class="i-material-symbols:info-outline-rounded op-50"/>,
default: () => <div class="max-w-60">{t('chart.import.option.ignoreGaplessTip')}</div>
}}
</Popover>
</div>
</div>}
</Section>
</>}
</div>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,4 @@ export const getCaptureTarget = (errorValue: unknown): unknown => {
return errorValue.error;
};

export const isAbortError = (errorValue: unknown): boolean =>
errorValue instanceof DOMException && errorValue.name === "AbortError" ||
errorValue instanceof Error && errorValue.name === "AbortError";
export const isAbortError = (errorValue: any): boolean => errorValue?.name === "AbortError"
9 changes: 5 additions & 4 deletions MaiChartManager/LinuxProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@ public static async Task Main(string[] args)
InitConfiguration();
ConfigureFfmpeg();

// 启动进程内 Kestrel:loopback + 伺服 SPA(wwwroot)+ API 同源,但不开 LAN 端口。
// Kestrel 在后台线程运行(StartApp 内部 Task.Run),主线程留给 Photino 开窗。
var exportMode = StaticSettings.Config.Export;
var serverReady = new ManualResetEventSlim(false);
string? backendUrl = null;
var serverTask = ServerManager.StartApp(export: false, serveSpa: true, onStart: url =>
var serverTask = ServerManager.StartApp(export: exportMode, serveSpa: true, onStart: url =>
{
backendUrl = url;
serverReady.Set();
Expand All @@ -38,7 +37,9 @@ public static async Task Main(string[] args)
// 决定初始路由(对齐 Windows AppMain 的逻辑):
// 未配置有效游戏目录时加载 OOBE 引导页(#/oobe),否则加载主界面(根路由)。
// 直接加载主界面会让 SPA 立刻调用依赖 GamePath 的接口,导致一连串异常。
var startUrl = string.IsNullOrEmpty(StaticSettings.GamePath)
var startUrl = exportMode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: startUrl 现在是一个三层嵌套的三元表达式(exportMode → #/server、GamePath → #/oobe、默认 → 后端地址),可读性较差,且新增的 export 分支与其余分支意义不同(前者指向 /server 运行页,后两者是引导页/主页),很容易在后续维护时误改。建议拆成 if/else 或先赋值再按模式覆盖,让各分支意图一目了然。

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At MaiChartManager/LinuxProgram.cs, line 40:

<comment>`startUrl` 现在是一个三层嵌套的三元表达式(exportMode → #/server、GamePath → #/oobe、默认 → 后端地址),可读性较差,且新增的 export 分支与其余分支意义不同(前者指向 /server 运行页,后两者是引导页/主页),很容易在后续维护时误改。建议拆成 if/else 或先赋值再按模式覆盖,让各分支意图一目了然。</comment>

<file context>
@@ -38,7 +37,9 @@ public static async Task Main(string[] args)
         // 未配置有效游戏目录时加载 OOBE 引导页(#/oobe),否则加载主界面(根路由)。
         // 直接加载主界面会让 SPA 立刻调用依赖 GamePath 的接口,导致一连串异常。
-        var startUrl = string.IsNullOrEmpty(StaticSettings.GamePath)
+        var startUrl = exportMode
+            ? $"{backendUrl.TrimEnd('/')}/#/server"
+            : string.IsNullOrEmpty(StaticSettings.GamePath)
</file context>

? $"{backendUrl.TrimEnd('/')}/#/server"
: string.IsNullOrEmpty(StaticSettings.GamePath)
? $"{backendUrl.TrimEnd('/')}/#/oobe"
: backendUrl;

Expand Down
56 changes: 28 additions & 28 deletions MaiChartManager/Locale.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion MaiChartManager/Locale.resx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ If you notice any issues with the conversion result, you can try testing it in A
<value>A chart with difficulty {0} will be ignored</value>
</data>
<data name="MusicNoCharts" xml:space="preserve">
<value>Music has no charts</value>
<value>Music has no valid charts</value>
</data>
<data name="ChartInvalidMeasure" xml:space="preserve">
<value>Chart difficulty {0} contains {1}-note division, this value cannot exceed 384. Most cases can be fixed by modifying the chart</value>
Expand Down
2 changes: 1 addition & 1 deletion MaiChartManager/Locale.zh-Hans.resx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@
<value>有一个难度为 {0} 的谱面将被忽略</value>
</data>
<data name="MusicNoCharts" xml:space="preserve">
<value>乐曲没有谱面</value>
<value>乐曲中没有有效的谱面</value>
</data>
<data name="ChartInvalidMeasure" xml:space="preserve">
<value>谱面难度 {0} 存在 {1} 分音符,这个数值不能大于 384。绝大多数这样的情况都是可以修改谱面解决的</value>
Expand Down
2 changes: 1 addition & 1 deletion MaiChartManager/Locale.zh-Hant.resx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@
<value>有一個難度為 {0} 的譜面將被忽略</value>
</data>
<data name="MusicNoCharts" xml:space="preserve">
<value>樂曲沒有譜面</value>
<value>樂曲中沒有有效的譜面</value>
</data>
<data name="ChartInvalidMeasure" xml:space="preserve">
<value>譜面難度 {0} 存在 {1} 分音符,這個數值不能大於 384。絕大多數這樣的情況都是可以修改譜面解決的</value>
Expand Down
17 changes: 12 additions & 5 deletions MaiChartManager/Utils/Audio.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ 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(ConvertToWavViaFfmpeg(src, ".mp3")), // 默认情况下,优先使用ffmpeg
// WAV / WMA / AAC(以及 MP3+forceUseNAudio 的兼容模式)原本走 Windows-only 的 MediaFoundation,
// 跨平台改为用 ffmpeg 把任意输入解码成 16bit PCM wav,再用 NAudio WaveFileReader 读取。
// MP3 兼容模式(ignoreGapless):仅在 Windows 上用 MediaFoundation 解码,
// 这会忽略 MP3 Gapless 元数据,从而表现与 Visual Maimai 等软件一致的行为。
// Linux 上无 MediaFoundation,因此兼容模式不可用(不管开不开启兼容模式,都一定会落到下面的ffmpeg逻辑中)
".mp3" when forceUseNAudio && SupportsMp3CompatibilityMode => new StreamMediaFoundationReader(src),
// 一般情况(MP3 默认、WAV、WMA、AAC 等):走 ffmpeg 解码为 16bit PCM wav。
_ => new WaveFileReader(ConvertToWavViaFfmpeg(src, extension)),
};
// 关于上述MP3 Gapless问题的影响等具体讨论,详见 https://github.com/MuNET-OSS/MaiChartManager/issues/40
Expand Down Expand Up @@ -99,16 +101,21 @@ public static Stream ConvertToWav(Stream src, string extension, float padding =
stream.Position = 0;
return stream;
}

/// <summary>MP3 兼容模式(ignoreGapless)依赖 Windows MediaFoundation,Linux 上不可用。</summary>
public static bool SupportsMp3CompatibilityMode => OperatingSystem.IsWindows();

// 用 ffmpeg 把任意输入流(按 ext 写到临时文件)解码成 16bit PCM wav,返回 wav 的内存流。
// 替代 Windows-only 的 MediaFoundation,跨平台可用(系统 ffmpeg 已配好)。
private static MemoryStream ConvertToWavViaFfmpeg(Stream src, string ext)
{
var tempFileGuid = Guid.NewGuid();
// ext 形如 ".mp3"/".wav"/".aac" 等;去掉前导点用作临时输入文件后缀
var inputExt = string.IsNullOrEmpty(ext) ? "" : (ext.StartsWith('.') ? ext : "." + ext);

var tempFileGuid = Guid.NewGuid();
// 输入/输出须用不同文件名,以防输入也是 .wav时,输入输出相同文件名导致报错。
var inputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}{inputExt}");
var outputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}.wav");
var outputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}_out.wav");
try
{
Directory.CreateDirectory(StaticSettings.tempPath);
Expand Down
Loading