Skip to content

Linux 原生支持(Photino + 跨平台音视频/CLI/打包)#69

Open
clansty wants to merge 50 commits into
mainfrom
feat/linux-photino-native
Open

Linux 原生支持(Photino + 跨平台音视频/CLI/打包)#69
clansty wants to merge 50 commits into
mainfrom
feat/linux-photino-native

Conversation

@clansty

@clansty clansty commented Jun 19, 2026

Copy link
Copy Markdown
Member

概述

让 MaiChartManager 原生运行在 Linux 上(Windows 行为保持不变)。界面本就是 Vue SPA,无需"提取";真正要做的是替换 Windows 外壳、让 ASP.NET Core 后端在 Linux 构建运行,并把音视频等原生依赖跨平台化。

通过 Configuration 驱动条件编译实现单项目双平台:LinuxDebug/LinuxReleasenet10.0,Windows 配置 → net10.0-windows + WINDOWS 宏。

主要改动

宿主与平台抽象

  • Photino.NET 作 Linux 窗口宿主(底层 WebKitGTK),进程内 Kestrel 同源伺服 SPA,替换 WinForms + WebView2。
  • 平台抽象层:IDesktopDialogService / IShellService / ITaskbarProgress / IAppShell / IProgressController,Windows 用 WinForms 实现、Linux 用 Photino/无头实现。
  • WebKitGTK 无 File System Access API → 后端原生对话框 + HTTP 目录浏览。

贴图/AssetBundle

  • 贴图解码改用 AssetsTools.NET.Texture(纯托管),彻底移除 AssetStudio 依赖(含 native Texture2DDecoderNative 与相关 Libs 二进制)。
  • 修 AB 文件名大小写(Linux 大小写敏感)。

音频

  • 从 XV2-Tools fork 抽出 AcbCore(netstandard2.0,含 ACB + VGAudio),替代原 net47 大库,两平台共用。
  • MediaFoundation → ffmpeg 跨平台解码。

视频 / FFmpeg

  • Xabe.FFmpeg → FFMpegCore 全量迁移并移除 Xabe(Xabe 在 Linux 有路径加引号 bug)。
  • 硬件视频编码:带 device 初始化的 VAAPI/NVENC/QSV 探测(编码器 profile 抽象),不可用自动回退软件;软件专属参数不再泄漏给硬件编码器。
  • -hwaccel dxva2(Windows 专有)在 Linux 导致转换中断的问题。
  • ffmpeg/ffprobe 路径统一走 GlobalFFOptions,不再误用随 Windows 构建拷入的 .exe

CLI / MuConvert

  • mcm(MaiChartManager CLI)与 muconvert 跨平台可用;maichartmanager 启动 GUI。
  • MuConvert 不再被强制构建成 Windows .exe(仅 Windows 打包时才自包含)。

打包

  • 新增 Arch Linux PKGBUILD:框架依赖发布(依赖系统 aspnet-runtime + webkit2gtk-4.1 + ffmpeg),装 maichartmanager/mcm/muconvert 三个命令、.desktop 与图标。
  • 版本号从 git tag 注入(对齐 Build.ps1)。

杂项修复

  • Locale.zh-hans/zh-hant.resx 区域名大小写规范化(Linux 卫星目录冲突 + 中文本地化失效)。
  • 修 GUI 启动卡 25 秒:显式指定 ContentRoot 为 exeDir(默认用 cwd,从 HOME 启动会扫描海量文件)。
  • 一批反斜杠路径字面量等跨平台 bug。

Windows 影响

设计上 Windows 走原有路径、逐参数等价。需在 Windows 上编译 + 跑一遍回归验证(音视频转换、导入、打包)。

备注

  • MuConvert 子模块的 resx 修复在其仓库分支 fix/linux-resx-case(commit ae6aeba),本 PR pin 到该 commit,可后续合入 MuConvert master。
  • 详细设计/计划见 .omo/drafts/(未提交)。

🤖 Generated with Claude Code

Review in cubic

clansty and others added 30 commits June 18, 2026 06:59
…dio on Linux

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…f WinForms

