From 2cc64ebaf01f0830177780a89e0c6636b86127f9 Mon Sep 17 00:00:00 2001 From: UranusNo7 <2023120205@njpji.edu.cn> Date: Sun, 2 Aug 2026 19:17:26 +0800 Subject: [PATCH 1/8] feat: add game resource junction manager --- .../MaiChartManager.Tests.csproj | 2 +- .../Services/ResourceJunctionServiceTests.cs | 262 ++++++++++ .../Tools/ResourceJunctionController.cs | 85 ++++ MaiChartManager/Front/src/client/apiGen.ts | 146 +++++- MaiChartManager/Front/src/locales/en.yaml | 33 ++ MaiChartManager/Front/src/locales/zh-TW.yaml | 33 ++ MaiChartManager/Front/src/locales/zh.yaml | 33 ++ .../src/views/Tools/ResourceJunctionModal.tsx | 170 +++++++ .../Front/src/views/Tools/index.tsx | 9 + MaiChartManager/ServerManager.cs | 1 + .../Services/ResourceJunctionService.cs | 457 ++++++++++++++++++ docs/resource-junction-manager.md | 41 ++ 12 files changed, 1269 insertions(+), 3 deletions(-) create mode 100644 MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs create mode 100644 MaiChartManager/Controllers/Tools/ResourceJunctionController.cs create mode 100644 MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx create mode 100644 MaiChartManager/Services/ResourceJunctionService.cs create mode 100644 docs/resource-junction-manager.md diff --git a/MaiChartManager.Tests/MaiChartManager.Tests.csproj b/MaiChartManager.Tests/MaiChartManager.Tests.csproj index 06dcf030..98afb4c1 100644 --- a/MaiChartManager.Tests/MaiChartManager.Tests.csproj +++ b/MaiChartManager.Tests/MaiChartManager.Tests.csproj @@ -1,6 +1,6 @@ - net10.0 + net10.0-windows10.0.17763.0 enable enable false diff --git a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs new file mode 100644 index 00000000..0fa372a5 --- /dev/null +++ b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs @@ -0,0 +1,262 @@ +using MaiChartManager.Services; + +namespace MaiChartManager.Tests.Services; + +public sealed class ResourceJunctionServiceTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), $"mcm-resource-links-{Guid.NewGuid():N}"); + private readonly string sourceRoot; + private readonly string targetRoot; + + public ResourceJunctionServiceTests() + { + sourceRoot = Path.Combine(root, "source"); + targetRoot = Path.Combine(root, "target"); + Directory.CreateDirectory(sourceRoot); + Directory.CreateDirectory(targetRoot); + foreach (var name in ResourceJunctionService.ResourceNames) + Directory.CreateDirectory(Path.Combine(sourceRoot, name)); + } + + [Fact] + public void FixedScopeContainsOnlyThreeResourceDirectories() + { + Assert.Equal(["AssetBundleImages", "MovieData", "SoundData"], ResourceJunctionService.ResourceNames); + } + + [Fact] + public void AutoSelectionAcceptsGameRootAndPackageAndChoosesMostFiles() + { + var targetGame = CreateGame("target-game", [0, 0, 0]); + var smallerGame = CreateGame("smaller-game", [1, 1, 1]); + var largerGame = CreateGame("larger-game", [2, 3, 4]); + var service = new ResourceJunctionService( + () => Path.Combine(targetGame, "Package"), + () => [targetGame, Path.Combine(smallerGame, "Package"), largerGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.Automatic, overview.SelectionMode); + Assert.Equal(Path.Combine(largerGame, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(9, overview.TotalFileCount); + Assert.Equal([2L, 3L, 4L], overview.FileCounts.Select(item => item.FileCount)); + } + + [Fact] + public void AutoSelectionRejectsIncompleteCandidatesAndCurrentGame() + { + var targetGame = CreateGame("target-game-invalid", [5, 5, 5]); + var incompleteGame = CreateGame("incomplete-game", [1, 1, 1]); + Directory.Delete(Path.Combine(incompleteGame, "Package", "Sinmai_Data", "StreamingAssets", "A000", "MovieData"), true); + var service = new ResourceJunctionService( + () => Path.Combine(targetGame, "Package"), + () => [targetGame, incompleteGame, Path.Combine(root, "missing")]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.None, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + Assert.All(overview.Items, item => Assert.Equal(ResourceJunctionStatus.SourceMissing, item.Status)); + } + + [Fact] + public void AutoSelectionRequiresManualChoiceWhenHighestCountsTie() + { + var targetGame = CreateGame("target-game-tie", [0, 0, 0]); + var firstGame = CreateGame("first-game-tie", [1, 2, 3]); + var secondGame = CreateGame("second-game-tie", [3, 2, 1]); + var service = new ResourceJunctionService( + () => targetGame, + () => [firstGame, secondGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.Tie, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + Assert.NotNull(overview.Detail); + } + + [Fact] + public void AutoSelectionRejectsResourceDirectoryJunctions() + { + if (!OperatingSystem.IsWindows()) return; + var targetGame = CreateGame("target-game-reparse", [0, 0, 0]); + var sourceGame = CreateGame("source-game-reparse", [1, 1, 1]); + var resourcePath = Path.Combine(sourceGame, "Package", "Sinmai_Data", "StreamingAssets", "A000", "MovieData"); + var linkedDirectory = Path.Combine(root, "linked-resource"); + Directory.Delete(resourcePath, true); + Directory.CreateDirectory(linkedDirectory); + CreateJunction(linkedDirectory, resourcePath); + try + { + var service = new ResourceJunctionService(() => targetGame, () => [sourceGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.None, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + } + finally + { + Directory.Delete(resourcePath, false); + } + } + + [Fact] + public void ManualSelectionOverridesAutomaticSelectionForSession() + { + var targetGame = CreateGame("target-game-manual", [0, 0, 0]); + var automaticGame = CreateGame("automatic-game", [4, 4, 4]); + var manualGame = CreateGame("manual-game", [1, 1, 1]); + var service = new ResourceJunctionService( + () => targetGame, + () => [automaticGame, manualGame]); + + service.AutoSelectSource(); + var overview = service.SelectManualSource(Path.Combine(manualGame, "Package")); + + Assert.Equal(ResourceSourceSelectionMode.Manual, overview.SelectionMode); + Assert.Equal(Path.Combine(manualGame, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(3, overview.TotalFileCount); + } + + [Fact] + public void ManualSelectionRejectsCurrentGame() + { + var targetGame = CreateGame("target-game-self", [0, 0, 0]); + var service = new ResourceJunctionService(() => targetGame, () => []); + + Assert.Throws(() => service.SelectManualSource(targetGame)); + } + + [Fact] + public void ManualTargetSelectionIsSessionOnlyAndKeepsDistinctSource() + { + var configuredTarget = CreateGame("configured-target", [0, 0, 0]); + var manualTarget = CreateGame("manual-target", [0, 0, 0]); + var sourceGame = CreateGame("source-for-manual-target", [1, 1, 1]); + var configuredTargetReads = 0; + var service = new ResourceJunctionService( + () => + { + configuredTargetReads++; + return configuredTarget; + }, + () => [sourceGame]); + service.AutoSelectSource(); + var readsBeforeManualSelection = configuredTargetReads; + + var overview = service.SelectManualTarget(manualTarget); + + Assert.Equal(Path.Combine(manualTarget, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.TargetRoot); + Assert.Equal(Path.Combine(sourceGame, "Package", "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(readsBeforeManualSelection, configuredTargetReads); + } + + [Fact] + public void SelectingCurrentSourceAsTargetClearsSource() + { + var configuredTarget = CreateGame("configured-target-clear", [0, 0, 0]); + var sourceGame = CreateGame("source-becomes-target", [1, 1, 1]); + var service = new ResourceJunctionService(() => configuredTarget, () => [sourceGame]); + service.AutoSelectSource(); + + var overview = service.SelectManualTarget(sourceGame); + + Assert.Equal(ResourceSourceSelectionMode.None, overview.SelectionMode); + Assert.Null(overview.SourceRoot); + Assert.NotNull(overview.Detail); + } + + [Fact] + public void ExistingRealDirectoriesAreConflicts() + { + if (!OperatingSystem.IsWindows()) return; + foreach (var name in ResourceJunctionService.ResourceNames) + Directory.CreateDirectory(Path.Combine(targetRoot, name)); + + var result = new ResourceJunctionService(sourceRoot, targetRoot).Inspect(); + + Assert.All(result, item => Assert.Equal(ResourceJunctionStatus.Conflict, item.Status)); + } + + [Fact] + public void CreateAndRemoveOnlyVerifiedJunctions() + { + if (!OperatingSystem.IsWindows()) return; + var sourceFile = Path.Combine(sourceRoot, ResourceJunctionService.ResourceNames[0], "source.txt"); + File.WriteAllText(sourceFile, "source remains unchanged"); + var service = new ResourceJunctionService(sourceRoot, targetRoot); + + var created = service.CreateLinks(); + var inspected = service.Inspect(); + var removed = service.RemoveLinks(); + + Assert.All(created, item => Assert.Equal(ResourceJunctionStatus.Created, item.Status)); + Assert.All(inspected, item => Assert.Equal(ResourceJunctionStatus.AlreadyLinked, item.Status)); + Assert.All(removed, item => Assert.Equal(ResourceJunctionStatus.Removed, item.Status)); + Assert.True(File.Exists(sourceFile)); + Assert.Equal("source remains unchanged", File.ReadAllText(sourceFile)); + } + + [Fact] + public void WrongJunctionTargetIsNotRemoved() + { + if (!OperatingSystem.IsWindows()) return; + var wrongSource = Path.Combine(root, "wrong-source"); + Directory.CreateDirectory(wrongSource); + var target = Path.Combine(targetRoot, ResourceJunctionService.ResourceNames[0]); + CreateJunction(wrongSource, target); + var service = new ResourceJunctionService(sourceRoot, targetRoot); + + var result = service.RemoveLinks(); + + Assert.Equal(ResourceJunctionStatus.WrongTarget, result[0].Status); + Assert.True(Directory.Exists(target)); + } + + public void Dispose() + { + if (!Directory.Exists(root)) return; + var service = new ResourceJunctionService(sourceRoot, targetRoot); + service.RemoveLinks(); + var wrongTarget = Path.Combine(targetRoot, ResourceJunctionService.ResourceNames[0]); + if (Directory.Exists(wrongTarget) && (File.GetAttributes(wrongTarget) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(wrongTarget, false); + Directory.Delete(root, true); + } + + private static void CreateJunction(string source, string target) + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("mklink"); + startInfo.ArgumentList.Add("/J"); + startInfo.ArgumentList.Add(target); + startInfo.ArgumentList.Add(source); + using var process = System.Diagnostics.Process.Start(startInfo)!; + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + } + + private string CreateGame(string name, int[] fileCounts) + { + var gameRoot = Path.Combine(root, name); + var a000 = Path.Combine(gameRoot, "Package", "Sinmai_Data", "StreamingAssets", "A000"); + for (var resourceIndex = 0; resourceIndex < ResourceJunctionService.ResourceNames.Length; resourceIndex++) + { + var resourceRoot = Path.Combine(a000, ResourceJunctionService.ResourceNames[resourceIndex]); + var nestedRoot = Path.Combine(resourceRoot, "nested"); + Directory.CreateDirectory(nestedRoot); + for (var fileIndex = 0; fileIndex < fileCounts[resourceIndex]; fileIndex++) + File.WriteAllText(Path.Combine(nestedRoot, $"{fileIndex}.dat"), "test"); + } + return gameRoot; + } +} diff --git a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs new file mode 100644 index 00000000..91631a0b --- /dev/null +++ b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs @@ -0,0 +1,85 @@ +using MaiChartManager.Platform; +using MaiChartManager.Services; +using Microsoft.AspNetCore.Mvc; + +namespace MaiChartManager.Controllers.Tools; + +[ApiController] +[Route("MaiChartManagerServlet/[action]Api")] +public class ResourceJunctionController(ResourceJunctionService service, IDesktopDialogService dialogService) : ControllerBase +{ + private const string LocalActionHeader = "X-MCM-Local-Action"; + private const string LocalActionValue = "resource-junction"; + + [HttpGet] + public ActionResult GetResourceJunctionStatus() + { + if (StaticSettings.Config.Export) return Forbid(); + return Ok(service.GetOverview()); + } + + [HttpGet] + public ActionResult AutoSelectResourceJunctionSource() + { + if (StaticSettings.Config.Export) return Forbid(); + return Ok(service.AutoSelectSource()); + } + + [HttpPost] + public ActionResult SelectResourceJunctionSource() + { + if (StaticSettings.Config.Export) return Forbid(); + if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + + var path = dialogService.PickFolder("Select a source game directory or Package directory"); + if (path is null) return Ok(service.GetOverview()); + try + { + return Ok(service.SelectManualSource(path)); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + catch (InvalidOperationException e) + { + return BadRequest(e.Message); + } + } + + [HttpPost] + public ActionResult SelectResourceJunctionTarget() + { + if (StaticSettings.Config.Export) return Forbid(); + if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + + var path = dialogService.PickFolder("Select a target game directory or Package directory"); + if (path is null) return Ok(service.GetOverview()); + try + { + return Ok(service.SelectManualTarget(path)); + } + catch (ArgumentException e) + { + return BadRequest(e.Message); + } + } + + [HttpPost] + public ActionResult CreateResourceJunctions() + { + if (StaticSettings.Config.Export) return Forbid(); + if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + var items = service.CreateLinks(); + return Ok(service.GetOverview() with { Items = items }); + } + + [HttpPost] + public ActionResult RemoveResourceJunctions() + { + if (StaticSettings.Config.Export) return Forbid(); + if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + var items = service.RemoveLinks(); + return Ok(service.GetOverview() with { Items = items }); + } +} diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index ae3b8c81..86f87f78 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -17,12 +17,40 @@ 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", NoShift = "NoShift", } +export enum ResourceSourceSelectionMode { + None = "None", + Automatic = "Automatic", + Manual = "Manual", + Tie = "Tie", +} + +export enum ResourceJunctionStatus { + Ready = "Ready", + Created = "Created", + AlreadyLinked = "AlreadyLinked", + Removed = "Removed", + SourceMissing = "SourceMissing", + TargetRootMissing = "TargetRootMissing", + Conflict = "Conflict", + WrongTarget = "WrongTarget", + Failed = "Failed", + Unsupported = "Unsupported", +} + export enum PubKeyId { None = "None", Local = "Local", @@ -385,8 +413,32 @@ export interface RequestExportMaidataRequest { export interface RequestPurchaseResult { errorMessage?: string | null; - /** @format int32 */ - status?: number; + status?: StorePurchaseStatus; +} + +export interface ResourceDirectoryFileCount { + name?: string | null; + /** @format int64 */ + fileCount?: number; +} + +export interface ResourceJunctionItem { + name?: string | null; + source?: string | null; + target?: string | null; + status?: ResourceJunctionStatus; + detail?: string | null; +} + +export interface ResourceJunctionOverview { + sourceRoot?: string | null; + targetRoot?: string | null; + selectionMode?: ResourceSourceSelectionMode; + fileCounts?: ResourceDirectoryFileCount[] | null; + /** @format int64 */ + totalFileCount?: number; + detail?: string | null; + items?: ResourceJunctionItem[] | null; } export interface Section { @@ -2670,6 +2722,96 @@ export class Api< ...params, }), + /** + * No description + * + * @tags ResourceJunction + * @name GetResourceJunctionStatus + * @request GET:/MaiChartManagerServlet/GetResourceJunctionStatusApi + */ + GetResourceJunctionStatus: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/GetResourceJunctionStatusApi`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name AutoSelectResourceJunctionSource + * @request GET:/MaiChartManagerServlet/AutoSelectResourceJunctionSourceApi + */ + AutoSelectResourceJunctionSource: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/AutoSelectResourceJunctionSourceApi`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name SelectResourceJunctionSource + * @request POST:/MaiChartManagerServlet/SelectResourceJunctionSourceApi + */ + SelectResourceJunctionSource: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/SelectResourceJunctionSourceApi`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name SelectResourceJunctionTarget + * @request POST:/MaiChartManagerServlet/SelectResourceJunctionTargetApi + */ + SelectResourceJunctionTarget: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/SelectResourceJunctionTargetApi`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name CreateResourceJunctions + * @request POST:/MaiChartManagerServlet/CreateResourceJunctionsApi + */ + CreateResourceJunctions: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/CreateResourceJunctionsApi`, + method: "POST", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags ResourceJunction + * @name RemoveResourceJunctions + * @request POST:/MaiChartManagerServlet/RemoveResourceJunctionsApi + */ + RemoveResourceJunctions: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/RemoveResourceJunctionsApi`, + method: "POST", + format: "json", + ...params, + }), + /** * No description * diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 1123dc84..29f937aa 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -532,6 +532,39 @@ tools: videoConvertError: Video conversion error imageToAb: Image to AssetBundle imageToAbError: Image to AB conversion error + resourceJunction: + label: Game Resource Links + title: Game Resource Links + source: Read-only source + target: Target directory + noSource: No source was selected automatically. Select one manually + selectSource: Select source manually + selectTarget: Select target directory + fileCounts: Source resource file counts + total: Total + refresh: Refresh status + create: Create links + remove: Remove links + loading: Reading status... + requestFailed: Game resource link operation failed + createConfirm: Create the three resource Junctions only where the target is missing? + removeConfirm: Remove only Junctions that point to the current source? Source files will not be deleted. + selection: + None: Not selected + Automatic: Automatically selected + Manual: Manually selected + Tie: File count tie; select manually + status: + Ready: Ready + Created: Created + AlreadyLinked: Linked correctly + Removed: Removed + SourceMissing: Source missing + TargetRootMissing: Target root missing + Conflict: Target is not a Junction + WrongTarget: Wrong Junction target + Failed: Failed + Unsupported: Unsupported system pvConvert: label: Convert PV single: Single convert diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index d8f6f558..9c47f521 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -477,6 +477,39 @@ tools: videoConvertError: 影片轉換出錯 imageToAb: 圖片轉 AssetBundle imageToAbError: 圖片轉 AB 出錯 + resourceJunction: + label: 遊戲資源連結 + title: 遊戲資源連結 + source: 唯讀來源目錄 + target: 目標目錄 + noSource: 未自動選出來源目錄,請手動選擇 + selectSource: 手動選擇來源目錄 + selectTarget: 選擇目標目錄 + fileCounts: 來源目錄資源檔案數 + total: 合計 + refresh: 重新整理狀態 + create: 建立連結 + remove: 移除連結 + loading: 正在讀取狀態… + requestFailed: 遊戲資源連結操作失敗 + createConfirm: 只會在目標位置不存在時建立三個資源 Junction,確認繼續? + removeConfirm: 只會移除正確指向目前來源目錄的 Junction,不會刪除來源檔案,確認繼續? + selection: + None: 未選擇 + Automatic: 自動選擇 + Manual: 手動選擇 + Tie: 檔案數並列,請手動選擇 + status: + Ready: 可建立 + Created: 已建立 + AlreadyLinked: 已正確連結 + Removed: 已移除 + SourceMissing: 來源目錄缺失 + TargetRootMissing: 目標根目錄缺失 + Conflict: 目標存在且不是 Junction + WrongTarget: Junction 指向錯誤 + Failed: 操作失敗 + Unsupported: 目前系統不支援 pvConvert: label: 轉換 PV single: 單個轉換 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 7b6c090e..d164b0d2 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -471,6 +471,39 @@ tools: videoConvertError: 视频转换出错 imageToAb: 图片转 AssetBundle imageToAbError: 图片转 AB 出错 + resourceJunction: + label: 游戏资源链接 + title: 游戏资源链接 + source: 只读源目录 + target: 目标目录 + noSource: 未自动选出源目录,请手动选择 + selectSource: 手动选择源目录 + selectTarget: 选择目标目录 + fileCounts: 源目录资源文件数 + total: 合计 + refresh: 刷新状态 + create: 建立链接 + remove: 移除链接 + loading: 正在读取状态… + requestFailed: 游戏资源链接操作失败 + createConfirm: 只会在目标位置不存在时建立三个资源 Junction,确认继续? + removeConfirm: 只会移除正确指向当前源目录的 Junction,不会删除源文件,确认继续? + selection: + None: 未选择 + Automatic: 自动选择 + Manual: 手动选择 + Tie: 文件数并列,请手动选择 + status: + Ready: 可建立 + Created: 已建立 + AlreadyLinked: 已正确链接 + Removed: 已移除 + SourceMissing: 源目录缺失 + TargetRootMissing: 目标根目录缺失 + Conflict: 目标存在且不是 Junction + WrongTarget: Junction 指向错误 + Failed: 操作失败 + Unsupported: 当前系统不支持 pvConvert: label: 转换 PV single: 单个转换 diff --git a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx new file mode 100644 index 00000000..147eecfe --- /dev/null +++ b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx @@ -0,0 +1,170 @@ +import api from '@/client/api'; +import { ResourceJunctionOverview, ResourceJunctionStatus } from '@/client/apiGen'; +import { Button, Modal, addToast, showTransactionalDialog } from '@munet/ui'; +import { computed, defineComponent, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +export default defineComponent({ + setup(_, { expose }) { + const { t } = useI18n(); + const show = ref(false); + const loading = ref(false); + const overview = ref(); + const items = computed(() => overview.value?.items ?? []); + + const canCreate = computed(() => items.value.some(item => item.status === 'Ready')); + const canRemove = computed(() => items.value.some(item => item.status === 'AlreadyLinked')); + + const request = async (action: 'auto' | 'status' | 'manual' | 'manualTarget' | 'create' | 'remove') => { + loading.value = true; + try { + const writeParams = { headers: { 'X-MCM-Local-Action': 'resource-junction' } }; + const response = action === 'auto' + ? await api.AutoSelectResourceJunctionSource() + : action === 'status' + ? await api.GetResourceJunctionStatus() + : action === 'manual' + ? await api.SelectResourceJunctionSource(writeParams) + : action === 'manualTarget' + ? await api.SelectResourceJunctionTarget(writeParams) + : action === 'create' + ? await api.CreateResourceJunctions(writeParams) + : await api.RemoveResourceJunctions(writeParams); + overview.value = response.data; + } catch (error) { + console.error(error); + addToast({ message: t('tools.resourceJunction.requestFailed'), type: 'error' }); + } finally { + loading.value = false; + } + }; + + const run = async (action: 'create' | 'remove') => { + const removing = action === 'remove'; + const confirmed = await showTransactionalDialog( + t('common.confirm'), + t(removing ? 'tools.resourceJunction.removeConfirm' : 'tools.resourceJunction.createConfirm'), + [ + { text: t('common.confirm'), action: true }, + { text: t('common.cancel'), action: false }, + ], + ); + if (!confirmed) return; + await request(action); + }; + + const trigger = () => { + show.value = true; + overview.value = undefined; + request('auto'); + }; + expose({ trigger }); + + const statusClass = (status?: ResourceJunctionStatus) => { + if (['Created', 'AlreadyLinked', 'Removed'].includes(status)) return 'text-green-700'; + if (status === 'Ready') return 'text-blue-700'; + return 'text-red-700'; + }; + + return () => ( + +
+
+
+
+
+
{t('tools.resourceJunction.source')}
+ {overview.value?.selectionMode && ( + + {t(`tools.resourceJunction.selection.${overview.value.selectionMode}`)} + + )} +
+
+ {overview.value?.sourceRoot ?? t('tools.resourceJunction.noSource')} +
+
+ +
+
+
+
{t('tools.resourceJunction.target')}
+
{overview.value?.targetRoot}
+
+ +
+ {!!overview.value?.fileCounts?.length && ( +
+
{t('tools.resourceJunction.fileCounts')}
+
+ {overview.value.fileCounts.map(item => ( + {item.name}: {item.fileCount} + ))} + {t('tools.resourceJunction.total')}: {overview.value.totalFileCount} +
+
+ )} + {overview.value?.detail &&
{overview.value.detail}
} +
+ +
+ {items.value.map((item, index) => ( +
0 && 'border-t border-t-solid border-t-gray-200', + ]} + > +
+
{item.name}
+ {item.detail &&
{item.detail}
} +
+
+ {t(`tools.resourceJunction.status.${item.status}`)} +
+
+ ))} + {!items.value.length && ( +
{t('tools.resourceJunction.loading')}
+ )} +
+ +
+ + + +
+
+
+ ); + }, +}); diff --git a/MaiChartManager/Front/src/views/Tools/index.tsx b/MaiChartManager/Front/src/views/Tools/index.tsx index 77a16b6d..f5c2fde5 100644 --- a/MaiChartManager/Front/src/views/Tools/index.tsx +++ b/MaiChartManager/Front/src/views/Tools/index.tsx @@ -3,6 +3,7 @@ import { addToast } from '@munet/ui'; import { defineComponent, ref } from 'vue'; import ImageToAbModal from '@/views/Tools/ImageToAbModal'; import PvConvertDropMenu from '@/views/Tools/PvConvertDropMenu'; +import ResourceJunctionModal from '@/views/Tools/ResourceJunctionModal'; import { useI18n } from 'vue-i18n'; interface ToolCard { @@ -15,6 +16,7 @@ interface ToolCard { export default defineComponent({ setup() { const imageToAbRef = ref<{ trigger: () => void }>(); + const resourceJunctionRef = ref<{ trigger: () => void }>(); const { t } = useI18n(); const handleAudioConvert = async () => { @@ -41,6 +43,11 @@ export default defineComponent({ labelKey: 'tools.imageToAb', action: () => imageToAbRef.value?.trigger(), }, + { + icon: 'i-mdi-link-variant', + labelKey: 'tools.resourceJunction.label', + action: () => resourceJunctionRef.value?.trigger(), + }, ]; const renderToolCard = (tool: ToolCard) => ( @@ -67,8 +74,10 @@ export default defineComponent({ {renderToolCard(tools[0])} {renderToolCard(tools[1])} + {renderToolCard(tools[2])} + ); }, diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index e00445c6..5d264f4f 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -104,6 +104,7 @@ public static Task StartApp(bool export, Action? onStart = null, bool se .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddEndpointsApiExplorer() .AddSwaggerGen(options => { options.CustomSchemaIds(type => type.Name == "Config" ? type.FullName : type.Name); }) .Configure(x => diff --git a/MaiChartManager/Services/ResourceJunctionService.cs b/MaiChartManager/Services/ResourceJunctionService.cs new file mode 100644 index 00000000..49d8d764 --- /dev/null +++ b/MaiChartManager/Services/ResourceJunctionService.cs @@ -0,0 +1,457 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace MaiChartManager.Services; + +public enum ResourceJunctionStatus +{ + Ready, + Created, + AlreadyLinked, + Removed, + SourceMissing, + TargetRootMissing, + Conflict, + WrongTarget, + Failed, + Unsupported, +} + +public enum ResourceSourceSelectionMode +{ + None, + Automatic, + Manual, + Tie, +} + +public record ResourceJunctionItem( + string Name, + string Source, + string Target, + ResourceJunctionStatus Status, + string? Detail = null); + +public record ResourceDirectoryFileCount(string Name, long FileCount); + +public record ResourceJunctionOverview( + string? SourceRoot, + string? TargetRoot, + ResourceSourceSelectionMode SelectionMode, + IReadOnlyList FileCounts, + long TotalFileCount, + string? Detail, + IReadOnlyList Items); + +public class ResourceJunctionService +{ + public static readonly string[] ResourceNames = ["AssetBundleImages", "MovieData", "SoundData"]; + + private const uint IoReparseTagMountPoint = 0xA0000003; + private readonly Func targetPathProvider; + private readonly Func> candidatePathProvider; + private readonly bool pathsAreA000Roots; + private string? selectedSourceRoot; + private string? selectedTargetRoot; + private ResourceSourceSelectionMode selectionMode; + private IReadOnlyList selectedFileCounts = []; + private string? selectionDetail; + + public ResourceJunctionService() + : this(() => StaticSettings.GamePath, GetDefaultCandidatePaths, false) + { + } + + public ResourceJunctionService(string sourceRoot, string targetRoot) + : this(() => targetRoot, () => [], true) + { + selectedSourceRoot = NormalizePath(sourceRoot); + selectionMode = ResourceSourceSelectionMode.Manual; + selectedFileCounts = CountResourceFiles(selectedSourceRoot); + } + + public ResourceJunctionService(Func targetPathProvider, Func> candidatePathProvider) + : this(targetPathProvider, candidatePathProvider, false) + { + } + + private ResourceJunctionService( + Func targetPathProvider, + Func> candidatePathProvider, + bool pathsAreA000Roots) + { + this.targetPathProvider = targetPathProvider; + this.candidatePathProvider = candidatePathProvider; + this.pathsAreA000Roots = pathsAreA000Roots; + } + + public ResourceJunctionOverview AutoSelectSource() + { + var targetRoot = GetTargetRoot(); + if (targetRoot is null) + return ClearSelection(ResourceSourceSelectionMode.None, "The current game directory is invalid."); + + var candidates = candidatePathProvider() + .Select(TryResolveA000Root) + .Where(path => path is not null && !SamePath(path, targetRoot)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => TryCreateCandidate(path!)) + .Where(candidate => candidate is not null) + .Cast() + .OrderByDescending(candidate => candidate.TotalFileCount) + .ToArray(); + + if (candidates.Length == 0) + return ClearSelection(ResourceSourceSelectionMode.None, "No valid source game directory was found in game path history or adjacent directories."); + + var best = candidates[0]; + if (candidates.Skip(1).Any(candidate => candidate.TotalFileCount == best.TotalFileCount)) + return ClearSelection(ResourceSourceSelectionMode.Tie, "Multiple source game directories have the same highest file count. Select one manually."); + + selectedSourceRoot = best.Root; + selectedFileCounts = best.FileCounts; + selectionMode = ResourceSourceSelectionMode.Automatic; + selectionDetail = null; + return GetOverview(); + } + + public ResourceJunctionOverview SelectManualSource(string path) + { + var targetRoot = GetTargetRoot() ?? throw new InvalidOperationException("The current game directory is invalid."); + var sourceRoot = TryResolveA000Root(path) + ?? throw new ArgumentException("The selected folder is not a valid game root or Package directory.", nameof(path)); + if (SamePath(sourceRoot, targetRoot)) + throw new ArgumentException("The source game directory must differ from the current game directory.", nameof(path)); + + var candidate = TryCreateCandidate(sourceRoot) + ?? throw new ArgumentException("The selected source must contain three readable, real resource directories.", nameof(path)); + selectedSourceRoot = candidate.Root; + selectedFileCounts = candidate.FileCounts; + selectionMode = ResourceSourceSelectionMode.Manual; + selectionDetail = null; + return GetOverview(); + } + + public ResourceJunctionOverview SelectManualTarget(string path) + { + var targetRoot = TryResolveA000Root(path) + ?? throw new ArgumentException("The selected folder is not a valid game root or Package directory.", nameof(path)); + + selectedTargetRoot = targetRoot; + if (selectedSourceRoot is not null && SamePath(selectedSourceRoot, targetRoot)) + return ClearSelection(ResourceSourceSelectionMode.None, "The source must differ from the selected target. Select a source directory again."); + + return GetOverview(); + } + + public ResourceJunctionOverview GetOverview() + { + var targetRoot = GetTargetRoot(); + var items = selectedSourceRoot is null || targetRoot is null + ? BuildUnavailableItems(targetRoot) + : ResourceNames.Select(name => Inspect(name, selectedSourceRoot, targetRoot)).ToArray(); + return new( + selectedSourceRoot, + targetRoot, + selectionMode, + selectedFileCounts, + selectedFileCounts.Sum(item => item.FileCount), + selectionDetail, + items); + } + + public IReadOnlyList Inspect() + { + return GetOverview().Items; + } + + public IReadOnlyList CreateLinks() + { + var sourceRoot = selectedSourceRoot; + var targetRoot = GetTargetRoot(); + if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(targetRoot); + + return ResourceNames.Select(name => + { + var item = Inspect(name, sourceRoot, targetRoot); + if (item.Status != ResourceJunctionStatus.Ready) return item; + + try + { + CreateJunction(item.Source, item.Target); + var verified = Inspect(name, sourceRoot, targetRoot); + return verified.Status == ResourceJunctionStatus.AlreadyLinked + ? verified with { Status = ResourceJunctionStatus.Created } + : verified with { Status = ResourceJunctionStatus.Failed, Detail = "Junction was created but verification failed." }; + } + catch (Exception e) + { + return item with { Status = ResourceJunctionStatus.Failed, Detail = e.Message }; + } + }).ToArray(); + } + + public IReadOnlyList RemoveLinks() + { + var sourceRoot = selectedSourceRoot; + var targetRoot = GetTargetRoot(); + if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(targetRoot); + + return ResourceNames.Select(name => + { + var item = Inspect(name, sourceRoot, targetRoot); + if (item.Status != ResourceJunctionStatus.AlreadyLinked) return item; + + try + { + Directory.Delete(item.Target, false); + var verified = Inspect(name, sourceRoot, targetRoot); + return verified.Status == ResourceJunctionStatus.Ready + ? verified with { Status = ResourceJunctionStatus.Removed } + : verified with { Status = ResourceJunctionStatus.Failed, Detail = "Junction removal could not be verified." }; + } + catch (Exception e) + { + return item with { Status = ResourceJunctionStatus.Failed, Detail = e.Message }; + } + }).ToArray(); + } + + private ResourceJunctionOverview ClearSelection(ResourceSourceSelectionMode mode, string detail) + { + selectedSourceRoot = null; + selectedFileCounts = []; + selectionMode = mode; + selectionDetail = detail; + return GetOverview(); + } + + private string? GetTargetRoot() + { + if (selectedTargetRoot is not null) return selectedTargetRoot; + var path = targetPathProvider(); + if (string.IsNullOrWhiteSpace(path)) return null; + if (pathsAreA000Roots) return Directory.Exists(path) ? NormalizePath(path) : null; + return TryResolveA000Root(path); + } + + private static string? TryResolveA000Root(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + try + { + var fullPath = NormalizePath(path); + var direct = Path.Combine(fullPath, "Sinmai_Data", "StreamingAssets", "A000"); + if (Directory.Exists(direct)) return NormalizePath(direct); + + var package = Path.Combine(fullPath, "Package", "Sinmai_Data", "StreamingAssets", "A000"); + return Directory.Exists(package) ? NormalizePath(package) : null; + } + catch (Exception) + { + return null; + } + } + + private static IEnumerable GetDefaultCandidatePaths() + { + foreach (var historyPath in StaticSettings.Config.HistoryPath) + yield return historyPath; + + var currentPath = StaticSettings.GamePath; + if (string.IsNullOrWhiteSpace(currentPath)) yield break; + + string? gameRoot; + try + { + var fullPath = NormalizePath(currentPath); + gameRoot = Directory.Exists(Path.Combine(fullPath, "Sinmai_Data", "StreamingAssets", "A000")) + ? Directory.GetParent(fullPath)?.FullName + : Directory.Exists(Path.Combine(fullPath, "Package", "Sinmai_Data", "StreamingAssets", "A000")) + ? fullPath + : null; + } + catch (Exception) + { + yield break; + } + + var parent = gameRoot is null ? null : Directory.GetParent(gameRoot)?.FullName; + if (parent is null || !Directory.Exists(parent)) yield break; + + IEnumerable siblings; + try + { + siblings = Directory.EnumerateDirectories(parent).ToArray(); + } + catch (Exception) + { + yield break; + } + + foreach (var sibling in siblings) + yield return sibling; + } + + private static ResourceSourceCandidate? TryCreateCandidate(string root) + { + try + { + var counts = CountResourceFiles(root); + return new(root, counts, counts.Sum(item => item.FileCount)); + } + catch (Exception) + { + return null; + } + } + + private static IReadOnlyList CountResourceFiles(string root) + { + return ResourceNames.Select(name => + { + var directory = new DirectoryInfo(Path.Combine(root, name)); + if (!directory.Exists || (directory.Attributes & FileAttributes.ReparsePoint) != 0) + throw new IOException($"{name} is missing or is a reparse point."); + return new ResourceDirectoryFileCount(name, directory.EnumerateFiles("*", SearchOption.AllDirectories).LongCount()); + }).ToArray(); + } + + private IReadOnlyList BuildUnavailableItems(string? targetRoot) + { + var status = targetRoot is null ? ResourceJunctionStatus.TargetRootMissing : ResourceJunctionStatus.SourceMissing; + return ResourceNames.Select(name => new ResourceJunctionItem( + name, + selectedSourceRoot is null ? "" : Path.Combine(selectedSourceRoot, name), + targetRoot is null ? "" : Path.Combine(targetRoot, name), + status, + selectionDetail)).ToArray(); + } + + private static ResourceJunctionItem Inspect(string name, string sourceRoot, string targetRoot) + { + var source = Path.Combine(sourceRoot, name); + var target = Path.Combine(targetRoot, name); + + if (!OperatingSystem.IsWindows()) + return new(name, source, target, ResourceJunctionStatus.Unsupported, "Junctions are only supported on Windows."); + if (!Directory.Exists(source)) + return new(name, source, target, ResourceJunctionStatus.SourceMissing); + if (!Directory.Exists(targetRoot)) + return new(name, source, target, ResourceJunctionStatus.TargetRootMissing); + + var entry = FindTargetEntry(targetRoot, name); + if (entry is null) + return new(name, source, target, ResourceJunctionStatus.Ready); + if (!TryGetReparseTag(target, out var tag) || tag != IoReparseTagMountPoint) + return new(name, source, target, ResourceJunctionStatus.Conflict, "The target exists and is not a Junction."); + + try + { + var destination = entry.ResolveLinkTarget(false)?.FullName; + if (destination is not null && SamePath(destination, source)) + return new(name, source, target, ResourceJunctionStatus.AlreadyLinked); + return new(name, source, target, ResourceJunctionStatus.WrongTarget, destination); + } + catch (Exception e) + { + return new(name, source, target, ResourceJunctionStatus.WrongTarget, e.Message); + } + } + + private static FileSystemInfo? FindTargetEntry(string targetRoot, string name) + { + return new DirectoryInfo(targetRoot) + .EnumerateFileSystemInfos(name, SearchOption.TopDirectoryOnly) + .FirstOrDefault(entry => string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private static bool SamePath(string left, string right) + { + return string.Equals(NormalizePath(left), NormalizePath(right), StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePath(string path) + { + if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + path = @"\\" + path[8..]; + else if (path.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + path = path[4..]; + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + + private static void CreateJunction(string source, string target) + { + var startInfo = new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("/d"); + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("mklink"); + startInfo.ArgumentList.Add("/J"); + startInfo.ArgumentList.Add(target); + startInfo.ArgumentList.Add(source); + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start mklink."); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new IOException((error.Length > 0 ? error : output).Trim()); + } + + private static bool TryGetReparseTag(string path, out uint tag) + { + tag = 0; + using var handle = CreateFile( + path, + 0, + 0x00000001 | 0x00000002 | 0x00000004, + IntPtr.Zero, + 3, + 0x00200000 | 0x02000000, + IntPtr.Zero); + if (handle.IsInvalid) return false; + + if (!GetFileInformationByHandleEx(handle, 9, out var info, (uint)Marshal.SizeOf())) + return false; + tag = info.ReparseTag; + return true; + } + + private record ResourceSourceCandidate( + string Root, + IReadOnlyList FileCounts, + long TotalFileCount); + + [StructLayout(LayoutKind.Sequential)] + private struct FileAttributeTagInfo + { + public uint FileAttributes; + public uint ReparseTag; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle file, + int fileInformationClass, + out FileAttributeTagInfo fileInformation, + uint bufferSize); +} diff --git a/docs/resource-junction-manager.md b/docs/resource-junction-manager.md new file mode 100644 index 00000000..0192634a --- /dev/null +++ b/docs/resource-junction-manager.md @@ -0,0 +1,41 @@ +# 游戏资源链接 + +## 目录选择 + +目标目录默认来自 MaiChartManager 当前配置的游戏目录,并解析到: + +```text +Package\Sinmai_Data\StreamingAssets\A000 +``` + +打开工具时会自动选择源目录。候选包括配置中的游戏目录历史,以及当前游戏所在父目录下的直接子目录。候选必须满足: + +- 与当前目标游戏不同。 +- 可以从游戏根目录或 `Package` 目录解析到 `A000`。 +- `AssetBundleImages`、`MovieData`、`SoundData` 三个目录全部存在、可读取,且自身不是重解析点。 + +工具递归统计三个目录的文件数,以总数最多的候选作为只读源。最高总数并列时不自动选择,用户须手动指定。手动选择在当前程序会话内覆盖自动结果,不修改游戏目录配置。 + +目标目录也可在工具内手动选择。手动目标仅在当前程序会话内生效,不修改 MaiChartManager 当前游戏目录,不触发游戏数据重新加载。若新目标与当前源相同,工具会清空源选择并要求重新选择,禁止同目录自链接。 + +## 固定范围 + +仅检查和操作: + +- `AssetBundleImages` +- `MovieData` +- `SoundData` + +## 安全规则 + +- 不写入、移动或删除源目录中的任何内容。 +- 自动发现和文件计数只读,不扫描整块磁盘。 +- 建立操作只处理目标位置不存在的项目;普通目录、文件、符号链接和错误 Junction 均拒绝覆盖。 +- 移除操作只处理正确指向当前所选源目录的 Windows Junction,并使用非递归目录删除。 +- 每次写操作前重新检查目标类型和指向,操作后再次验证状态。 +- 写操作和手动目录选择仅限本地桌面模式,并要求专用请求头;远程导出模式返回拒绝。 +- Linux 构建只返回不支持,不执行 Junction 操作。 + +## 使用 + +打开“工具”中的“游戏资源链接”。界面会自动选择源目录,并以当前游戏作为默认目标,显示三类资源及合计文件数,然后读取三项链接状态。源和目标各自的选择按钮位于对应目录右侧,均可选择游戏根目录或 `Package` 目录。建立和移除操作均会弹出二次确认。 From 8336763094f2a50a3eba8ff5c80744ea89aa8b60 Mon Sep 17 00:00:00 2001 From: UranusNo7 <2023120205@njpji.edu.cn> Date: Sun, 2 Aug 2026 19:39:35 +0800 Subject: [PATCH 2/8] fix: address resource junction review feedback --- .../MaiChartManager.Tests.csproj | 4 +- .../Services/ResourceJunctionServiceTests.cs | 80 ++++++++++++++++++- .../Tools/ResourceJunctionController.cs | 3 +- MaiChartManager/Front/src/client/apiGen.ts | 15 +--- .../src/views/Tools/ResourceJunctionModal.tsx | 18 +++-- .../Services/ResourceJunctionService.cs | 28 ++++++- docs/resource-junction-manager.md | 2 +- 7 files changed, 126 insertions(+), 24 deletions(-) diff --git a/MaiChartManager.Tests/MaiChartManager.Tests.csproj b/MaiChartManager.Tests/MaiChartManager.Tests.csproj index 98afb4c1..3941a31c 100644 --- a/MaiChartManager.Tests/MaiChartManager.Tests.csproj +++ b/MaiChartManager.Tests/MaiChartManager.Tests.csproj @@ -1,6 +1,8 @@ - net10.0-windows10.0.17763.0 + true + net10.0-windows10.0.17763.0 + net10.0 enable enable false diff --git a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs index 0fa372a5..6411b22f 100644 --- a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs +++ b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs @@ -2,9 +2,13 @@ namespace MaiChartManager.Tests.Services; +[CollectionDefinition("Static settings", DisableParallelization = true)] +public sealed class StaticSettingsCollection; + +[Collection("Static settings")] public sealed class ResourceJunctionServiceTests : IDisposable { - private readonly string root = Path.Combine(Path.GetTempPath(), $"mcm-resource-links-{Guid.NewGuid():N}"); + private readonly string root = Path.Combine(Path.GetTempPath(), $"mcm resource links & {Guid.NewGuid():N}"); private readonly string sourceRoot; private readonly string targetRoot; @@ -22,6 +26,7 @@ public ResourceJunctionServiceTests() public void FixedScopeContainsOnlyThreeResourceDirectories() { Assert.Equal(["AssetBundleImages", "MovieData", "SoundData"], ResourceJunctionService.ResourceNames); + Assert.Throws(() => ((IList)ResourceJunctionService.ResourceNames)[0] = "OtherDirectory"); } [Fact] @@ -76,6 +81,31 @@ public void AutoSelectionRequiresManualChoiceWhenHighestCountsTie() Assert.NotNull(overview.Detail); } + [Fact] + public void AutoSelectionFindsSiblingForDirectSinmaiDataLayout() + { + var targetGame = CreateDirectGame("target-direct", [0, 0, 0]); + var sourceGame = CreateDirectGame("source-direct", [2, 2, 2]); + var previousGamePath = StaticSettings.GamePath; + var previousConfig = StaticSettings.Config; + try + { + StaticSettings.GamePath = targetGame; + StaticSettings.Config = new Config(); + + var overview = new ResourceJunctionService().AutoSelectSource(); + + Assert.Equal(ResourceSourceSelectionMode.Automatic, overview.SelectionMode); + Assert.Equal(Path.Combine(sourceGame, "Sinmai_Data", "StreamingAssets", "A000"), overview.SourceRoot); + Assert.Equal(6, overview.TotalFileCount); + } + finally + { + StaticSettings.GamePath = previousGamePath; + StaticSettings.Config = previousConfig; + } + } + [Fact] public void AutoSelectionRejectsResourceDirectoryJunctions() { @@ -102,6 +132,38 @@ public void AutoSelectionRejectsResourceDirectoryJunctions() } } + [Fact] + public void AutoSelectionDoesNotTraverseNestedReparsePoints() + { + if (!OperatingSystem.IsWindows()) return; + var targetGame = CreateGame("target-game-nested-reparse", [0, 0, 0]); + var sourceGame = CreateGame("source-game-nested-reparse", [1, 1, 1]); + var externalDirectory = Path.Combine(root, "external-resource-files"); + Directory.CreateDirectory(externalDirectory); + File.WriteAllText(Path.Combine(externalDirectory, "outside.dat"), "outside"); + var nestedJunction = Path.Combine( + sourceGame, + "Package", + "Sinmai_Data", + "StreamingAssets", + "A000", + "AssetBundleImages", + "external-link"); + CreateJunction(externalDirectory, nestedJunction); + try + { + var service = new ResourceJunctionService(() => targetGame, () => [sourceGame]); + + var overview = service.AutoSelectSource(); + + Assert.Equal(3, overview.TotalFileCount); + } + finally + { + Directory.Delete(nestedJunction, false); + } + } + [Fact] public void ManualSelectionOverridesAutomaticSelectionForSession() { @@ -249,7 +311,7 @@ private string CreateGame(string name, int[] fileCounts) { var gameRoot = Path.Combine(root, name); var a000 = Path.Combine(gameRoot, "Package", "Sinmai_Data", "StreamingAssets", "A000"); - for (var resourceIndex = 0; resourceIndex < ResourceJunctionService.ResourceNames.Length; resourceIndex++) + for (var resourceIndex = 0; resourceIndex < ResourceJunctionService.ResourceNames.Count; resourceIndex++) { var resourceRoot = Path.Combine(a000, ResourceJunctionService.ResourceNames[resourceIndex]); var nestedRoot = Path.Combine(resourceRoot, "nested"); @@ -259,4 +321,18 @@ private string CreateGame(string name, int[] fileCounts) } return gameRoot; } + + private string CreateDirectGame(string name, int[] fileCounts) + { + var gameRoot = Path.Combine(root, name); + var a000 = Path.Combine(gameRoot, "Sinmai_Data", "StreamingAssets", "A000"); + for (var resourceIndex = 0; resourceIndex < ResourceJunctionService.ResourceNames.Count; resourceIndex++) + { + var resourceRoot = Path.Combine(a000, ResourceJunctionService.ResourceNames[resourceIndex]); + Directory.CreateDirectory(resourceRoot); + for (var fileIndex = 0; fileIndex < fileCounts[resourceIndex]; fileIndex++) + File.WriteAllText(Path.Combine(resourceRoot, $"{fileIndex}.dat"), "test"); + } + return gameRoot; + } } diff --git a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs index 91631a0b..c68d10eb 100644 --- a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs +++ b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs @@ -18,10 +18,11 @@ public ActionResult GetResourceJunctionStatus() return Ok(service.GetOverview()); } - [HttpGet] + [HttpPost] public ActionResult AutoSelectResourceJunctionSource() { if (StaticSettings.Config.Export) return Forbid(); + if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); return Ok(service.AutoSelectSource()); } diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index 86f87f78..f0ede6e7 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -17,14 +17,6 @@ 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", @@ -413,7 +405,8 @@ export interface RequestExportMaidataRequest { export interface RequestPurchaseResult { errorMessage?: string | null; - status?: StorePurchaseStatus; + /** @format int32 */ + status?: number; } export interface ResourceDirectoryFileCount { @@ -2742,12 +2735,12 @@ export class Api< * * @tags ResourceJunction * @name AutoSelectResourceJunctionSource - * @request GET:/MaiChartManagerServlet/AutoSelectResourceJunctionSourceApi + * @request POST:/MaiChartManagerServlet/AutoSelectResourceJunctionSourceApi */ AutoSelectResourceJunctionSource: (params: RequestParams = {}) => this.request({ path: `/MaiChartManagerServlet/AutoSelectResourceJunctionSourceApi`, - method: "GET", + method: "POST", format: "json", ...params, }), diff --git a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx index 147eecfe..3a2f705c 100644 --- a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx +++ b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx @@ -12,15 +12,19 @@ export default defineComponent({ const overview = ref(); const items = computed(() => overview.value?.items ?? []); - const canCreate = computed(() => items.value.some(item => item.status === 'Ready')); - const canRemove = computed(() => items.value.some(item => item.status === 'AlreadyLinked')); + const canCreate = computed(() => items.value.some(item => ( + item.status === ResourceJunctionStatus.Ready || item.status === ResourceJunctionStatus.Removed + ))); + const canRemove = computed(() => items.value.some(item => ( + item.status === ResourceJunctionStatus.AlreadyLinked || item.status === ResourceJunctionStatus.Created + ))); const request = async (action: 'auto' | 'status' | 'manual' | 'manualTarget' | 'create' | 'remove') => { loading.value = true; try { const writeParams = { headers: { 'X-MCM-Local-Action': 'resource-junction' } }; const response = action === 'auto' - ? await api.AutoSelectResourceJunctionSource() + ? await api.AutoSelectResourceJunctionSource(writeParams) : action === 'status' ? await api.GetResourceJunctionStatus() : action === 'manual' @@ -61,8 +65,12 @@ export default defineComponent({ expose({ trigger }); const statusClass = (status?: ResourceJunctionStatus) => { - if (['Created', 'AlreadyLinked', 'Removed'].includes(status)) return 'text-green-700'; - if (status === 'Ready') return 'text-blue-700'; + if ( + status === ResourceJunctionStatus.Created + || status === ResourceJunctionStatus.AlreadyLinked + || status === ResourceJunctionStatus.Removed + ) return 'text-green-700'; + if (status === ResourceJunctionStatus.Ready) return 'text-blue-700'; return 'text-red-700'; }; diff --git a/MaiChartManager/Services/ResourceJunctionService.cs b/MaiChartManager/Services/ResourceJunctionService.cs index 49d8d764..48cc8531 100644 --- a/MaiChartManager/Services/ResourceJunctionService.cs +++ b/MaiChartManager/Services/ResourceJunctionService.cs @@ -46,7 +46,8 @@ public record ResourceJunctionOverview( public class ResourceJunctionService { - public static readonly string[] ResourceNames = ["AssetBundleImages", "MovieData", "SoundData"]; + public static IReadOnlyList ResourceNames { get; } = + Array.AsReadOnly(["AssetBundleImages", "MovieData", "SoundData"]); private const uint IoReparseTagMountPoint = 0xA0000003; private readonly Func targetPathProvider; @@ -267,7 +268,9 @@ private static IEnumerable GetDefaultCandidatePaths() { var fullPath = NormalizePath(currentPath); gameRoot = Directory.Exists(Path.Combine(fullPath, "Sinmai_Data", "StreamingAssets", "A000")) - ? Directory.GetParent(fullPath)?.FullName + ? string.Equals(Path.GetFileName(fullPath), "Package", StringComparison.OrdinalIgnoreCase) + ? Directory.GetParent(fullPath)?.FullName + : fullPath : Directory.Exists(Path.Combine(fullPath, "Package", "Sinmai_Data", "StreamingAssets", "A000")) ? fullPath : null; @@ -314,10 +317,29 @@ private static IReadOnlyList CountResourceFiles(stri var directory = new DirectoryInfo(Path.Combine(root, name)); if (!directory.Exists || (directory.Attributes & FileAttributes.ReparsePoint) != 0) throw new IOException($"{name} is missing or is a reparse point."); - return new ResourceDirectoryFileCount(name, directory.EnumerateFiles("*", SearchOption.AllDirectories).LongCount()); + return new ResourceDirectoryFileCount(name, CountFilesWithoutReparsePoints(directory)); }).ToArray(); } + private static long CountFilesWithoutReparsePoints(DirectoryInfo root) + { + var count = 0L; + var pending = new Stack(); + pending.Push(root); + while (pending.TryPop(out var directory)) + { + foreach (var entry in directory.EnumerateFileSystemInfos()) + { + if ((entry.Attributes & FileAttributes.ReparsePoint) != 0) continue; + if (entry is DirectoryInfo child) + pending.Push(child); + else + count++; + } + } + return count; + } + private IReadOnlyList BuildUnavailableItems(string? targetRoot) { var status = targetRoot is null ? ResourceJunctionStatus.TargetRootMissing : ResourceJunctionStatus.SourceMissing; diff --git a/docs/resource-junction-manager.md b/docs/resource-junction-manager.md index 0192634a..18c6d472 100644 --- a/docs/resource-junction-manager.md +++ b/docs/resource-junction-manager.md @@ -29,7 +29,7 @@ Package\Sinmai_Data\StreamingAssets\A000 ## 安全规则 - 不写入、移动或删除源目录中的任何内容。 -- 自动发现和文件计数只读,不扫描整块磁盘。 +- 自动发现和文件计数只读,不扫描整块磁盘,也不会进入资源目录内部的重解析点。 - 建立操作只处理目标位置不存在的项目;普通目录、文件、符号链接和错误 Junction 均拒绝覆盖。 - 移除操作只处理正确指向当前所选源目录的 Windows Junction,并使用非递归目录删除。 - 每次写操作前重新检查目标类型和指向,操作后再次验证状态。 From 5f932b1db2201d9b8a2d6044067d79aa7e1f0155 Mon Sep 17 00:00:00 2001 From: UranusNo7 <2023120205@njpji.edu.cn> Date: Sun, 2 Aug 2026 20:16:49 +0800 Subject: [PATCH 3/8] fix: address resource junction review feedback --- .../Tools/ResourceJunctionController.cs | 27 ++++++++++--------- .../src/views/Tools/ResourceJunctionModal.tsx | 16 ++++++++++- MaiChartManager/Locale.resx | 6 +++++ MaiChartManager/Locale.zh-Hans.resx | 6 +++++ MaiChartManager/Locale.zh-Hant.resx | 6 +++++ docs/resource-junction-manager.md | 2 +- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs index c68d10eb..18bc9631 100644 --- a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs +++ b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs @@ -21,18 +21,17 @@ public ActionResult GetResourceJunctionStatus() [HttpPost] public ActionResult AutoSelectResourceJunctionSource() { - if (StaticSettings.Config.Export) return Forbid(); - if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + if (RejectUnavailableLocalAction() is { } rejection) return rejection; return Ok(service.AutoSelectSource()); } [HttpPost] public ActionResult SelectResourceJunctionSource() { - if (StaticSettings.Config.Export) return Forbid(); - if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + if (RejectUnavailableLocalAction() is { } rejection) return rejection; - var path = dialogService.PickFolder("Select a source game directory or Package directory"); + var path = dialogService.PickFolder( + Locale.ResourceManager.GetString("SelectResourceJunctionSourceFolder", Locale.Culture)); if (path is null) return Ok(service.GetOverview()); try { @@ -51,10 +50,10 @@ public ActionResult SelectResourceJunctionSource() [HttpPost] public ActionResult SelectResourceJunctionTarget() { - if (StaticSettings.Config.Export) return Forbid(); - if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + if (RejectUnavailableLocalAction() is { } rejection) return rejection; - var path = dialogService.PickFolder("Select a target game directory or Package directory"); + var path = dialogService.PickFolder( + Locale.ResourceManager.GetString("SelectResourceJunctionTargetFolder", Locale.Culture)); if (path is null) return Ok(service.GetOverview()); try { @@ -69,8 +68,7 @@ public ActionResult SelectResourceJunctionTarget() [HttpPost] public ActionResult CreateResourceJunctions() { - if (StaticSettings.Config.Export) return Forbid(); - if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + if (RejectUnavailableLocalAction() is { } rejection) return rejection; var items = service.CreateLinks(); return Ok(service.GetOverview() with { Items = items }); } @@ -78,9 +76,14 @@ public ActionResult CreateResourceJunctions() [HttpPost] public ActionResult RemoveResourceJunctions() { - if (StaticSettings.Config.Export) return Forbid(); - if (Request.Headers[LocalActionHeader] != LocalActionValue) return BadRequest(); + if (RejectUnavailableLocalAction() is { } rejection) return rejection; var items = service.RemoveLinks(); return Ok(service.GetOverview() with { Items = items }); } + + private ActionResult? RejectUnavailableLocalAction() + { + if (StaticSettings.Config.Export) return Forbid(); + return Request.Headers[LocalActionHeader] != LocalActionValue ? BadRequest() : null; + } } diff --git a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx index 3a2f705c..18f6378a 100644 --- a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx +++ b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx @@ -19,6 +19,16 @@ export default defineComponent({ item.status === ResourceJunctionStatus.AlreadyLinked || item.status === ResourceJunctionStatus.Created ))); + const errorDetail = (error: unknown) => { + if (!error || typeof error !== 'object') return typeof error === 'string' ? error : undefined; + const payload = 'error' in error ? error.error : error; + if (typeof payload === 'string') return payload; + if (!payload || typeof payload !== 'object') return error instanceof Error ? error.message : undefined; + const detail = payload as { message?: unknown; error?: unknown }; + if (typeof detail.message === 'string') return detail.message; + return typeof detail.error === 'string' ? detail.error : undefined; + }; + const request = async (action: 'auto' | 'status' | 'manual' | 'manualTarget' | 'create' | 'remove') => { loading.value = true; try { @@ -37,7 +47,11 @@ export default defineComponent({ overview.value = response.data; } catch (error) { console.error(error); - addToast({ message: t('tools.resourceJunction.requestFailed'), type: 'error' }); + const detail = errorDetail(error); + addToast({ + message: detail ? `${t('tools.resourceJunction.requestFailed')}: ${detail}` : t('tools.resourceJunction.requestFailed'), + type: 'error', + }); } finally { loading.value = false; } diff --git a/MaiChartManager/Locale.resx b/MaiChartManager/Locale.resx index 6c7f2324..adb2171d 100644 --- a/MaiChartManager/Locale.resx +++ b/MaiChartManager/Locale.resx @@ -334,4 +334,10 @@ If you notice any issues with the conversion result, you can try testing it in A Unsupported file format + + Select a source game directory or Package directory + + + Select a target game directory or Package directory + diff --git a/MaiChartManager/Locale.zh-Hans.resx b/MaiChartManager/Locale.zh-Hans.resx index c8536ba3..67a5e5cf 100644 --- a/MaiChartManager/Locale.zh-Hans.resx +++ b/MaiChartManager/Locale.zh-Hans.resx @@ -326,4 +326,10 @@ 不支持的文件格式 + + 选择源游戏目录或 Package 目录 + + + 选择目标游戏目录或 Package 目录 + diff --git a/MaiChartManager/Locale.zh-Hant.resx b/MaiChartManager/Locale.zh-Hant.resx index 72dbff24..1e2efd88 100644 --- a/MaiChartManager/Locale.zh-Hant.resx +++ b/MaiChartManager/Locale.zh-Hant.resx @@ -326,4 +326,10 @@ 不支援的檔案格式 + + 選擇來源遊戲目錄或 Package 目錄 + + + 選擇目標遊戲目錄或 Package 目錄 + diff --git a/docs/resource-junction-manager.md b/docs/resource-junction-manager.md index 18c6d472..a093a6c8 100644 --- a/docs/resource-junction-manager.md +++ b/docs/resource-junction-manager.md @@ -38,4 +38,4 @@ Package\Sinmai_Data\StreamingAssets\A000 ## 使用 -打开“工具”中的“游戏资源链接”。界面会自动选择源目录,并以当前游戏作为默认目标,显示三类资源及合计文件数,然后读取三项链接状态。源和目标各自的选择按钮位于对应目录右侧,均可选择游戏根目录或 `Package` 目录。建立和移除操作均会弹出二次确认。 +打开“工具”中的“游戏资源链接”。界面会自动选择源目录,并以当前游戏作为默认目标,显示三类资源及合计文件数,然后读取三项链接状态。源和目标各自的选择按钮位于对应目录右侧,均可选择游戏根目录或 `Package` 目录。建立和移除操作均会弹出二次确认。目录校验失败时,界面会在通用失败提示后显示后端返回的具体原因。 From dcea6fb84ed922a114deadebc29e9b95e642eb8d Mon Sep 17 00:00:00 2001 From: Clansty Date: Wed, 12 Aug 2026 11:31:46 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E9=99=90=E5=88=B6=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=8E=A5=E5=8F=A3=E4=BB=85=E6=8E=A5=E5=8F=97?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根据实际连接来源拒绝非 loopback 的资源链接状态与写操作 - 添加控制器测试,确保远程请求不会触发桌面目录选择 --- .../Tools/ResourceJunctionControllerTests.cs | 67 +++++++++++++++++++ .../Tools/ResourceJunctionController.cs | 10 ++- 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs diff --git a/MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs b/MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs new file mode 100644 index 00000000..ef0de4bb --- /dev/null +++ b/MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using MaiChartManager.Controllers.Tools; +using MaiChartManager.Platform; +using MaiChartManager.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace MaiChartManager.Tests.Controllers.Tools; + +[Collection("Static settings")] +public sealed class ResourceJunctionControllerTests +{ + [Fact] + public void StatusRejectsRemoteRequests() + { + var controller = CreateController(IPAddress.Parse("192.0.2.1")); + + var result = controller.GetResourceJunctionStatus(); + + var status = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status403Forbidden, status.StatusCode); + } + + [Fact] + public void WriteActionRejectsRemoteRequestsBeforeUsingDesktopCapabilities() + { + var dialogService = new RecordingDialogService(); + var controller = CreateController(IPAddress.Parse("192.0.2.1"), dialogService); + controller.Request.Headers["X-MCM-Local-Action"] = "resource-junction"; + + var result = controller.SelectResourceJunctionSource(); + + var status = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status403Forbidden, status.StatusCode); + Assert.Equal(0, dialogService.PickFolderCalls); + } + + private static ResourceJunctionController CreateController( + IPAddress remoteAddress, + RecordingDialogService? dialogService = null) + { + StaticSettings.Config = new Config { Export = false }; + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = remoteAddress; + return new ResourceJunctionController( + new ResourceJunctionService(() => string.Empty, () => []), + dialogService ?? new RecordingDialogService()) + { + ControllerContext = new ControllerContext { HttpContext = context }, + }; + } + + private sealed class RecordingDialogService : IDesktopDialogService + { + public int PickFolderCalls { get; private set; } + + public string? PickFolder(string? title = null) + { + PickFolderCalls++; + return null; + } + + public string? PickFile(string? title = null, string? filter = null) => null; + public bool Confirm(string message, string title, bool defaultResult = false) => defaultResult; + public void ShowError(string message, string title) { } + } +} diff --git a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs index 18bc9631..5f565cf7 100644 --- a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs +++ b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs @@ -1,3 +1,4 @@ +using System.Net; using MaiChartManager.Platform; using MaiChartManager.Services; using Microsoft.AspNetCore.Mvc; @@ -14,7 +15,8 @@ public class ResourceJunctionController(ResourceJunctionService service, IDeskto [HttpGet] public ActionResult GetResourceJunctionStatus() { - if (StaticSettings.Config.Export) return Forbid(); + if (!IsLoopbackRequest() || StaticSettings.Config.Export) + return StatusCode(StatusCodes.Status403Forbidden); return Ok(service.GetOverview()); } @@ -83,7 +85,11 @@ public ActionResult RemoveResourceJunctions() private ActionResult? RejectUnavailableLocalAction() { - if (StaticSettings.Config.Export) return Forbid(); + if (!IsLoopbackRequest() || StaticSettings.Config.Export) + return StatusCode(StatusCodes.Status403Forbidden); return Request.Headers[LocalActionHeader] != LocalActionValue ? BadRequest() : null; } + + private bool IsLoopbackRequest() + => HttpContext.Connection.RemoteIpAddress is { } remoteIp && IPAddress.IsLoopback(remoteIp); } From b103ac0914c5709e8812ddf775cd64333a5ed9de Mon Sep 17 00:00:00 2001 From: Clansty Date: Wed, 12 Aug 2026 11:34:39 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=E9=80=9A=E8=BF=87=E5=8F=A5=E6=9F=84?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E7=A7=BB=E9=99=A4=E8=B5=84=E6=BA=90=20Juncti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在打开并验证的重解析点句柄上执行删除 - 增加 Junction 替换竞态回归测试 - 允许测试程序集访问句柄删除测试入口 --- .../Services/ResourceJunctionServiceTests.cs | 30 +++++++ MaiChartManager/Properties/AssemblyInfo.cs | 2 + .../Services/ResourceJunctionService.cs | 81 ++++++++++++++++++- 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs index 6411b22f..ee62595c 100644 --- a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs +++ b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs @@ -277,6 +277,36 @@ public void WrongJunctionTargetIsNotRemoved() Assert.True(Directory.Exists(target)); } + [Fact] + public void VerifiedJunctionCannotBeReplacedBeforeDeletion() + { + if (!OperatingSystem.IsWindows()) return; + var source = Path.Combine(sourceRoot, ResourceJunctionService.ResourceNames[0]); + var target = Path.Combine(targetRoot, ResourceJunctionService.ResourceNames[0]); + CreateJunction(source, target); + var replacementSucceeded = false; + + var removed = ResourceJunctionService.RemoveVerifiedJunction( + source, + target, + () => + { + try + { + Directory.Delete(target, false); + Directory.CreateDirectory(target); + replacementSucceeded = true; + } + catch (IOException) + { + } + }); + + Assert.True(removed); + Assert.False(replacementSucceeded); + Assert.False(Directory.Exists(target)); + } + public void Dispose() { if (!Directory.Exists(root)) return; diff --git a/MaiChartManager/Properties/AssemblyInfo.cs b/MaiChartManager/Properties/AssemblyInfo.cs index 1e4a96b7..b447d794 100644 --- a/MaiChartManager/Properties/AssemblyInfo.cs +++ b/MaiChartManager/Properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using MaiChartManager; [assembly: AssemblyCompany("Clansty")] @@ -8,6 +9,7 @@ // Linux 由 Packaging/arch/PKGBUILD 重写,两端一致地从 git tag 派生。 [assembly: AssemblyFileVersion(AppMain.Version)] [assembly: AssemblyInformationalVersion(AppMain.Version)] +[assembly: InternalsVisibleTo("MaiChartManager.Tests")] [assembly: AssemblyVersion(AppMain.Version)] #if WINDOWS [assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows10.0.17763.0")] diff --git a/MaiChartManager/Services/ResourceJunctionService.cs b/MaiChartManager/Services/ResourceJunctionService.cs index 48cc8531..63146cd0 100644 --- a/MaiChartManager/Services/ResourceJunctionService.cs +++ b/MaiChartManager/Services/ResourceJunctionService.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; @@ -206,7 +207,8 @@ public IReadOnlyList RemoveLinks() try { - Directory.Delete(item.Target, false); + if (!RemoveVerifiedJunction(item.Source, item.Target)) + return Inspect(name, sourceRoot, targetRoot); var verified = Inspect(name, sourceRoot, targetRoot); return verified.Status == ResourceJunctionStatus.Ready ? verified with { Status = ResourceJunctionStatus.Removed } @@ -428,6 +430,57 @@ private static void CreateJunction(string source, string target) throw new IOException((error.Length > 0 ? error : output).Trim()); } + internal static bool RemoveVerifiedJunction(string source, string target, Action? beforeDelete = null) + { + using var handle = CreateFile( + target, + 0x00010000, + 0x00000001 | 0x00000002, + IntPtr.Zero, + 3, + 0x00200000 | 0x02000000, + IntPtr.Zero); + if (handle.IsInvalid) return false; + + var buffer = new byte[16 * 1024]; + if (!DeviceIoControl(handle, 0x000900A8, IntPtr.Zero, 0, buffer, buffer.Length, out _, IntPtr.Zero)) + return false; + if (BitConverter.ToUInt32(buffer, 0) != IoReparseTagMountPoint) + return false; + + var destination = ReadMountPointDestination(buffer); + if (destination is null || !SamePath(destination, source)) + return false; + + beforeDelete?.Invoke(); + var disposition = new FileDispositionInfo { DeleteFile = 1 }; + if (!SetFileInformationByHandle( + handle, + 4, + ref disposition, + (uint)Marshal.SizeOf())) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return true; + } + + private static string? ReadMountPointDestination(byte[] buffer) + { + const int pathBufferOffset = 16; + var printNameOffset = BitConverter.ToUInt16(buffer, 12); + var printNameLength = BitConverter.ToUInt16(buffer, 14); + if (printNameLength > 0) + return System.Text.Encoding.Unicode.GetString(buffer, pathBufferOffset + printNameOffset, printNameLength); + + var substituteNameOffset = BitConverter.ToUInt16(buffer, 8); + var substituteNameLength = BitConverter.ToUInt16(buffer, 10); + if (substituteNameLength == 0) return null; + var substituteName = System.Text.Encoding.Unicode.GetString( + buffer, + pathBufferOffset + substituteNameOffset, + substituteNameLength); + return substituteName.StartsWith(@"\??\", StringComparison.Ordinal) ? substituteName[4..] : substituteName; + } + private static bool TryGetReparseTag(string path, out uint tag) { tag = 0; @@ -459,6 +512,12 @@ private struct FileAttributeTagInfo public uint ReparseTag; } + [StructLayout(LayoutKind.Sequential)] + private struct FileDispositionInfo + { + public byte DeleteFile; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern SafeFileHandle CreateFile( string fileName, @@ -476,4 +535,24 @@ private static extern bool GetFileInformationByHandleEx( int fileInformationClass, out FileAttributeTagInfo fileInformation, uint bufferSize); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeviceIoControl( + SafeFileHandle device, + uint controlCode, + IntPtr inputBuffer, + int inputBufferSize, + [Out] byte[] outputBuffer, + int outputBufferSize, + out int bytesReturned, + IntPtr overlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle( + SafeFileHandle file, + int fileInformationClass, + ref FileDispositionInfo fileInformation, + uint bufferSize); } From fdfb8609858bc62c2edc3655568ca10577b208b2 Mon Sep 17 00:00:00 2001 From: Clansty Date: Wed, 12 Aug 2026 11:40:00 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=E9=9A=94=E7=A6=BB=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E4=BC=9A=E8=AF=9D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 session id 保存源目录、目标目录和选择结果 - 为每个会话串行化资源链接操作,避免并发请求互相覆盖 - 移除额外本地操作认证头,仅依赖 loopback 来源校验 --- .../Services/ResourceJunctionServiceTests.cs | 19 ++ .../Tools/ResourceJunctionController.cs | 46 ++--- .../src/views/Tools/ResourceJunctionModal.tsx | 9 +- .../Services/ResourceJunctionService.cs | 165 +++++++++++------- 4 files changed, 155 insertions(+), 84 deletions(-) diff --git a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs index ee62595c..0996817f 100644 --- a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs +++ b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs @@ -182,6 +182,25 @@ public void ManualSelectionOverridesAutomaticSelectionForSession() Assert.Equal(3, overview.TotalFileCount); } + [Fact] + public void SelectionStateIsIsolatedBetweenSessions() + { + var targetGame = CreateGame("target-game-session-isolation", [0, 0, 0]); + var firstSource = CreateGame("first-source-session-isolation", [1, 1, 1]); + var secondSource = CreateGame("second-source-session-isolation", [2, 2, 2]); + var service = new ResourceJunctionService(() => targetGame, () => [firstSource, secondSource]); + + service.SelectManualSource(firstSource, "first"); + service.SelectManualSource(secondSource, "second"); + + Assert.Equal( + Path.Combine(firstSource, "Package", "Sinmai_Data", "StreamingAssets", "A000"), + service.GetOverview("first").SourceRoot); + Assert.Equal( + Path.Combine(secondSource, "Package", "Sinmai_Data", "StreamingAssets", "A000"), + service.GetOverview("second").SourceRoot); + } + [Fact] public void ManualSelectionRejectsCurrentGame() { diff --git a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs index 5f565cf7..140e6583 100644 --- a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs +++ b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs @@ -9,35 +9,34 @@ namespace MaiChartManager.Controllers.Tools; [Route("MaiChartManagerServlet/[action]Api")] public class ResourceJunctionController(ResourceJunctionService service, IDesktopDialogService dialogService) : ControllerBase { - private const string LocalActionHeader = "X-MCM-Local-Action"; - private const string LocalActionValue = "resource-junction"; + private const string SessionHeader = "X-MCM-Resource-Junction-Session"; [HttpGet] public ActionResult GetResourceJunctionStatus() { - if (!IsLoopbackRequest() || StaticSettings.Config.Export) + if (!IsLoopbackRequest()) return StatusCode(StatusCodes.Status403Forbidden); - return Ok(service.GetOverview()); + return Ok(service.GetOverview(GetSessionId())); } [HttpPost] public ActionResult AutoSelectResourceJunctionSource() { - if (RejectUnavailableLocalAction() is { } rejection) return rejection; - return Ok(service.AutoSelectSource()); + if (RejectRemoteRequest() is { } rejection) return rejection; + return Ok(service.AutoSelectSource(GetSessionId())); } [HttpPost] public ActionResult SelectResourceJunctionSource() { - if (RejectUnavailableLocalAction() is { } rejection) return rejection; + if (RejectRemoteRequest() is { } rejection) return rejection; var path = dialogService.PickFolder( Locale.ResourceManager.GetString("SelectResourceJunctionSourceFolder", Locale.Culture)); - if (path is null) return Ok(service.GetOverview()); + if (path is null) return Ok(service.GetOverview(GetSessionId())); try { - return Ok(service.SelectManualSource(path)); + return Ok(service.SelectManualSource(path, GetSessionId())); } catch (ArgumentException e) { @@ -52,14 +51,14 @@ public ActionResult SelectResourceJunctionSource() [HttpPost] public ActionResult SelectResourceJunctionTarget() { - if (RejectUnavailableLocalAction() is { } rejection) return rejection; + if (RejectRemoteRequest() is { } rejection) return rejection; var path = dialogService.PickFolder( Locale.ResourceManager.GetString("SelectResourceJunctionTargetFolder", Locale.Culture)); - if (path is null) return Ok(service.GetOverview()); + if (path is null) return Ok(service.GetOverview(GetSessionId())); try { - return Ok(service.SelectManualTarget(path)); + return Ok(service.SelectManualTarget(path, GetSessionId())); } catch (ArgumentException e) { @@ -70,26 +69,31 @@ public ActionResult SelectResourceJunctionTarget() [HttpPost] public ActionResult CreateResourceJunctions() { - if (RejectUnavailableLocalAction() is { } rejection) return rejection; - var items = service.CreateLinks(); - return Ok(service.GetOverview() with { Items = items }); + if (RejectRemoteRequest() is { } rejection) return rejection; + var sessionId = GetSessionId(); + var items = service.CreateLinks(sessionId); + return Ok(service.GetOverview(sessionId) with { Items = items }); } [HttpPost] public ActionResult RemoveResourceJunctions() { - if (RejectUnavailableLocalAction() is { } rejection) return rejection; - var items = service.RemoveLinks(); - return Ok(service.GetOverview() with { Items = items }); + if (RejectRemoteRequest() is { } rejection) return rejection; + var sessionId = GetSessionId(); + var items = service.RemoveLinks(sessionId); + return Ok(service.GetOverview(sessionId) with { Items = items }); } - private ActionResult? RejectUnavailableLocalAction() + private ActionResult? RejectRemoteRequest() { - if (!IsLoopbackRequest() || StaticSettings.Config.Export) + if (!IsLoopbackRequest()) return StatusCode(StatusCodes.Status403Forbidden); - return Request.Headers[LocalActionHeader] != LocalActionValue ? BadRequest() : null; + return null; } private bool IsLoopbackRequest() => HttpContext.Connection.RemoteIpAddress is { } remoteIp && IPAddress.IsLoopback(remoteIp); + + private string GetSessionId() + => Request.Headers[SessionHeader].FirstOrDefault() ?? "default"; } diff --git a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx index 18f6378a..5a8e4e12 100644 --- a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx +++ b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx @@ -9,6 +9,7 @@ export default defineComponent({ const { t } = useI18n(); const show = ref(false); const loading = ref(false); + const sessionId = crypto.randomUUID(); const overview = ref(); const items = computed(() => overview.value?.items ?? []); @@ -32,11 +33,15 @@ export default defineComponent({ const request = async (action: 'auto' | 'status' | 'manual' | 'manualTarget' | 'create' | 'remove') => { loading.value = true; try { - const writeParams = { headers: { 'X-MCM-Local-Action': 'resource-junction' } }; + const writeParams = { + headers: { + 'X-MCM-Resource-Junction-Session': sessionId, + }, + }; const response = action === 'auto' ? await api.AutoSelectResourceJunctionSource(writeParams) : action === 'status' - ? await api.GetResourceJunctionStatus() + ? await api.GetResourceJunctionStatus(writeParams) : action === 'manual' ? await api.SelectResourceJunctionSource(writeParams) : action === 'manualTarget' diff --git a/MaiChartManager/Services/ResourceJunctionService.cs b/MaiChartManager/Services/ResourceJunctionService.cs index 63146cd0..1dc76d4a 100644 --- a/MaiChartManager/Services/ResourceJunctionService.cs +++ b/MaiChartManager/Services/ResourceJunctionService.cs @@ -54,11 +54,8 @@ public class ResourceJunctionService private readonly Func targetPathProvider; private readonly Func> candidatePathProvider; private readonly bool pathsAreA000Roots; - private string? selectedSourceRoot; - private string? selectedTargetRoot; - private ResourceSourceSelectionMode selectionMode; - private IReadOnlyList selectedFileCounts = []; - private string? selectionDetail; + private readonly object stateGate = new(); + private readonly Dictionary sessionStates = new(StringComparer.Ordinal); public ResourceJunctionService() : this(() => StaticSettings.GamePath, GetDefaultCandidatePaths, false) @@ -68,9 +65,10 @@ public ResourceJunctionService() public ResourceJunctionService(string sourceRoot, string targetRoot) : this(() => targetRoot, () => [], true) { - selectedSourceRoot = NormalizePath(sourceRoot); - selectionMode = ResourceSourceSelectionMode.Manual; - selectedFileCounts = CountResourceFiles(selectedSourceRoot); + var state = GetState("default"); + state.SelectedSourceRoot = NormalizePath(sourceRoot); + state.SelectionMode = ResourceSourceSelectionMode.Manual; + state.SelectedFileCounts = CountResourceFiles(state.SelectedSourceRoot); } public ResourceJunctionService(Func targetPathProvider, Func> candidatePathProvider) @@ -88,11 +86,14 @@ private ResourceJunctionService( this.pathsAreA000Roots = pathsAreA000Roots; } - public ResourceJunctionOverview AutoSelectSource() + public ResourceJunctionOverview AutoSelectSource(string sessionId = "default") { - var targetRoot = GetTargetRoot(); + lock (stateGate) + { + var state = GetState(sessionId); + var targetRoot = GetTargetRoot(state); if (targetRoot is null) - return ClearSelection(ResourceSourceSelectionMode.None, "The current game directory is invalid."); + return ClearSelection(state, ResourceSourceSelectionMode.None, "The current game directory is invalid."); var candidates = candidatePathProvider() .Select(TryResolveA000Root) @@ -105,22 +106,26 @@ public ResourceJunctionOverview AutoSelectSource() .ToArray(); if (candidates.Length == 0) - return ClearSelection(ResourceSourceSelectionMode.None, "No valid source game directory was found in game path history or adjacent directories."); + return ClearSelection(state, ResourceSourceSelectionMode.None, "No valid source game directory was found in game path history or adjacent directories."); var best = candidates[0]; if (candidates.Skip(1).Any(candidate => candidate.TotalFileCount == best.TotalFileCount)) - return ClearSelection(ResourceSourceSelectionMode.Tie, "Multiple source game directories have the same highest file count. Select one manually."); + return ClearSelection(state, ResourceSourceSelectionMode.Tie, "Multiple source game directories have the same highest file count. Select one manually."); - selectedSourceRoot = best.Root; - selectedFileCounts = best.FileCounts; - selectionMode = ResourceSourceSelectionMode.Automatic; - selectionDetail = null; - return GetOverview(); + state.SelectedSourceRoot = best.Root; + state.SelectedFileCounts = best.FileCounts; + state.SelectionMode = ResourceSourceSelectionMode.Automatic; + state.SelectionDetail = null; + return GetOverviewCore(state); + } } - public ResourceJunctionOverview SelectManualSource(string path) + public ResourceJunctionOverview SelectManualSource(string path, string sessionId = "default") { - var targetRoot = GetTargetRoot() ?? throw new InvalidOperationException("The current game directory is invalid."); + lock (stateGate) + { + var state = GetState(sessionId); + var targetRoot = GetTargetRoot(state) ?? throw new InvalidOperationException("The current game directory is invalid."); var sourceRoot = TryResolveA000Root(path) ?? throw new ArgumentException("The selected folder is not a valid game root or Package directory.", nameof(path)); if (SamePath(sourceRoot, targetRoot)) @@ -128,51 +133,67 @@ public ResourceJunctionOverview SelectManualSource(string path) var candidate = TryCreateCandidate(sourceRoot) ?? throw new ArgumentException("The selected source must contain three readable, real resource directories.", nameof(path)); - selectedSourceRoot = candidate.Root; - selectedFileCounts = candidate.FileCounts; - selectionMode = ResourceSourceSelectionMode.Manual; - selectionDetail = null; - return GetOverview(); + state.SelectedSourceRoot = candidate.Root; + state.SelectedFileCounts = candidate.FileCounts; + state.SelectionMode = ResourceSourceSelectionMode.Manual; + state.SelectionDetail = null; + return GetOverviewCore(state); + } } - public ResourceJunctionOverview SelectManualTarget(string path) + public ResourceJunctionOverview SelectManualTarget(string path, string sessionId = "default") { + lock (stateGate) + { + var state = GetState(sessionId); var targetRoot = TryResolveA000Root(path) ?? throw new ArgumentException("The selected folder is not a valid game root or Package directory.", nameof(path)); - selectedTargetRoot = targetRoot; - if (selectedSourceRoot is not null && SamePath(selectedSourceRoot, targetRoot)) - return ClearSelection(ResourceSourceSelectionMode.None, "The source must differ from the selected target. Select a source directory again."); + state.SelectedTargetRoot = targetRoot; + if (state.SelectedSourceRoot is not null && SamePath(state.SelectedSourceRoot, targetRoot)) + return ClearSelection(state, ResourceSourceSelectionMode.None, "The source must differ from the selected target. Select a source directory again."); - return GetOverview(); + return GetOverviewCore(state); + } + } + + public ResourceJunctionOverview GetOverview(string sessionId = "default") + { + lock (stateGate) + { + return GetOverviewCore(GetState(sessionId)); + } } - public ResourceJunctionOverview GetOverview() + private ResourceJunctionOverview GetOverviewCore(SelectionState state) { - var targetRoot = GetTargetRoot(); - var items = selectedSourceRoot is null || targetRoot is null - ? BuildUnavailableItems(targetRoot) - : ResourceNames.Select(name => Inspect(name, selectedSourceRoot, targetRoot)).ToArray(); + var targetRoot = GetTargetRoot(state); + var items = state.SelectedSourceRoot is null || targetRoot is null + ? BuildUnavailableItems(state, targetRoot) + : ResourceNames.Select(name => Inspect(name, state.SelectedSourceRoot, targetRoot)).ToArray(); return new( - selectedSourceRoot, + state.SelectedSourceRoot, targetRoot, - selectionMode, - selectedFileCounts, - selectedFileCounts.Sum(item => item.FileCount), - selectionDetail, + state.SelectionMode, + state.SelectedFileCounts, + state.SelectedFileCounts.Sum(item => item.FileCount), + state.SelectionDetail, items); } - public IReadOnlyList Inspect() + public IReadOnlyList Inspect(string sessionId = "default") { - return GetOverview().Items; + return GetOverview(sessionId).Items; } - public IReadOnlyList CreateLinks() + public IReadOnlyList CreateLinks(string sessionId = "default") { - var sourceRoot = selectedSourceRoot; - var targetRoot = GetTargetRoot(); - if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(targetRoot); + lock (stateGate) + { + var state = GetState(sessionId); + var sourceRoot = state.SelectedSourceRoot; + var targetRoot = GetTargetRoot(state); + if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(state, targetRoot); return ResourceNames.Select(name => { @@ -192,13 +213,17 @@ public IReadOnlyList CreateLinks() return item with { Status = ResourceJunctionStatus.Failed, Detail = e.Message }; } }).ToArray(); + } } - public IReadOnlyList RemoveLinks() + public IReadOnlyList RemoveLinks(string sessionId = "default") { - var sourceRoot = selectedSourceRoot; - var targetRoot = GetTargetRoot(); - if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(targetRoot); + lock (stateGate) + { + var state = GetState(sessionId); + var sourceRoot = state.SelectedSourceRoot; + var targetRoot = GetTargetRoot(state); + if (sourceRoot is null || targetRoot is null) return BuildUnavailableItems(state, targetRoot); return ResourceNames.Select(name => { @@ -219,20 +244,21 @@ public IReadOnlyList RemoveLinks() return item with { Status = ResourceJunctionStatus.Failed, Detail = e.Message }; } }).ToArray(); + } } - private ResourceJunctionOverview ClearSelection(ResourceSourceSelectionMode mode, string detail) + private ResourceJunctionOverview ClearSelection(SelectionState state, ResourceSourceSelectionMode mode, string detail) { - selectedSourceRoot = null; - selectedFileCounts = []; - selectionMode = mode; - selectionDetail = detail; - return GetOverview(); + state.SelectedSourceRoot = null; + state.SelectedFileCounts = []; + state.SelectionMode = mode; + state.SelectionDetail = detail; + return GetOverviewCore(state); } - private string? GetTargetRoot() + private string? GetTargetRoot(SelectionState state) { - if (selectedTargetRoot is not null) return selectedTargetRoot; + if (state.SelectedTargetRoot is not null) return state.SelectedTargetRoot; var path = targetPathProvider(); if (string.IsNullOrWhiteSpace(path)) return null; if (pathsAreA000Roots) return Directory.Exists(path) ? NormalizePath(path) : null; @@ -342,15 +368,23 @@ private static long CountFilesWithoutReparsePoints(DirectoryInfo root) return count; } - private IReadOnlyList BuildUnavailableItems(string? targetRoot) + private IReadOnlyList BuildUnavailableItems(SelectionState state, string? targetRoot) { var status = targetRoot is null ? ResourceJunctionStatus.TargetRootMissing : ResourceJunctionStatus.SourceMissing; return ResourceNames.Select(name => new ResourceJunctionItem( name, - selectedSourceRoot is null ? "" : Path.Combine(selectedSourceRoot, name), + state.SelectedSourceRoot is null ? "" : Path.Combine(state.SelectedSourceRoot, name), targetRoot is null ? "" : Path.Combine(targetRoot, name), status, - selectionDetail)).ToArray(); + state.SelectionDetail)).ToArray(); + } + + private SelectionState GetState(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) throw new ArgumentException("Session id is required.", nameof(sessionId)); + if (!sessionStates.TryGetValue(sessionId, out var state)) + sessionStates[sessionId] = state = new SelectionState(); + return state; } private static ResourceJunctionItem Inspect(string name, string sourceRoot, string targetRoot) @@ -505,6 +539,15 @@ private record ResourceSourceCandidate( IReadOnlyList FileCounts, long TotalFileCount); + private sealed class SelectionState + { + public string? SelectedSourceRoot { get; set; } + public string? SelectedTargetRoot { get; set; } + public ResourceSourceSelectionMode SelectionMode { get; set; } + public IReadOnlyList SelectedFileCounts { get; set; } = []; + public string? SelectionDetail { get; set; } + } + [StructLayout(LayoutKind.Sequential)] private struct FileAttributeTagInfo { From 8128e6b61f1d837bc69b0a6dd067b0bc5afb551a Mon Sep 17 00:00:00 2001 From: Clansty Date: Wed, 12 Aug 2026 11:45:13 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E6=98=8E=E7=A1=AE=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E7=9A=84=E9=9D=9E=20Windows=20=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 非 Windows 平台统一返回 Unsupported 状态,不打开文件夹选择器 - 提供统一的不支持平台概览并覆盖控制器入口 - 增加不支持平台与概览契约测试 --- .../Tools/ResourceJunctionControllerTests.cs | 30 +++++++++++++++++-- .../Services/ResourceJunctionServiceTests.cs | 26 ++++++++++++++++ .../Tools/ResourceJunctionController.cs | 6 ++++ .../Services/ResourceJunctionService.cs | 15 ++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs b/MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs index ef0de4bb..e4b7bc4c 100644 --- a/MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs +++ b/MaiChartManager.Tests/Controllers/Tools/ResourceJunctionControllerTests.cs @@ -26,8 +26,6 @@ public void WriteActionRejectsRemoteRequestsBeforeUsingDesktopCapabilities() { var dialogService = new RecordingDialogService(); var controller = CreateController(IPAddress.Parse("192.0.2.1"), dialogService); - controller.Request.Headers["X-MCM-Local-Action"] = "resource-junction"; - var result = controller.SelectResourceJunctionSource(); var status = Assert.IsType(result.Result); @@ -35,11 +33,36 @@ public void WriteActionRejectsRemoteRequestsBeforeUsingDesktopCapabilities() Assert.Equal(0, dialogService.PickFolderCalls); } + [Fact] + public void LocalWriteActionDoesNotRequireAnAuthenticationHeader() + { + var dialogService = new RecordingDialogService(); + var controller = CreateController(IPAddress.Loopback, dialogService); + + var result = controller.SelectResourceJunctionSource(); + + Assert.IsType(result.Result); + Assert.Equal(1, dialogService.PickFolderCalls); + } + + [Fact] + public void LinuxUnsupportedStatusDoesNotOpenFolderPicker() + { + if (OperatingSystem.IsWindows()) return; + var dialogService = new RecordingDialogService(); + var controller = CreateController(IPAddress.Loopback, dialogService); + + var result = controller.SelectResourceJunctionSource(); + + Assert.IsType(result.Result); + Assert.Equal(0, dialogService.PickFolderCalls); + } + private static ResourceJunctionController CreateController( IPAddress remoteAddress, RecordingDialogService? dialogService = null) { - StaticSettings.Config = new Config { Export = false }; + StaticSettings.Config = new Config(); var context = new DefaultHttpContext(); context.Connection.RemoteIpAddress = remoteAddress; return new ResourceJunctionController( @@ -64,4 +87,5 @@ private sealed class RecordingDialogService : IDesktopDialogService public bool Confirm(string message, string title, bool defaultResult = false) => defaultResult; public void ShowError(string message, string title) { } } + } diff --git a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs index 0996817f..a9716822 100644 --- a/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs +++ b/MaiChartManager.Tests/Services/ResourceJunctionServiceTests.cs @@ -201,6 +201,32 @@ public void SelectionStateIsIsolatedBetweenSessions() service.GetOverview("second").SourceRoot); } + [Fact] + public void UnsupportedOverviewUsesUnsupportedStatusForEveryResource() + { + var overview = ResourceJunctionService.CreateUnsupportedOverview(); + + Assert.All(overview.Items, item => Assert.Equal(ResourceJunctionStatus.Unsupported, item.Status)); + Assert.Null(overview.SourceRoot); + Assert.Null(overview.TargetRoot); + } + + [Fact] + public void AutoSelectionPreservesManualSourceForTheSameSession() + { + var sourceRoot = CreateGame("manual-source", [1, 2, 3]); + var targetRoot = CreateGame("manual-target", [0, 0, 0]); + var service = new ResourceJunctionService( + () => targetRoot, + () => [sourceRoot]); + + var manual = service.SelectManualSource(sourceRoot, "session"); + var afterAuto = service.AutoSelectSource("session"); + + Assert.Equal(manual.SourceRoot, afterAuto.SourceRoot); + Assert.Equal(ResourceSourceSelectionMode.Manual, afterAuto.SelectionMode); + } + [Fact] public void ManualSelectionRejectsCurrentGame() { diff --git a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs index 140e6583..795cfda2 100644 --- a/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs +++ b/MaiChartManager/Controllers/Tools/ResourceJunctionController.cs @@ -16,6 +16,7 @@ public ActionResult GetResourceJunctionStatus() { if (!IsLoopbackRequest()) return StatusCode(StatusCodes.Status403Forbidden); + if (!OperatingSystem.IsWindows()) return Ok(ResourceJunctionService.CreateUnsupportedOverview()); return Ok(service.GetOverview(GetSessionId())); } @@ -23,6 +24,7 @@ public ActionResult GetResourceJunctionStatus() public ActionResult AutoSelectResourceJunctionSource() { if (RejectRemoteRequest() is { } rejection) return rejection; + if (!OperatingSystem.IsWindows()) return Ok(ResourceJunctionService.CreateUnsupportedOverview()); return Ok(service.AutoSelectSource(GetSessionId())); } @@ -30,6 +32,7 @@ public ActionResult AutoSelectResourceJunctionSource() public ActionResult SelectResourceJunctionSource() { if (RejectRemoteRequest() is { } rejection) return rejection; + if (!OperatingSystem.IsWindows()) return Ok(ResourceJunctionService.CreateUnsupportedOverview()); var path = dialogService.PickFolder( Locale.ResourceManager.GetString("SelectResourceJunctionSourceFolder", Locale.Culture)); @@ -52,6 +55,7 @@ public ActionResult SelectResourceJunctionSource() public ActionResult SelectResourceJunctionTarget() { if (RejectRemoteRequest() is { } rejection) return rejection; + if (!OperatingSystem.IsWindows()) return Ok(ResourceJunctionService.CreateUnsupportedOverview()); var path = dialogService.PickFolder( Locale.ResourceManager.GetString("SelectResourceJunctionTargetFolder", Locale.Culture)); @@ -70,6 +74,7 @@ public ActionResult SelectResourceJunctionTarget() public ActionResult CreateResourceJunctions() { if (RejectRemoteRequest() is { } rejection) return rejection; + if (!OperatingSystem.IsWindows()) return Ok(ResourceJunctionService.CreateUnsupportedOverview()); var sessionId = GetSessionId(); var items = service.CreateLinks(sessionId); return Ok(service.GetOverview(sessionId) with { Items = items }); @@ -79,6 +84,7 @@ public ActionResult CreateResourceJunctions() public ActionResult RemoveResourceJunctions() { if (RejectRemoteRequest() is { } rejection) return rejection; + if (!OperatingSystem.IsWindows()) return Ok(ResourceJunctionService.CreateUnsupportedOverview()); var sessionId = GetSessionId(); var items = service.RemoveLinks(sessionId); return Ok(service.GetOverview(sessionId) with { Items = items }); diff --git a/MaiChartManager/Services/ResourceJunctionService.cs b/MaiChartManager/Services/ResourceJunctionService.cs index 1dc76d4a..2b54daa9 100644 --- a/MaiChartManager/Services/ResourceJunctionService.cs +++ b/MaiChartManager/Services/ResourceJunctionService.cs @@ -57,6 +57,19 @@ public class ResourceJunctionService private readonly object stateGate = new(); private readonly Dictionary sessionStates = new(StringComparer.Ordinal); + internal static ResourceJunctionOverview CreateUnsupportedOverview() + { + var items = ResourceNames + .Select(name => new ResourceJunctionItem( + name, + string.Empty, + string.Empty, + ResourceJunctionStatus.Unsupported, + "Junctions are only supported on Windows.")) + .ToArray(); + return new(null, null, ResourceSourceSelectionMode.None, [], 0, "Junctions are only supported on Windows.", items); + } + public ResourceJunctionService() : this(() => StaticSettings.GamePath, GetDefaultCandidatePaths, false) { @@ -91,6 +104,8 @@ public ResourceJunctionOverview AutoSelectSource(string sessionId = "default") lock (stateGate) { var state = GetState(sessionId); + if (state.SelectionMode == ResourceSourceSelectionMode.Manual && state.SelectedSourceRoot is not null) + return GetOverviewCore(state); var targetRoot = GetTargetRoot(state); if (targetRoot is null) return ClearSelection(state, ResourceSourceSelectionMode.None, "The current game directory is invalid."); From 4474195c092b71f31f420ed234f7e1c4a16a4d0e Mon Sep 17 00:00:00 2001 From: Clansty Date: Wed, 12 Aug 2026 11:45:44 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=89=8B=E5=8A=A8=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 打开资源链接设置时先恢复当前会话状态 - 仅在没有手动来源时执行自动来源探测 --- .../Front/src/views/Tools/ResourceJunctionModal.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx index 5a8e4e12..703564f2 100644 --- a/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx +++ b/MaiChartManager/Front/src/views/Tools/ResourceJunctionModal.tsx @@ -1,5 +1,5 @@ import api from '@/client/api'; -import { ResourceJunctionOverview, ResourceJunctionStatus } from '@/client/apiGen'; +import { ResourceJunctionOverview, ResourceJunctionStatus, ResourceSourceSelectionMode } from '@/client/apiGen'; import { Button, Modal, addToast, showTransactionalDialog } from '@munet/ui'; import { computed, defineComponent, ref } from 'vue'; import { useI18n } from 'vue-i18n'; @@ -76,10 +76,14 @@ export default defineComponent({ await request(action); }; - const trigger = () => { + const trigger = async () => { show.value = true; - overview.value = undefined; - request('auto'); + await request('status'); + if ( + overview.value?.sourceRoot + || overview.value?.selectionMode === ResourceSourceSelectionMode.Manual + ) return; + await request('auto'); }; expose({ trigger });