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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ wpfhw/obj/

# 系统文件
Thumbs.db
.DS_Store
.DS_Store

# 运行时数据(设置、图片缓存)
.wpfhw/
2 changes: 1 addition & 1 deletion wpfhw/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ public partial class App : System.Windows.Application
{
protected override void OnStartup(StartupEventArgs e)
{
AppStorage.Initialize();
base.OnStartup(e);

// 显示启动画面
var splash = new SplashScreen();
splash.Show();
}
Expand Down
54 changes: 54 additions & 0 deletions wpfhw/AppSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace wpfhw;

public class AppSettings
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

public string DownloadPath { get; set; } = "";
public string LastProjectType { get; set; } = "mod";
public double WindowWidth { get; set; } = 1080;
public double WindowHeight { get; set; } = 720;

public static AppSettings Load()
{
AppStorage.Initialize();

try
{
if (File.Exists(AppStorage.SettingsFile))
{
string json = File.ReadAllText(AppStorage.SettingsFile);
var loaded = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions);
if (loaded != null) return loaded;
}
}
catch
{
}

return new AppSettings();
}

public void Save()
{
AppStorage.Initialize();

try
{
string json = JsonSerializer.Serialize(this, JsonOptions);
File.WriteAllText(AppStorage.SettingsFile, json);
}
catch
{
}
}
}
29 changes: 29 additions & 0 deletions wpfhw/AppStorage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.IO;

namespace wpfhw;

public static class AppStorage
{
public static string RootDirectory { get; private set; } = "";
public static string SettingsFile { get; private set; } = "";
public static string CacheDirectory { get; private set; } = "";
public static string IconCacheDirectory { get; private set; } = "";

private static bool _initialized;

public static void Initialize()
{
if (_initialized) return;

RootDirectory = Path.Combine(AppContext.BaseDirectory, ".wpfhw");
SettingsFile = Path.Combine(RootDirectory, "settings.json");
CacheDirectory = Path.Combine(RootDirectory, "cache");
IconCacheDirectory = Path.Combine(CacheDirectory, "icons");

Directory.CreateDirectory(RootDirectory);
Directory.CreateDirectory(CacheDirectory);
Directory.CreateDirectory(IconCacheDirectory);

_initialized = true;
}
}
93 changes: 93 additions & 0 deletions wpfhw/IconCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Collections.Concurrent;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;

namespace wpfhw;

public static class IconCache
{
private static readonly ConcurrentDictionary<string, Task<string>> InFlight = new(StringComparer.OrdinalIgnoreCase);

public static string? TryGetLocalPath(string? url)
{
if (!IsRemoteUrl(url)) return null;

AppStorage.Initialize();
string path = GetCachePath(url!);
return File.Exists(path) && new FileInfo(path).Length > 0 ? path : null;
}

public static Task<string> GetLocalPathAsync(string url, HttpClient http, CancellationToken ct)
{
string? cached = TryGetLocalPath(url);
if (cached != null) return Task.FromResult(cached);

return InFlight.GetOrAdd(url, u => DownloadAsync(u, http, ct));
}

private static async Task<string> DownloadAsync(string url, HttpClient http, CancellationToken ct)
{
AppStorage.Initialize();
string path = GetCachePath(url);
string temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";

try
{
if (File.Exists(path) && new FileInfo(path).Length > 0)
return path;

using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();

await using (var remote = await response.Content.ReadAsStreamAsync(ct))
await using (var local = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true))
{
await remote.CopyToAsync(local, ct);
}

File.Move(temp, path, overwrite: true);
return path;
}
catch
{
try { if (File.Exists(temp)) File.Delete(temp); } catch { }
throw;
}
finally
{
InFlight.TryRemove(url, out _);
}
}

public static bool IsRemoteUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
return url.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| url.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
}

private static string GetCachePath(string url)
{
string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(url))).ToLowerInvariant();
string ext = GetExtension(url);
return Path.Combine(AppStorage.IconCacheDirectory, hash + ext);
}

private static string GetExtension(string url)
{
try
{
var uri = new Uri(url);
string ext = Path.GetExtension(uri.AbsolutePath);
if (ext is ".png" or ".jpg" or ".jpeg" or ".webp" or ".gif" or ".bmp" or ".ico")
return ext.ToLowerInvariant();
}
catch
{
}

return ".png";
}
}
73 changes: 67 additions & 6 deletions wpfhw/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public partial class MainWindow : Window
};