- csproj: <Compile Remove> 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) <noreply@anthropic.com>
…eadless on Linux); restore batch-export progress+cancel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GamePath 默认空串而非 null,AssetsDirs 在 StreamingAssets 不存在时返回空,避免 Path.Combine/枚举抛异常
- LinuxProgram 未配置游戏目录时加载 #/oobe 引导页(对齐 Windows AppMain 逻辑)
- PhotinoAppShell 替换 HeadlessAppShell:用 PhotinoWindowHolder 导航单窗口,使 OOBE 完成后能切到主界面
- 新增 PlatformFile.cs(上一提交迁移了调用点但漏提交了类本身,导致分支无法编译)
- StaticSettings 扫描用 ResolveSubDir 大小写不敏感解析子目录,去掉 Linux 区分大小写的 glob
- PathUtils.ResolveIgnoreCase 逐段大小写不敏感解析路径
- VersionXml/GenreXml 的 FilePath 用 ResolveIgnoreCase,删除改用 PlatformFile
…ost 走后端原生对话框,消除不支持浏览器警告

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tGTK 下导出

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ry 适配器,支持 WebKitGTK

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g.exe 失败;并在 Linux 启动时检测硬件加速

(本提交同时包含此前未提交的窗口尺寸 1600x800 改动)
…oryPicker/webkitdirectory)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- LinuxProgram 注册 WebMessage handler,收到 open-window 消息时开一个 PhotinoWindow 子窗口加载预览页
- 前端 PreviewChartButton 在 Photino(isPhotino) 下走 window.external.sendMessage 而非 window.open
- 附带新增 ShellController.OpenExternalUrl(系统浏览器打开 http/https,备外部链接用)
clansty and others added 20 commits June 19, 2026 12:51
…),MediaFoundation 换 ffmpeg

- 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 <noreply@anthropic.com>
…传给 ffmpeg 导致的 'Couldn't initialize muxer'

- ConvertToWavViaFfmpeg / ConvertMp4AudioToWavViaFfmpeg 不再走 Xabe,直接 Process.Start
- ArgumentList 每个参数独立传递,跨平台正确处理带空格路径、无引号问题
- FfmpegExePath: Windows 用内置 ffmpeg.exe,Linux 从 PATH 解析系统 ffmpeg

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
1) UsmCreator 自带的 ffprobe/ffmpeg 解析器硬编码 .exe 路径并用 File.Exists
   命中,在 Linux 上会命中随 Windows 构建拷进输出目录的 ffprobe.exe(PE 文件
   无执行权限)→ 启动报 EACCES(权限不够)。改为复用 FFMpegCore 的
   GlobalFFOptions.GetFF*BinaryPath(单一来源,按 OS 自动补后缀、尊重 BinaryFolder)。

2) FFMpegCore 的 NotifyOnProgress 只保留最后一次注册的回调(不像 Xabe 事件可叠加)。
   AttachProgress 先注册前端进度、再注册任务栏进度,后者把前者覆盖;而任务栏回调在
   Linux 是 #if WINDOWS 空实现,导致前端进度恒为 0。合并为单个回调解决(Windows 同样受益)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
这些 PE 文件在 Linux 上无用,且会被 File.Exists 命中导致误 exec(EACCES)。
给 <None Update="FFMpeg\**"> 加 IsLinuxBuild 条件,仅 Windows 复制。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CheckHardwareAcceleration 改为调用 VideoEncoderProbe(带 device 初始化的完整探测)
- 软件专属参数 -cpu-used/-pix_fmt 只对软件编码器发出,不再泄漏给硬件编码器
- 硬件 profile 注入 -vaapi_device + format=nv12,hwupload(含 concat 的 filter_complex)
- H264Encoder 改为暴露选中 profile 名,保持前端 API 契约
- DEBUG 下 dump [ffargs] 便于核对生成的 ffmpeg 参数

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AB 包/texture 处理已全面改用 AssetsTools.NET(.Texture) 跨平台路径
(ImageConvert 用 TextureFile.DecodeManagedData 纯托管解码,AssetBundleCreator
用 AssetsTools.NET),全仓库零处 using AssetStudio。Windows 原本通过 Libs 引用
AssetStudio/AssetStudioUtility 与复制 Texture2DDecoderNative.dll 已是死依赖。

- 删 AssetStudio/AssetStudioUtility 引用与对应 Libs/x64 二进制
- 删 Texture2DDecoderNative.dll 复制
- Mono.Cecil 两平台统一走 NuGet 0.11.6(原 Windows 用 Libs\Mono.Cecil.dll)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
这些都已无任何 csproj/代码引用:WinBlur 早已不用,K4os/Mono.Cecil.* 是
AssetStudio 与旧 Cecil 的捆绑依赖,现 Mono.Cecil 走 NuGet。一并清掉 csproj 里
已失效的 Libs\** Remove 指令。Libs 目录清空。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Linux 发布改为框架依赖(SelfContained=false + 关 ReadyToRun),依赖系统
  aspnet-runtime,不打包运行时;保留 RID 以带上 Photino.Native.so 原生库。
