Skip to content

Commit c57dde9

Browse files
committed
feat(mod): add MuModService for cache and config management
1 parent f2f93c2 commit c57dde9

2 files changed

Lines changed: 337 additions & 1 deletion

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
using System.Diagnostics;
2+
using System.Text.Json;
3+
using MaiChartManager.Utils;
4+
using Tomlyn;
5+
6+
namespace MaiChartManager.Controllers.Mod;
7+
8+
public class MuModService(ILogger<MuModService> logger)
9+
{
10+
private const string CosVersionApiUrl = "https://munet-version-config-1251600285.cos.ap-shanghai.myqcloud.com/aquamai.json";
11+
private const string CfVersionApiUrl = "https://aquamai-version-config.mumur.net/api/config";
12+
private const string DefaultCacheRelativePath = @"LocalAssets\MuMod.cache";
13+
14+
private enum VersionSource
15+
{
16+
Cos,
17+
Cf,
18+
}
19+
20+
private class VersionInfoModel
21+
{
22+
public string Version { get; set; } = string.Empty;
23+
public string Type { get; set; } = string.Empty;
24+
public string Url { get; set; } = string.Empty;
25+
public string? Url2 { get; set; }
26+
}
27+
28+
public class MuModConfigModel
29+
{
30+
public string Channel { get; set; } = "slow";
31+
public string CachePath { get; set; } = DefaultCacheRelativePath;
32+
}
33+
34+
public record EnsureCacheResult(bool Success, string? Version, string? Error);
35+
36+
public MuModConfigModel ReadConfig()
37+
{
38+
if (!File.Exists(ModPaths.MuModConfigPath))
39+
{
40+
return new MuModConfigModel();
41+
}
42+
43+
var text = File.ReadAllText(ModPaths.MuModConfigPath);
44+
var model = Toml.ToModel<MuModConfigModel>(text) ?? new MuModConfigModel();
45+
model.Channel = string.IsNullOrWhiteSpace(model.Channel) ? "slow" : model.Channel;
46+
model.CachePath = string.IsNullOrWhiteSpace(model.CachePath) ? DefaultCacheRelativePath : model.CachePath;
47+
return model;
48+
}
49+
50+
public void WriteChannel(string channel)
51+
{
52+
if (channel != "slow" && channel != "fast")
53+
{
54+
throw new ArgumentException("Channel must be slow or fast", nameof(channel));
55+
}
56+
57+
var config = ReadConfig();
58+
config.Channel = channel;
59+
60+
var content = Toml.FromModel(config);
61+
var targetPath = ModPaths.MuModConfigPath;
62+
var dir = Path.GetDirectoryName(targetPath);
63+
if (!string.IsNullOrEmpty(dir))
64+
{
65+
Directory.CreateDirectory(dir);
66+
}
67+
68+
var tempPath = $"{targetPath}.{Guid.NewGuid():N}.tmp";
69+
try
70+
{
71+
File.WriteAllText(tempPath, content);
72+
File.Move(tempPath, targetPath, true);
73+
}
74+
finally
75+
{
76+
if (File.Exists(tempPath))
77+
{
78+
File.Delete(tempPath);
79+
}
80+
}
81+
}
82+
83+
public string GetResolvedCachePath()
84+
{
85+
var config = ReadConfig();
86+
var rawPath = string.IsNullOrWhiteSpace(config.CachePath) ? DefaultCacheRelativePath : config.CachePath;
87+
88+
if (ContainsParentTraversal(rawPath))
89+
{
90+
throw new InvalidOperationException("MuMod cache path cannot contain '..'");
91+
}
92+
93+
var expandedPath = Environment.ExpandEnvironmentVariables(rawPath.Trim());
94+
var candidate = Path.IsPathRooted(expandedPath)
95+
? expandedPath
96+
: Path.Combine(StaticSettings.GamePath, expandedPath);
97+
98+
var fullPath = Path.GetFullPath(candidate);
99+
var gameRoot = Path.GetFullPath(StaticSettings.GamePath);
100+
if (!IsPathInsideRoot(fullPath, gameRoot))
101+
{
102+
throw new InvalidOperationException($"MuMod cache path escapes game directory: {fullPath}");
103+
}
104+
105+
return fullPath;
106+
}
107+
108+
public async Task<EnsureCacheResult> EnsureCache(CancellationToken ct = default)
109+
{
110+
var cachePath = GetResolvedCachePath();
111+
var hasOldCache = File.Exists(cachePath);
112+
var oldVersion = hasOldCache ? ReadProductVersion(cachePath) : null;
113+
114+
try
115+
{
116+
var apiType = ResolveApiType(ReadConfig().Channel);
117+
var (versionInfo, source) = await ResolveVersionInfoAsync(apiType, ct);
118+
var targetVersion = NormalizeVersion(versionInfo.Version);
119+
120+
if (hasOldCache && string.Equals(NormalizeVersion(oldVersion), targetVersion, StringComparison.OrdinalIgnoreCase))
121+
{
122+
logger.LogInformation("MuMod cache is up-to-date: {Version}", oldVersion);
123+
return new EnsureCacheResult(true, oldVersion, null);
124+
}
125+
126+
var downloadUrls = BuildDownloadUrls(versionInfo, source).ToArray();
127+
if (downloadUrls.Length == 0)
128+
{
129+
throw new InvalidOperationException("No valid download urls found in version API response");
130+
}
131+
132+
var data = await DownloadFromUrlsAsync(downloadUrls, ct);
133+
var verifyResult = AquaMaiSignatureV2.VerifySignature(data);
134+
if (verifyResult.Status != AquaMaiSignatureV2.VerifyStatus.Valid)
135+
{
136+
throw new InvalidOperationException($"MuMod cache signature verification failed: {verifyResult.Status}");
137+
}
138+
139+
var cacheDir = Path.GetDirectoryName(cachePath);
140+
if (!string.IsNullOrWhiteSpace(cacheDir))
141+
{
142+
Directory.CreateDirectory(cacheDir);
143+
}
144+
145+
var tempPath = $"{cachePath}.{Guid.NewGuid():N}.tmp";
146+
try
147+
{
148+
await File.WriteAllBytesAsync(tempPath, data, ct);
149+
File.Move(tempPath, cachePath, true);
150+
}
151+
finally
152+
{
153+
if (File.Exists(tempPath))
154+
{
155+
File.Delete(tempPath);
156+
}
157+
}
158+
159+
var finalVersion = ReadProductVersion(cachePath) ?? targetVersion;
160+
logger.LogInformation("MuMod cache updated successfully to version {Version}", finalVersion);
161+
return new EnsureCacheResult(true, finalVersion, null);
162+
}
163+
catch (Exception ex)
164+
{
165+
logger.LogError(ex, "Failed to ensure MuMod cache");
166+
if (hasOldCache)
167+
{
168+
logger.LogWarning("Using existing MuMod cache due to update failure");
169+
return new EnsureCacheResult(true, oldVersion, null);
170+
}
171+
172+
return new EnsureCacheResult(false, null, ex.Message);
173+
}
174+
}
175+
176+
public string? GetCacheInfo()
177+
{
178+
var cachePath = GetResolvedCachePath();
179+
return File.Exists(cachePath) ? ReadProductVersion(cachePath) : null;
180+
}
181+
182+
public bool IsMuModInstalled()
183+
{
184+
return File.Exists(ModPaths.MuModDllInstalledPath);
185+
}
186+
187+
public string? GetMuModVersion()
188+
{
189+
return File.Exists(ModPaths.MuModDllInstalledPath) ? ReadProductVersion(ModPaths.MuModDllInstalledPath) : null;
190+
}
191+
192+
private static bool ContainsParentTraversal(string path)
193+
{
194+
var segments = path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
195+
return segments.Any(segment => segment == "..");
196+
}
197+
198+
private static bool IsPathInsideRoot(string fullPath, string rootPath)
199+
{
200+
var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
201+
return fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)
202+
|| string.Equals(fullPath, rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
203+
}
204+
205+
private static string ResolveApiType(string channel)
206+
{
207+
return channel switch
208+
{
209+
"slow" => "slow",
210+
"fast" => "ci",
211+
_ => throw new InvalidOperationException($"Unsupported MuMod channel: {channel}"),
212+
};
213+
}
214+
215+
private static string? ReadProductVersion(string path)
216+
{
217+
return FileVersionInfo.GetVersionInfo(path).ProductVersion;
218+
}
219+
220+
private static string? NormalizeVersion(string? version)
221+
{
222+
return version?.Trim().TrimStart('v', 'V');
223+
}
224+
225+
private async Task<(VersionInfoModel Info, VersionSource Source)> ResolveVersionInfoAsync(string apiType, CancellationToken ct)
226+
{
227+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
228+
timeoutCts.CancelAfter(TimeSpan.FromSeconds(15));
229+
230+
var cosTask = FetchVersionInfosAsync(CosVersionApiUrl, timeoutCts.Token);
231+
var cfTask = FetchVersionInfosAsync(CfVersionApiUrl, timeoutCts.Token);
232+
233+
var firstTask = await Task.WhenAny(cosTask, cfTask);
234+
var secondTask = firstTask == cosTask ? cfTask : cosTask;
235+
var firstSource = firstTask == cosTask ? VersionSource.Cos : VersionSource.Cf;
236+
var secondSource = firstTask == cosTask ? VersionSource.Cf : VersionSource.Cos;
237+
238+
Exception? firstError = null;
239+
try
240+
{
241+
var firstVersions = await firstTask;
242+
var firstMatch = firstVersions.FirstOrDefault(v => string.Equals(v.Type, apiType, StringComparison.OrdinalIgnoreCase));
243+
if (firstMatch != null)
244+
{
245+
logger.LogInformation("MuMod version metadata resolved from {Source}", firstSource);
246+
return (firstMatch, firstSource);
247+
}
248+
249+
throw new InvalidOperationException($"Version metadata from {firstSource} has no '{apiType}' item");
250+
}
251+
catch (Exception ex)
252+
{
253+
firstError = ex;
254+
logger.LogWarning(ex, "Failed to use version metadata from {Source}", firstSource);
255+
}
256+
257+
var secondVersions = await secondTask;
258+
var secondMatch = secondVersions.FirstOrDefault(v => string.Equals(v.Type, apiType, StringComparison.OrdinalIgnoreCase));
259+
if (secondMatch == null)
260+
{
261+
throw new InvalidOperationException($"Version metadata from both sources has no '{apiType}' item", firstError);
262+
}
263+
264+
logger.LogInformation("MuMod version metadata resolved from fallback source {Source}", secondSource);
265+
return (secondMatch, secondSource);
266+
}
267+
268+
private static IEnumerable<string> BuildDownloadUrls(VersionInfoModel info, VersionSource source)
269+
{
270+
if (source == VersionSource.Cos)
271+
{
272+
if (!string.IsNullOrWhiteSpace(info.Url))
273+
{
274+
yield return info.Url;
275+
}
276+
277+
if (!string.IsNullOrWhiteSpace(info.Url2))
278+
{
279+
yield return info.Url2;
280+
}
281+
}
282+
else
283+
{
284+
if (!string.IsNullOrWhiteSpace(info.Url2))
285+
{
286+
yield return info.Url2;
287+
}
288+
289+
if (!string.IsNullOrWhiteSpace(info.Url))
290+
{
291+
yield return info.Url;
292+
}
293+
}
294+
}
295+
296+
private static async Task<VersionInfoModel[]> FetchVersionInfosAsync(string url, CancellationToken ct)
297+
{
298+
using var client = new HttpClient
299+
{
300+
Timeout = TimeSpan.FromSeconds(15)
301+
};
302+
303+
var json = await client.GetStringAsync(url, ct);
304+
var result = JsonSerializer.Deserialize<VersionInfoModel[]>(json, new JsonSerializerOptions
305+
{
306+
PropertyNameCaseInsensitive = true
307+
});
308+
309+
return result ?? [];
310+
}
311+
312+
private static async Task<byte[]> DownloadFromUrlsAsync(IReadOnlyList<string> urls, CancellationToken ct)
313+
{
314+
using var client = new HttpClient
315+
{
316+
Timeout = TimeSpan.FromSeconds(15)
317+
};
318+
319+
Exception? lastError = null;
320+
foreach (var url in urls)
321+
{
322+
try
323+
{
324+
return await client.GetByteArrayAsync(url, ct);
325+
}
326+
catch (Exception ex)
327+
{
328+
lastError = ex;
329+
}
330+
}
331+
332+
throw new InvalidOperationException("Failed to download MuMod cache from all urls", lastError);
333+
}
334+
}

MaiChartManager/ServerManager.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Text.Json.Serialization;
77
using System.Windows.Forms;
88
using idunno.Authentication.Basic;
9+
using MaiChartManager.Controllers.Mod;
910
using MaiChartManager.Services;
1011
using Microsoft.AspNetCore.Hosting.Server;
1112
using Microsoft.AspNetCore.Hosting.Server.Features;
@@ -115,6 +116,7 @@ public static void StartApp(bool export, Action<string>? onStart = null)
115116
builder.Services
116117
.AddSingleton<StaticSettings>()
117118
.AddSingleton<MaidataImportService>()
119+
.AddSingleton<MuModService>()
118120
.AddEndpointsApiExplorer()
119121
.AddSwaggerGen(options => { options.CustomSchemaIds(type => type.Name == "Config" ? type.FullName : type.Name); })
120122
.Configure<FormOptions>(x =>
@@ -219,4 +221,4 @@ public static void StartApp(bool export, Action<string>? onStart = null)
219221

220222
return serverAddressesFeature.Addresses.First();
221223
}
222-
}
224+
}

0 commit comments

Comments
 (0)