private readonly HttpClient _httpClient;
private readonly AppSettings _settings;
private ModSearchHit? _selectedMod;
private List<ModVersion> _currentVersions = new();
private string _currentGameVer = "";
Expand All @@ -54,6 +55,7 @@ public partial class MainWindow : Window
private readonly Dictionary<string, ModTranslation> _pendingByEnglish = new(StringComparer.OrdinalIgnoreCase);

private static readonly HashSet<string> LoaderTypes = new() { "mod", "modpack" };
private static readonly HashSet<string> ProjectTypes = new() { "mod", "resourcepack", "shader", "datapack", "modpack" };

public MainWindow()
{
Expand All @@ -64,14 +66,25 @@ public MainWindow()
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(
"ModDownloader/1.0 (haodi0302@qq.com; Windows)");

_currentProjectType = "mod";
_downloadPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
_settings = AppSettings.Load();
_currentProjectType = ProjectTypes.Contains(_settings.LastProjectType)
? _settings.LastProjectType
: "mod";
_downloadPath = !string.IsNullOrWhiteSpace(_settings.DownloadPath) && Directory.Exists(_settings.DownloadPath)
? _settings.DownloadPath
: Environment.GetFolderPath(Environment.SpecialFolder.Desktop);

UpdateNavStyle(navMod);
if (_settings.WindowWidth >= 640) Width = _settings.WindowWidth;
if (_settings.WindowHeight >= 480) Height = _settings.WindowHeight;

UpdateNavStyle(GetNavButton(_currentProjectType));
UpdateLoaderVisibility();
UpdateSearchPlaceholder();
SaveSettings();

this.Closed += (s, e) =>
Closed += (_, _) =>
{
SaveSettings();
_downloadCts?.Cancel();
_downloadCts?.Dispose();
_searchCts?.Cancel();
Expand All @@ -80,6 +93,53 @@ public MainWindow()
};
}

private Button GetNavButton(string projectType) => projectType switch
{
"resourcepack" => navResource,
"shader" => navShader,
"datapack" => navData,
"modpack" => navPack,
_ => navMod
};

private void SaveSettings()
{
_settings.DownloadPath = _downloadPath;
_settings.LastProjectType = _currentProjectType;
_settings.WindowWidth = Width;
_settings.WindowHeight = Height;
_settings.Save();
}

private string ResolveIconUrl(string url)
{
if (!IconCache.IsRemoteUrl(url)) return url;

string? local = IconCache.TryGetLocalPath(url);
if (local != null)
return new Uri(local).AbsoluteUri;

_ = CacheIconInBackground(url);
return url;
}

private async Task CacheIconInBackground(string url)
{
try
{
await IconCache.GetLocalPathAsync(url, _httpClient, CancellationToken.None);
}
catch
{
}
}

private ModSearchHit WithCachedIcon(ModSearchHit hit)
{
hit.IconUrl = ResolveIconUrl(hit.IconUrl);
return hit;
}

private bool HasLoaders() => LoaderTypes.Contains(_currentProjectType);

private void UpdateLoaderVisibility()
Expand Down Expand Up @@ -312,7 +372,7 @@ private async Task SearchModrinthByEnglishNames(List<MCModSearchHit> mcHits, Can
MatchEnglishName = mc.EnglishName
};

lstModResult.Items.Add(ApplyTranslation(hit));
lstModResult.Items.Add(WithCachedIcon(ApplyTranslation(hit)));
_totalHits++;
}
}
Expand Down Expand Up @@ -381,7 +441,7 @@ private async Task DoModrinthSearch(string keyword, CancellationToken ct)
foreach (var m in searchResult.Hits)
{
TryMatchPendingByEnglish(m);
lstModResult.Items.Add(ApplyTranslation(m));
lstModResult.Items.Add(WithCachedIcon(ApplyTranslation(m)));
}
txtStatusMsg.Text = $"找到 {_totalHits} 个结果,第 {_currentOffset / PageSize + 1} 页(中译仅在通过MC百科路径搜索时应用)";
}
Expand Down Expand Up @@ -710,6 +770,7 @@ private void BtnBrowseDownloadPath_Click(object sender, RoutedEventArgs e)
{
_downloadPath = dialog.FolderName;
txtDownloadPath.Text = _downloadPath;
SaveSettings();
}
}

Expand Down