- 关掉 StaticWebAssetsEnabled:发布用 UseFileServer 显式伺服 wwwroot,
  不需要静态资产清单(也避免其 endpoints.json 干扰)。
- 把 Locale.zh-hans/zh-hant.resx 重命名为规范大小写 zh-Hans/zh-Hant:
  原小写在大小写敏感的 Linux 上有两个问题——(1) 运行时 CultureInfo("zh-Hans")
  探测规范大小写目录,找不到小写卫星目录,中文后端本地化失效;(2) 发布时与
  System.CommandLine 的规范大小写卫星目录冲突导致 MSBuild 复制失败、publish 中断。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
从本地含 submodule 的工作树构建:前端 vite build → wwwroot,后端框架依赖
publish 到 /opt/maichartmanager,附 /usr/bin 启动器与 .desktop。
运行时依赖 aspnet-runtime + webkit2gtk-4.1 + ffmpeg;图标复用 Windows appx 的
Square 256x256 装进 hicolor 主题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
之前 Linux 上 AppVersionController 返回的版本来自空的 AssemblyVersion,显示不对。
- AssemblyInfo.cs 的版本特性原本全在 #if WINDOWS 内,Linux 不发;改为两平台都发,
  统一从 AppMain.Version 取(与 Windows 一致)。
- Linux 构建不再排除 AppMain.g.cs(其 Version 常量供 AssemblyInfo 使用)。
- AppVersionController(Linux) 改为读 AssemblyInformationalVersion,与 Windows 的
  Application.ProductVersion 语义一致。
- PKGBUILD 像 Build.ps1 一样从 git describe --tags 重写 AppMain.g.cs 的 Version
  (Release 取 tag 基号,如 26.3)。
另:PKGBUILD 前端改用 pnpm(workspace + @munet/ui submodule),图标用 appx 的
Square 256x256。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MuConvert:
- 之前随主项目发布产出 Windows PE 的 MuConvert.exe——根因是 Directory.Build.props
  无条件给 MuConvert 设 win-x64+SelfContained(原为 Linux 上跑 Wine 测 Windows 版)。
  改为仅 Windows 构建时才强制(保留 Windows 打包进自包含 exe 的需求),Linux 原生
  按 linux-x64 框架依赖构建出 ELF。去掉无用的 EnableWindowsTargeting。
- 子模块修 Locale.zh-hant.resx 大小写(同主项目的 Linux 卫星目录冲突/本地化问题)。

MaiChartManager.CLI:
- csproj 跨平台化(IsLinuxBuild:Windows=net*-windows 自包含 win-x64,Linux=net10.0
  框架依赖 linux-x64),Program.cs/DebugCommand/AssemblyInfo 加 #if WINDOWS 守卫
  (AppMain/IapManager/Program.Main/WebView2 STA 仅 Windows;Linux 走 LinuxProgram)。
- LinuxProgram.InitConfiguration/ConfigureFfmpeg 公开供 CLI 复用。

打包:PKGBUILD 改为发布 CLI 项目(超集,含 GUI/CLI/MuConvert/wwwroot),
装三个命令 maichartmanager(GUI) / mcm(CLI) / muconvert。

启动性能:重开 PublishReadyToRun(之前因卫星 crossgen 报错关掉,那其实是 zh-Hant
大小写冲突,已修)。CLI 冷启动从全量 JIT 降到 ~0.15s,GUI 启动同样受益。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
上一提交因 .gitignore 忽略而漏加,导致文件被误删。-f 强制加回。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WebApplication.CreateBuilder 默认用 cwd 当 ContentRoot,从用户 HOME 启动时
host 会对 HOME 海量文件做文件监视/扫描,CreateBuilder 卡 25 秒+。显式指向
exeDir 解决(dotnet run 从项目目录启动所以不慢,掩盖了这个问题)。
wwwroot 伺服走独立 PhysicalFileProvider,不受影响。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

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.

Sorry @clansty, your pull request is larger than the review limit of 150000 diff characters

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request adds native Linux support to MaiChartManager by introducing cross-platform abstractions for UI shells, dialogs, and file operations, migrating the media conversion engine from Xabe.FFmpeg to FFMpegCore, and replacing AssetStudio with AssetsTools.NET. The code review identified several critical issues, including an argument injection vulnerability in the Linux shell service, potential resource leaks from undisposed ManualResetEventSlim and MemoryStream instances, and temporary file leaks during failed FFmpeg conversions. Additionally, feedback was provided to address potential infinite recursion when copying directories, fragile path segment matching, unhandled permission exceptions during directory enumeration, and premature cancellation triggers in the file picker.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +21 to +31
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

UseShellExecute = false 时,手动使用双引号包裹参数 $"\"{arg}\"" 容易导致参数注入(Argument Injection)漏洞,并且如果 URL 或路径本身包含双引号,也会导致解析失败。建议使用 ProcessStartInfoArgumentList 属性来安全地传递参数,这样 .NET 会自动处理所有转义和安全隔离。

    private void XdgOpen(string arg)
    {
        try
        {
            Process.Start(new ProcessStartInfo("xdg-open")
            {
                ArgumentList = { arg },
                UseShellExecute = false
            });
        }
        catch (Exception e)
        {
            logger.LogWarning(e, "Failed to xdg-open {Arg}", arg);
        }
    }
References
  1. Before flagging a shell injection vulnerability, confirm that a shell is actually being used to execute the command. If UseShellExecute is false, the command is not vulnerable to shell injection, although argument injection might still be possible if parameters are not escaped correctly.

Comment on lines +233 to 252
private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path)
{
Directory.CreateDirectory(StaticSettings.tempPath);
var outputPath = Path.Combine(StaticSettings.tempPath, $"ExtractMp4Audio_{Guid.NewGuid():N}.wav");

// 用 FFMpegCore 把 mp4(或其它视频容器)里的音频流解码成 16bit PCM wav。
// 等价命令行:ffmpeg -y -i <mp4> -vn -c:a pcm_s16le <output>
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)
{
File.Delete(outputPath);
throw new InvalidOperationException("ffmpeg produced empty wav file from mp4 input.");
}

return outputPath;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

如果 FFMpegArguments...ProcessSynchronously() 执行失败抛出异常,临时文件 outputPath 将不会被删除,从而导致临时文件泄露。建议使用 try-catch 块包裹执行过程,在捕获到异常时确保清理已创建的临时文件。

    private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path)
    {
        Directory.CreateDirectory(StaticSettings.tempPath);
        var outputPath = Path.Combine(StaticSettings.tempPath, $"ExtractMp4Audio_{Guid.NewGuid():N}.wav");

        try
        {
            // 用 FFMpegCore 把 mp4(或其它视频容器)里的音频流解码成 16bit PCM wav。
            // 等价命令行:ffmpeg -y -i <mp4> -vn -c:a pcm_s16le <output>
            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)
            {
                throw new InvalidOperationException("ffmpeg produced empty wav file from mp4 input.");
            }

            return outputPath;
        }
        catch
        {
            if (File.Exists(outputPath))
            {
                try { File.Delete(outputPath); } catch { }
            }
            throw;
        }
    }
References
  1. Avoid deleting shared cached files immediately after use, especially in concurrent environments. Instead, implement a global cache cleanup strategy or use non-cached temporary files to prevent race conditions and data corruption.


// 启动进程内 Kestrel:loopback + 伺服 SPA(wwwroot)+ API 同源,但不开 LAN 端口。
// Kestrel 在后台线程运行(StartApp 内部 Task.Run),主线程留给 Photino 开窗。
var serverReady = new ManualResetEventSlim(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

ManualResetEventSlim 实现了 IDisposable 接口。虽然在某些情况下不释放它不会立即导致严重问题,但显式使用 using 释放它是更好的资源管理实践,能避免潜在的句柄泄露。

        using var serverReady = new ManualResetEventSlim(false);

}

string[]? result = null;
var done = new ManualResetEventSlim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

在此文件中的多个方法(如 PickFolderPickFileConfirmShowError)中,创建的 ManualResetEventSlim 实例均未被释放。建议使用 using var done = new ManualResetEventSlim(); 确保其被正确释放,避免潜在的句柄泄露。

        using var done = new ManualResetEventSlim();

Comment on lines +6 to +8
public static bool ContainsSegment(string? path, string segment)
=> path is not null &&
path.Replace('\\', '/').Contains($"/{segment}/", System.StringComparison.InvariantCultureIgnoreCase);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

当前的 ContainsSegment 实现使用 Replace('\\', '/').Contains($"/{segment}/", ...),这在处理相对路径(如 A000\\foo.png,没有前导斜杠)或路径以该段结尾(如 C:\\foo\\A000,没有后续斜杠)时会匹配失败。建议使用更健壮的路径分割和匹配逻辑。

    public static bool ContainsSegment(string? path, string segment)
    {
        if (path is null) return false;
        var parts = path.Split(new[] { '/', '\\' }, System.StringSplitOptions.RemoveEmptyEntries);
        return parts.Any(p => string.Equals(p, segment, System.StringComparison.OrdinalIgnoreCase));
    }

Comment on lines +10 to +13
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

使用 window focus 兜底判定取消文件选择存在一个已知问题:如果用户在文件选择框打开时切换到其他应用,然后再切换回浏览器,窗口会重新获得焦点,从而提前触发取消逻辑,导致后续选择文件失效。现代浏览器 and WebKit 内核已原生支持 cancel 事件,建议结合使用 cancel 事件以提供更可靠的取消判定。

Suggested change
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 });
let settled = false;
input.addEventListener('change', () => { settled = true; resolve(input.files?.[0] ?? null); input.remove(); });
input.addEventListener('cancel', () => { settled = true; resolve(null); input.remove(); });
// 取消时多数内核不触发 change;用 window focus 兜底判定取消
window.addEventListener('focus', () => setTimeout(() => { if (!settled) { resolve(null); input.remove(); } }, 500), { once: true });

Comment on lines +57 to +64
{
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

如果目标目录 dest 是源目录 source 的子目录(例如将 C:\\foo 复制到 C:\\foo\\bar),递归调用 CopyDirectory 会导致无限循环,最终导致磁盘写满或栈溢出崩溃。建议在复制前进行路径层级层级校验,防止无限递归。

    public static void CopyDirectory(string source, string dest, bool overwrite = true)
    {
        var sourceReal = Path.GetFullPath(source);
        var destReal = Path.GetFullPath(dest);

        if (string.Equals(sourceReal, destReal, StringComparison.OrdinalIgnoreCase))
        {
            return; // 相同目录,无需复制
        }

        // 避免无限递归:如果目标目录是源目录的子目录
        if (destReal.StartsWith(sourceReal + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
            destReal.StartsWith(sourceReal + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
        {
            throw new IOException("Cannot copy a directory into a subdirectory of itself.");
        }

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

Comment on lines +161 to +173
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

当输入流 s 不是 MemoryStream 时,新创建的 MemoryStream ms 在方法结束时没有被释放。建议使用 C# 的 using 声明来优雅地管理临时 MemoryStream 的生命周期,确保其被正确释放。

        using var tempMs = s is MemoryStream ? null : new MemoryStream();
        if (tempMs != null)
        {
            s.CopyTo(tempMs);
            tempMs.Position = 0;
        }
        MemoryStream ms = (s as MemoryStream) ?? tempMs!;

        byte[] track = ConvertStream.ConvertFile(options, ms, encodeType, convertToType);

Comment on lines +44 to +55
var result = new List<ImportDirEntry>();
// 子目录
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

如果用户选择的导入目录中包含某些受系统保护或无权限访问的子目录/文件,Directory.EnumerateDirectoriesEnumerateFiles 会抛出 UnauthorizedAccessException,导致整个 API 请求返回 500 错误。建议使用 try-catch 块包裹枚举过程,以便在遇到权限问题时能优雅降级并记录警告,而不是直接崩溃。

        var result = new List<ImportDirEntry>();
        try
        {
            // 子目录
            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));
            }
        }
        catch (Exception e)
        {
            logger.LogWarning(e, "读取导入目录失败:{Path}", path);
        }
        return Ok(result);

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

22 issues found across 103 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="MaiChartManager/Controllers/Music/MovieConvertController.cs">

<violation number="1" location="MaiChartManager/Controllers/Music/MovieConvertController.cs:33">
P1: Unsanitized `assetDir` is used in file-system write paths, enabling path traversal outside `StreamingAssets`. Validate/canonicalize combined path and reject values that resolve outside the allowed root.</violation>
</file>

<file name="MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs">

<violation number="1" location="MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs:26">
P1: Security gate is incomplete: endpoints trust `Export` flag but do not enforce loopback caller. Add remote-IP loopback check to prevent accidental LAN exposure of filesystem APIs.</violation>

<violation number="2" location="MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs:46">
P2: Handle directory enumeration exceptions here; inaccessible entries currently bubble out and can turn this endpoint into a 500.</violation>
</file>

<file name="MaiChartManager/Utils/Audio.cs">

<violation number="1" location="MaiChartManager/Utils/Audio.cs:125">
P2: Wrap ffmpeg execution with failure cleanup so temporary WAV files are deleted when conversion throws.</violation>
</file>

<file name="MaiChartManager/LinuxProgram.cs">

<violation number="1" location="MaiChartManager/LinuxProgram.cs:20">
P3: Dispose `ManualResetEventSlim` after startup synchronization to release its wait handle.</violation>
</file>

<file name="MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx">

<violation number="1" location="MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx:83">
P2: Local-host maidata export drops ById/subdir semantics; ConvertToMaidataById and subdir selection are ignored in this new path. This causes inconsistent export structure versus the non-local path.</violation>

<violation number="2" location="MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx:87">
P2: Canceling native folder selection can still show “export success” and close the action step. Users get a false-success result for a no-op export.</violation>
</file>

<file name="MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts">

<violation number="1" location="MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts:59">
P3: 新增的 webkitDirectory 适配器当前未被任何代码路径使用,形成死代码并增加维护负担。应删除该实现或在实际目录选择流程中接入调用。</violation>
</file>

<file name="MaiChartManager/Front/src/utils/httpImportDirectory.ts">

<violation number="1" location="MaiChartManager/Front/src/utils/httpImportDirectory.ts:54">
P2: Directory listing HTTP errors are silently swallowed, returning an empty iterator. This can cause import logic to behave as if the folder is empty instead of surfacing a real failure.</violation>
</file>

<file name="MaiChartManager/Controllers/App/LocaleController.cs">

<violation number="1" location="MaiChartManager/Controllers/App/LocaleController.cs:22">
P2: Invalid locale input is handled by throwing an exception, producing error-path responses instead of a client validation response. Return `BadRequest`/`ValidationProblem` for unsupported locale values.</violation>

<violation number="2" location="MaiChartManager/Controllers/App/LocaleController.cs:25">
P2: Locale application logic is duplicated across controller/startup paths, increasing drift risk when locale rules change. Centralize this block in one shared method/service and call it here.</violation>
</file>

<file name="MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx">

<violation number="1" location="MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx:41">
P2: 500ms delay before overlays hide when user cancels file picker. `pickFile`'s cancellation detection uses a `focus` event listener with 500ms setTimeout, making the BottomOverlay linger after the user already dismissed the dialog. To remove the delay, set `tipShow.value = false` before awaiting `pickFile` (the native dialog itself is enough visual feedback).</violation>
</file>

<file name="MaiChartManager/Controllers/App/ShellController.cs">

<violation number="1" location="MaiChartManager/Controllers/App/ShellController.cs:30">
P2: API reports success even when URL opening fails on Linux. This breaks frontend error handling and hides real launch failures.</violation>

<violation number="2" location="MaiChartManager/Controllers/App/ShellController.cs:35">
P2: 500 response leaks raw exception message to callers. Return a generic error body and keep details only in logs.</violation>
</file>

<file name="MaiChartManager/Front/src/client/api.ts">

<violation number="1" location="MaiChartManager/Front/src/client/api.ts:48">
P2: Photino detection is too broad and misclassifies regular localhost browsers, causing preview open to call a non-existent host bridge.</violation>
</file>

<file name="MaiChartManager/Front/src/utils/pickFile.ts">

<violation number="1" location="MaiChartManager/Front/src/utils/pickFile.ts:13">
P3: Focus-based cancel fallback can leave dangling Promise and leaked DOM element. If `window.focus` doesn't fire after cancel, `input` stays in DOM and Promise never resolves.</violation>
</file>

<file name="MaiChartManager/Utils/PathUtils.cs">

<violation number="1" location="MaiChartManager/Utils/PathUtils.cs:8">
P1: This segment check misses path-boundary cases like `A000/foo` and `.../A000`; match by path segments instead of searching for `"/{segment}/"`.</violation>
</file>

<file name="MaiChartManager/Platform/Linux/LinuxShellService.cs">

<violation number="1" location="MaiChartManager/Platform/Linux/LinuxShellService.cs:25">
P1: Use `ProcessStartInfo.ArgumentList` instead of manually quoting `Arguments` so paths/URLs containing quotes or spaces are passed safely as a single argument.</violation>
</file>

<file name="MaiChartManager/Controllers/App/AppLicenseController.cs">

<violation number="1" location="MaiChartManager/Controllers/App/AppLicenseController.cs:41">
P2: Linux purchase response changes `status` to numeric, breaking existing string-enum API contract.</violation>

<violation number="2" location="MaiChartManager/Controllers/App/AppLicenseController.cs:54">
P1: `VerifyOfflineKey` unconditionally succeeds on Linux, allowing invalid offline keys to be accepted.</violation>
</file>

<file name="MaiChartManager/Platform/Linux/PhotinoDialogService.cs">

<violation number="1" location="MaiChartManager/Platform/Linux/PhotinoDialogService.cs:30">
P3: Dispose each `ManualResetEventSlim` created in dialog methods (for example with `using var`) to avoid accumulating wait handles over repeated dialog calls.</violation>
</file>

<file name="MaiChartManager/Platform/PlatformFile.cs">

<violation number="1" location="MaiChartManager/Platform/PlatformFile.cs:58">
P1: Add a guard that rejects destination paths inside the source tree; without it, recursive copy can loop indefinitely.</violation>
</file>

Note: This PR contains a large number of files. cubic only reviews up to 100 files per PR, so some files may not have been reviewed. cubic prioritizes the most important files to review.
On a pro plan you can use ultrareview for larger PRs.

Re-trigger cubic

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");

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.

P1: Unsanitized assetDir is used in file-system write paths, enabling path traversal outside StreamingAssets. Validate/canonicalize combined path and reject values that resolve outside the allowed root.

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

<comment>Unsanitized `assetDir` is used in file-system write paths, enabling path traversal outside `StreamingAssets`. Validate/canonicalize combined path and reject values that resolve outside the allowed root.</comment>

<file context>
@@ -30,15 +30,17 @@ 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);
</file context>

public ActionResult<string?> PickImportFolder()
{
// 仅本地桌面场景,export / 远程模式下禁止
if (StaticSettings.Config.Export) return Forbid();

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.

P1: Security gate is incomplete: endpoints trust Export flag but do not enforce loopback caller. Add remote-IP loopback check to prevent accidental LAN exposure of filesystem APIs.

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

<comment>Security gate is incomplete: endpoints trust `Export` flag but do not enforce loopback caller. Add remote-IP loopback check to prevent accidental LAN exposure of filesystem APIs.</comment>

<file context>
@@ -0,0 +1,75 @@
+    public ActionResult<string?> PickImportFolder()
+    {
+        // 仅本地桌面场景,export / 远程模式下禁止
+        if (StaticSettings.Config.Export) return Forbid();
+        var path = dialogService.PickFolder();
+        logger.LogInformation("PickImportFolder: {path}", path);
</file context>

/// 判断路径是否包含某个目录段(跨平台,忽略分隔符差异,大小写不敏感)
public static bool ContainsSegment(string? path, string segment)
=> path is not null &&
path.Replace('\\', '/').Contains($"/{segment}/", System.StringComparison.InvariantCultureIgnoreCase);

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.

P1: This segment check misses path-boundary cases like A000/foo and .../A000; match by path segments instead of searching for "/{segment}/".

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

<comment>This segment check misses path-boundary cases like `A000/foo` and `.../A000`; match by path segments instead of searching for `"/{segment}/"`.</comment>

<file context>
@@ -0,0 +1,40 @@
+    /// 判断路径是否包含某个目录段(跨平台,忽略分隔符差异,大小写不敏感)
+    public static bool ContainsSegment(string? path, string segment)
+        => path is not null &&
+           path.Replace('\\', '/').Contains($"/{segment}/", System.StringComparison.InvariantCultureIgnoreCase);
+
+    /// <summary>
</file context>

{
try
{
Process.Start(new ProcessStartInfo("xdg-open", $"\"{arg}\"") { UseShellExecute = false });

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.

P1: Use ProcessStartInfo.ArgumentList instead of manually quoting Arguments so paths/URLs containing quotes or spaces are passed safely as a single argument.

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

<comment>Use `ProcessStartInfo.ArgumentList` instead of manually quoting `Arguments` so paths/URLs containing quotes or spaces are passed safely as a single argument.</comment>

<file context>
@@ -0,0 +1,32 @@
+    {
+        try
+        {
+            Process.Start(new ProcessStartInfo("xdg-open", $"\"{arg}\"") { UseShellExecute = false });
+        }
+        catch (Exception e)
</file context>

{
// Linux 不做离线密钥验证;始终视为已授权
return Task.FromResult(true);
}

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.

P1: VerifyOfflineKey unconditionally succeeds on Linux, allowing invalid offline keys to be accepted.

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

<comment>`VerifyOfflineKey` unconditionally succeeds on Linux, allowing invalid offline keys to be accepted.</comment>

<file context>
@@ -32,4 +35,22 @@ public async Task<bool> VerifyOfflineKey([FromBody] string key)
+    {
+        // Linux 不做离线密钥验证;始终视为已授权
+        return Task.FromResult(true);
+    }
+#endif
+}
</file context>


var result = new List<ImportDirEntry>();
// 子目录
foreach (var dir in Directory.EnumerateDirectories(path))

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.

P2: Handle directory enumeration exceptions here; inaccessible entries currently bubble out and can turn this endpoint into a 500.

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

<comment>Handle directory enumeration exceptions here; inaccessible entries currently bubble out and can turn this endpoint into a 500.</comment>

<file context>
@@ -0,0 +1,75 @@
+
+        var result = new List<ImportDirEntry>();
+        // 子目录
+        foreach (var dir in Directory.EnumerateDirectories(path))
+        {
+            result.Add(new ImportDirEntry(Path.GetFileName(dir), dir, true));
</file context>


// 启动进程内 Kestrel:loopback + 伺服 SPA(wwwroot)+ API 同源,但不开 LAN 端口。
// Kestrel 在后台线程运行(StartApp 内部 Task.Run),主线程留给 Photino 开窗。
var serverReady = new ManualResetEventSlim(false);

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: Dispose ManualResetEventSlim after startup synchronization to release its wait handle.

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

<comment>Dispose `ManualResetEventSlim` after startup synchronization to release its wait handle.</comment>

<file context>
@@ -0,0 +1,181 @@
+
+        // 启动进程内 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 =>
</file context>


// 把扁平 FileList(带 webkitRelativePath)重建为目录树,返回根目录适配器。
// 选目录时浏览器会把所选目录名作为 webkitRelativePath 的第一段,因此根节点名取该第一段。
export function buildDirectoryFromFileList(files: FileList | File[]): ImportDirectory {

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: 新增的 webkitDirectory 适配器当前未被任何代码路径使用,形成死代码并增加维护负担。应删除该实现或在实际目录选择流程中接入调用。

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts, line 59:

<comment>新增的 webkitDirectory 适配器当前未被任何代码路径使用,形成死代码并增加维护负担。应删除该实现或在实际目录选择流程中接入调用。</comment>

<file context>
@@ -0,0 +1,93 @@
+
+// 把扁平 FileList(带 webkitRelativePath)重建为目录树,返回根目录适配器。
+// 选目录时浏览器会把所选目录名作为 webkitRelativePath 的第一段,因此根节点名取该第一段。
+export function buildDirectoryFromFileList(files: FileList | File[]): ImportDirectory {
+  const list = Array.from(files);
+  // 用一个虚拟根承载,最终若只有单一顶层目录则把它作为返回根
</file context>

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

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: Focus-based cancel fallback can leave dangling Promise and leaked DOM element. If window.focus doesn't fire after cancel, input stays in DOM and Promise never resolves.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At MaiChartManager/Front/src/utils/pickFile.ts, line 13:

<comment>Focus-based cancel fallback can leave dangling Promise and leaked DOM element. If `window.focus` doesn't fire after cancel, `input` stays in DOM and Promise never resolves.</comment>

<file context>
@@ -0,0 +1,16 @@
+    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();
+  });
</file context>

@@ -0,0 +1,155 @@
#if !WINDOWS

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: Dispose each ManualResetEventSlim created in dialog methods (for example with using var) to avoid accumulating wait handles over repeated dialog calls.

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

<comment>Dispose each `ManualResetEventSlim` created in dialog methods (for example with `using var`) to avoid accumulating wait handles over repeated dialog calls.</comment>

<file context>
@@ -0,0 +1,155 @@
+        }
+
+        string[]? result = null;
+        var done = new ManualResetEventSlim();
+        // 上次选过的目录作为初始目录(没有则交给系统默认)
+        var startDir = StaticSettings.Config.LastDialogFolder is { Length: > 0 } d && Directory.Exists(d) ? d : "";
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant