diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml
new file mode 100644
index 000000000..6c5fa797f
--- /dev/null
+++ b/.github/workflows/cross-platform.yml
@@ -0,0 +1,84 @@
+name: Cross-platform desktop
+
+on:
+ workflow_call:
+ inputs:
+ configuration:
+ type: string
+ default: Release
+ required: false
+ push:
+ branches: [main, dev]
+ paths: &paths
+ - 'PCL.Core/**'
+ - 'PCL.Core.SourceGenerators/**'
+ - 'PCL.Launcher.Core/**'
+ - 'PCL.Avalonia/**'
+ - 'PCL.CrossPlatform.Test/**'
+ - 'PCL.CrossPlatform.slnx'
+ - 'Plain Craft Launcher 2/metadata.json'
+ - '.github/workflows/cross-platform.yml'
+ pull_request:
+ paths: *paths
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: desktop-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: '1'
+ AVALONIA_TELEMETRY_OPTOUT: '1'
+ DOTNET_NOLOGO: '1'
+ BUILD_CONFIGURATION: ${{ inputs.configuration || 'Release' }}
+
+jobs:
+ desktop:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: ubuntu-latest
+ rids: 'linux-x64 linux-arm64'
+ - os: windows-latest
+ rids: 'win-x64 win-arm64'
+ - os: macos-latest
+ rids: 'osx-x64 osx-arm64'
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: |
+ 10.0.x
+ 11.0.x
+ - name: Test portable core and headless desktop
+ run: dotnet test PCL.CrossPlatform.Test/PCL.CrossPlatform.Test.csproj -c CI --logger "trx;LogFileName=desktop.trx" --results-directory TestResults
+ - name: Publish desktop targets
+ shell: bash
+ run: |
+ for rid in ${{ matrix.rids }}; do
+ dotnet publish PCL.Avalonia/PCL.Avalonia.csproj -c "$BUILD_CONFIGURATION" -r "$rid" --self-contained true -o "artifact/$rid"
+ if [[ "$rid" == osx-* ]]; then
+ app="artifact/$rid/PCL CE.app/Contents"
+ mkdir -p "$app/MacOS" "$app/Resources"
+ for item in artifact/"$rid"/*; do
+ [[ "$item" == *.app ]] || mv "$item" "$app/MacOS/"
+ done
+ cp PCL.Avalonia/Info.plist "$app/Info.plist"
+ fi
+ tar -czf "artifact/PCL-CE-$rid.tar.gz" -C "artifact/$rid" .
+ done
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: desktop-${{ matrix.os }}
+ path: artifact/*.tar.gz
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: always()
+ with:
+ name: desktop-tests-${{ matrix.os }}
+ path: TestResults/*.trx
diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml
index e3dcdc566..cc0e6e8ea 100644
--- a/.github/workflows/release-publish.yml
+++ b/.github/workflows/release-publish.yml
@@ -40,6 +40,7 @@ jobs:
run: |
version="${INPUT_VERSION:-${REF_NAME#v}}"
configuration="${INPUT_CONFIGURATION:-Release}"
+ [[ "$version" =~ ^[0-9][0-9A-Za-z.+-]*$ ]] || { echo "Invalid release version" >&2; exit 1; }
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "configuration=$configuration" >> "$GITHUB_OUTPUT"
@@ -60,6 +61,12 @@ jobs:
architecture: ${{ matrix.architecture }}
secrets: inherit
+ desktop:
+ needs: [prepare]
+ uses: ./.github/workflows/cross-platform.yml
+ with:
+ configuration: ${{ needs.prepare.outputs.configuration }}
+
changelog:
needs: [prepare]
runs-on: ubuntu-latest
@@ -145,3 +152,41 @@ jobs:
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: v${{ needs.prepare.outputs.version }}
+
+ publish_desktop:
+ needs: [prepare, desktop, changelog]
+ permissions:
+ actions: read
+ contents: write
+ id-token: write
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download desktop packages
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: desktop-*
+ merge-multiple: true
+ path: artifact
+ - name: Add release version to package names
+ env:
+ VERSION: ${{ needs.prepare.outputs.version }}
+ run: |
+ for path in artifact/PCL-CE-*.tar.gz; do
+ name=$(basename "$path" .tar.gz)
+ mv "$path" "$name-$VERSION.tar.gz"
+ done
+ - name: Sign desktop packages
+ uses: sigstore/gh-action-sigstore-python@790bc6befb9d733738f18d8f895854b453640ec9 # v3.5.0
+ with:
+ inputs: PCL-CE-*.tar.gz
+ verify: true
+ verify-cert-identity: https://github.com/${{ github.repository }}/.github/workflows/release-publish.yml@${{ github.ref }}
+ verify-oidc-issuer: https://token.actions.githubusercontent.com
+ - name: Attach desktop packages to release
+ uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
+ with:
+ draft: true
+ tag_name: v${{ needs.prepare.outputs.version }}
+ files: |
+ PCL-CE-*.tar.gz
+ PCL-CE-*.sigstore.json
diff --git a/PCL.Avalonia/App.axaml b/PCL.Avalonia/App.axaml
new file mode 100644
index 000000000..c21946a1a
--- /dev/null
+++ b/PCL.Avalonia/App.axaml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
diff --git a/PCL.Avalonia/App.axaml.cs b/PCL.Avalonia/App.axaml.cs
new file mode 100644
index 000000000..2af15934e
--- /dev/null
+++ b/PCL.Avalonia/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using PCL.Core.IO.Net;
+using PCL.Core.Platform;
+
+namespace PCL.Avalonia;
+
+public sealed partial class App : Application
+{
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ NetworkService.Initialize(useConfiguration: false);
+ PlatformServices.UiDispatcher = new AvaloniaUiDispatcher();
+ desktop.MainWindow = new MainWindow();
+ }
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/PCL.Avalonia/AvaloniaUiDispatcher.cs b/PCL.Avalonia/AvaloniaUiDispatcher.cs
new file mode 100644
index 000000000..5976fa01a
--- /dev/null
+++ b/PCL.Avalonia/AvaloniaUiDispatcher.cs
@@ -0,0 +1,27 @@
+using Avalonia.Threading;
+using PCL.Core.Platform;
+
+namespace PCL.Avalonia;
+
+/// Shared core dispatcher adapter, including nested frames for existing synchronous dialogs.
+public sealed class AvaloniaUiDispatcher : IUiDispatcher
+{
+ public bool CheckAccess() => Dispatcher.UIThread.CheckAccess();
+ public void Post(Action action) => Dispatcher.UIThread.Post(action);
+ public void Invoke(Action action)
+ {
+ if (CheckAccess()) action();
+ else Dispatcher.UIThread.Invoke(action);
+ }
+ public T? Invoke(Func func) => CheckAccess() ? func() : Dispatcher.UIThread.Invoke(func);
+ public async Task InvokeAsync(Action action) => await Dispatcher.UIThread.InvokeAsync(action);
+ public async Task InvokeAsync(Func func) => await Dispatcher.UIThread.InvokeAsync(func);
+ public IUiModalFrame CreateModalFrame() => new ModalFrame();
+
+ private sealed class ModalFrame : IUiModalFrame
+ {
+ private readonly DispatcherFrame _frame = new();
+ public void Exit() => Dispatcher.UIThread.Post(() => _frame.Continue = false);
+ public void Run() => Dispatcher.UIThread.PushFrame(_frame);
+ }
+}
diff --git a/PCL.Avalonia/CustomHomeRenderer.cs b/PCL.Avalonia/CustomHomeRenderer.cs
new file mode 100644
index 000000000..8a3831cd4
--- /dev/null
+++ b/PCL.Avalonia/CustomHomeRenderer.cs
@@ -0,0 +1,69 @@
+using System.Xml;
+using System.Xml.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace PCL.Avalonia;
+
+/// A declarative subset of custom home XAML. It does not instantiate CLR types or evaluate markup extensions.
+public static class CustomHomeRenderer
+{
+ public static Control Render(string xml, Action openLink)
+ {
+ using var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, MaxCharactersInDocument = 2_000_000, XmlResolver = null });
+ var document = XDocument.Load(reader);
+ return Build(document.Root ?? throw new InvalidDataException("主页内容为空。"), openLink, 0);
+ }
+ private static Control Build(XElement element, Action openLink, int depth)
+ {
+ if (depth > 40) throw new InvalidDataException("主页嵌套层级过多。");
+ if (element.Attributes().Any(a => a.Name.LocalName is "Class" or "Code" || a.Value.Contains('{')))
+ throw new InvalidDataException("主页不允许执行代码、绑定或标记扩展。");
+ string Value(string name, string fallback = "") => element.Attribute(name)?.Value ?? fallback;
+ Control control;
+ switch (element.Name.LocalName)
+ {
+ case "StackPanel": case "MyCard":
+ var panel = new StackPanel { Spacing = 10, Orientation = Value("Orientation") == "Horizontal" ? Orientation.Horizontal : Orientation.Vertical };
+ foreach (var child in element.Elements()) panel.Children.Add(Build(child, openLink, depth + 1));
+ control = panel; break;
+ case "WrapPanel":
+ var wrap = new WrapPanel { Orientation = Value("Orientation") == "Vertical" ? Orientation.Vertical : Orientation.Horizontal };
+ foreach (var child in element.Elements()) wrap.Children.Add(Build(child, openLink, depth + 1));
+ control = wrap; break;
+ case "Grid":
+ var grid = new Grid();
+ foreach (var definition in element.Elements().Where(e => e.Name.LocalName == "Grid.RowDefinitions").Elements())
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Parse(definition.Attribute("Height")?.Value ?? "*")));
+ foreach (var definition in element.Elements().Where(e => e.Name.LocalName == "Grid.ColumnDefinitions").Elements())
+ grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Parse(definition.Attribute("Width")?.Value ?? "*")));
+ foreach (var child in element.Elements().Where(e => !e.Name.LocalName.Contains('.')))
+ {
+ var item = Build(child, openLink, depth + 1);
+ int Index(string attribute, int fallback) => int.TryParse(child.Attribute(attribute)?.Value, out var value) ? Math.Clamp(value, fallback, 1000) : fallback;
+ Grid.SetRow(item, Index("Grid.Row", 0)); Grid.SetColumn(item, Index("Grid.Column", 0));
+ Grid.SetRowSpan(item, Index("Grid.RowSpan", 1)); Grid.SetColumnSpan(item, Index("Grid.ColumnSpan", 1));
+ grid.Children.Add(item);
+ }
+ control = grid; break;
+ case "TextBlock": case "Label": case "Run":
+ control = new TextBlock { Text = Value("Text", Value("Content", element.Value)), TextWrapping = TextWrapping.Wrap, FontSize = double.TryParse(Value("FontSize"), System.Globalization.CultureInfo.InvariantCulture, out var fontSize) ? Math.Clamp(fontSize, 8, 80) : 15 }; break;
+ case "Button": case "MyButton":
+ var address = Value("NavigateUri", Value("EventData"));
+ if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) throw new InvalidDataException("主页按钮只允许打开 HTTP(S) 链接。");
+ var button = new Button { Content = Value("Text", Value("Content", element.Value)) };
+ button.Click += (_, _) => openLink(uri); control = button; break;
+ case "Border":
+ var content = element.Elements().FirstOrDefault(); control = new Border { Padding = Thickness.Parse(Value("Padding", "14")), Child = content is null ? null : Build(content, openLink, depth + 1) }; break;
+ case "ScrollViewer":
+ var scrollContent = element.Elements().FirstOrDefault();
+ control = new ScrollViewer { Content = scrollContent is null ? null : Build(scrollContent, openLink, depth + 1) }; break;
+ case "Separator": control = new Separator(); break;
+ default: throw new InvalidDataException("尚不支持的主页控件:" + element.Name.LocalName);
+ }
+ control.Margin = Thickness.Parse(Value("Margin", "0"));
+ return control;
+ }
+}
diff --git a/PCL.Avalonia/DesktopLocalization.cs b/PCL.Avalonia/DesktopLocalization.cs
new file mode 100644
index 000000000..81eb79de6
--- /dev/null
+++ b/PCL.Avalonia/DesktopLocalization.cs
@@ -0,0 +1,40 @@
+using System.Globalization;
+using System.Reflection;
+using System.Xml.Linq;
+using Avalonia.Threading;
+using PCL.Core.App.Localization;
+using PCL.Core.Platform;
+
+namespace PCL.Avalonia;
+
+/// Reads the existing language dictionaries as plain XML strings, without loading WPF XAML.
+public sealed class DesktopLocalization : ILocalizationBackend
+{
+ private readonly Dictionary _strings = new(StringComparer.Ordinal);
+ private readonly Dictionary _phrases = new(StringComparer.Ordinal);
+ public bool CheckAccess() => Dispatcher.UIThread.CheckAccess();
+ public void Invoke(Action action) => Dispatcher.UIThread.Invoke(action);
+ public string? TryFindResource(string key) => _strings.GetValueOrDefault(key);
+ public string Translate(string text) => _phrases.GetValueOrDefault(text) ?? text;
+ public void ApplyLanguageResources(string baseLanguageCode, string? currentLanguageCode)
+ {
+ var chinese = Read("zh-CN"); _strings.Clear(); foreach (var pair in Read(baseLanguageCode)) _strings[pair.Key] = pair.Value;
+ if (!string.IsNullOrWhiteSpace(currentLanguageCode)) foreach (var pair in Read(currentLanguageCode)) _strings[pair.Key] = pair.Value;
+ _phrases.Clear();
+ // Accept every dictionary's text as input, so switching languages repeatedly is reversible.
+ foreach (var source in new[] { chinese, Read("zh-TW"), Read("en-US"), Read("en-GB"), Read("ja-JP"), Read("fr-FR"), Read("es-ES") })
+ foreach (var (key, value) in source)
+ if (_strings.TryGetValue(key, out var translation)) _phrases.TryAdd(value, translation);
+ var language = currentLanguageCode ?? baseLanguageCode;
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(language); CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture;
+ }
+ public void ApplyLaunchFont(string? customFontName, LocalizationLanguage? language) { }
+ private static Dictionary Read(string language)
+ {
+ using var input = Assembly.GetExecutingAssembly().GetManifestResourceStream("PCL.Languages." + language + ".xml");
+ if (input is null) return [];
+ return XDocument.Load(input).Root!.Elements().Where(element => element.Name.LocalName == "String")
+ .Where(element => element.Attribute("{http://schemas.microsoft.com/winfx/2006/xaml}Key") is not null)
+ .ToDictionary(element => element.Attribute("{http://schemas.microsoft.com/winfx/2006/xaml}Key")!.Value, element => element.Value);
+ }
+}
diff --git a/PCL.Avalonia/DesktopMediaPlayer.cs b/PCL.Avalonia/DesktopMediaPlayer.cs
new file mode 100644
index 000000000..2fecc10dc
--- /dev/null
+++ b/PCL.Avalonia/DesktopMediaPlayer.cs
@@ -0,0 +1,36 @@
+using LibVLCSharp.Shared;
+
+namespace PCL.Avalonia;
+
+/// Native media is loaded only when playback is requested, keeping headless/startup paths independent.
+public sealed class DesktopMediaPlayer : IDisposable
+{
+ private LibVLC? _vlc;
+ private MediaPlayer? _player;
+ private Media? _media;
+ public MediaPlayer Player => _player ?? throw new InvalidOperationException("播放器尚未初始化。");
+ public void Initialize(string? libraryDirectory)
+ {
+ if (_player is not null) return;
+ try
+ {
+ if (string.IsNullOrWhiteSpace(libraryDirectory)) LibVLCSharp.Shared.Core.Initialize();
+ else LibVLCSharp.Shared.Core.Initialize(libraryDirectory);
+ _vlc = new LibVLC("--no-video-title-show", "--no-metadata-network-access");
+ _player = new MediaPlayer(_vlc);
+ }
+ catch (Exception ex) { Dispose(); throw new InvalidOperationException("无法载入 VLC 播放库。请安装 VLC,或在外观设置中指定 libvlc 所在目录。", ex); }
+ }
+ public void Play(string file, int volume, bool loop)
+ {
+ if (_vlc is null || _player is null) throw new InvalidOperationException("播放器尚未初始化。");
+ _player.Stop(); _media?.Dispose(); _media = new Media(_vlc, new Uri(Path.GetFullPath(file)));
+ if (loop) _media.AddOption(":input-repeat=65535");
+ _player.Volume = Math.Clamp(volume, 0, 100);
+ if (!_player.Play(_media)) throw new InvalidOperationException("无法播放所选媒体。");
+ }
+ public void Pause() { if (_player is not null) _player.Pause(); }
+ public void Stop() { if (_player is not null) _player.Stop(); }
+ public void Volume(int value) { if (_player is not null) _player.Volume = Math.Clamp(value, 0, 100); }
+ public void Dispose() { _player?.Stop(); _player?.Dispose(); _player = null; _media?.Dispose(); _media = null; _vlc?.Dispose(); _vlc = null; }
+}
diff --git a/PCL.Avalonia/DesktopSettings.cs b/PCL.Avalonia/DesktopSettings.cs
new file mode 100644
index 000000000..04dea4f61
--- /dev/null
+++ b/PCL.Avalonia/DesktopSettings.cs
@@ -0,0 +1,112 @@
+using System.Text.Json;
+using PCL.Core.App;
+using PCL.Core.Minecraft.Profile.Authentication;
+using PCL.Core.Minecraft.Profile.Models;
+using PCL.Core.Utils.Secret;
+
+namespace PCL.Avalonia;
+
+public sealed class DesktopSettings
+{
+ public string GameDirectory { get; set; } = DefaultGameDirectory();
+ public string JavaPath { get; set; } = OperatingSystem.IsWindows() ? "java.exe" : "java";
+ public int MemoryMb { get; set; } = 2048;
+ public string OfflineName { get; set; } = "Player";
+ public string? SelectedVersion { get; set; }
+ public string MicrosoftClientId { get; set; } = "";
+ public string? EncryptedAccount { get; set; }
+ public Dictionary Instances { get; set; } = new();
+ public string? EncryptedAccounts { get; set; }
+ public string? SelectedAccountId { get; set; }
+ public Dictionary ProviderClientIds { get; set; } = new();
+ public PCL.Core.IO.Net.Http.HttpProxyManager.ProxyMode ProxyMode { get; set; } = PCL.Core.IO.Net.Http.HttpProxyManager.ProxyMode.SystemProxy;
+ public string ProxyAddress { get; set; } = "";
+ public string DownloadMirror { get; set; } = "";
+ public string? EncryptedCurseForgeKey { get; set; }
+ public List Favorites { get; set; } = [];
+ public string Language { get; set; } = "zh-CN";
+ public string? BackgroundImage { get; set; }
+ public string? HomepageFile { get; set; }
+ public string? VlcLibraryDirectory { get; set; }
+ public int MusicVolume { get; set; } = 60;
+ public bool ShuffleMusic { get; set; }
+ public bool LoopMusic { get; set; } = true;
+ public string? MusicFile { get; set; }
+ public string? EasyTierDirectory { get; set; }
+ public LaunchWindowBehavior WindowBehavior { get; set; }
+ public bool CheckUpdatesOnStartup { get; set; }
+ public bool AutoAnalyzeCrash { get; set; } = true;
+ public string AccentColor { get; set; } = "#0078D4";
+ public string? FontName { get; set; }
+ public double BackgroundOpacity { get; set; } = 0.18;
+ public bool DarkTheme { get; set; }
+
+ public static string FilePath => Path.Combine(Paths.SharedData, "avalonia.settings.json");
+
+ public static DesktopSettings Load() => File.Exists(FilePath)
+ ? JsonSerializer.Deserialize(File.ReadAllText(FilePath)) ?? throw new InvalidDataException("设置文件为空。")
+ : new();
+
+ public void Save()
+ {
+ var temporary = FilePath + ".tmp";
+ File.WriteAllText(temporary, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
+ File.Move(temporary, FilePath, true);
+ }
+
+ public McProfile? LoadAccount() => string.IsNullOrEmpty(EncryptedAccount) ? null
+ : JsonSerializer.Deserialize(EncryptHelper.SecretDecrypt(EncryptedAccount));
+
+ public List Accounts() => string.IsNullOrEmpty(EncryptedAccounts)
+ ? (LoadAccount() is { } legacy ? [legacy] : [])
+ : JsonSerializer.Deserialize>(EncryptHelper.SecretDecrypt(EncryptedAccounts)) ?? [];
+
+ public McProfile? SelectedAccount() { var accounts = Accounts(); return accounts.FirstOrDefault(p => p.ProfileId == SelectedAccountId) ?? accounts.FirstOrDefault(); }
+ public void RemoveSelectedAccount()
+ {
+ var selected = SelectedAccount(); var accounts = Accounts(); accounts.RemoveAll(p => p.ProfileId == selected?.ProfileId);
+ EncryptedAccounts = EncryptHelper.SecretEncrypt(JsonSerializer.Serialize(accounts)); EncryptedAccount = null;
+ SelectedAccountId = accounts.FirstOrDefault()?.ProfileId;
+ }
+ public void StoreAccount(AuthenticationResult result)
+ {
+ var accounts = Accounts();
+ var existing = accounts.FirstOrDefault(p => p.Uuid == result.Uuid && p.ProfileType == result.ProfileType && p.Server == result.Server);
+ var profile = new McProfile
+ {
+ ProfileId = existing?.ProfileId ?? Guid.NewGuid().ToString("N"), ProfileType = result.ProfileType,
+ UserName = result.UserName, Uuid = result.Uuid, AccessToken = result.AccessToken,
+ RefreshToken = result.RefreshToken, ExpiresAt = result.ExpiresAt, ClientToken = result.ClientToken,
+ Provider = result.Provider, Server = result.Server, LoginName = result.LoginName,
+ DiscoveryAddress = result.DiscoveryAddress, IdToken = result.IdToken
+ };
+ accounts.RemoveAll(p => p.ProfileId == profile.ProfileId); accounts.Add(profile);
+ EncryptedAccounts = EncryptHelper.SecretEncrypt(JsonSerializer.Serialize(accounts));
+ EncryptedAccount = null; SelectedAccountId = profile.ProfileId;
+ }
+
+ private static string DefaultGameDirectory()
+ {
+ var user = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return OperatingSystem.IsWindows()
+ ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ".minecraft")
+ : OperatingSystem.IsMacOS() ? Path.Combine(user, "Library", "Application Support", "minecraft")
+ : Path.Combine(user, ".minecraft");
+ }
+}
+
+public sealed class InstancePreferences
+{
+ public string? JavaPath { get; set; }
+ public int MemoryMb { get; set; }
+ public bool Isolated { get; set; }
+ public string JvmArguments { get; set; } = "";
+ public string GameArguments { get; set; } = "";
+ public string EnvironmentVariables { get; set; } = "";
+ public int Width { get; set; } = 854;
+ public int Height { get; set; } = 480;
+ public string ServerAddress { get; set; } = "";
+}
+public sealed record FavoriteResource(PCL.CrossPlatform.ResourceProvider Provider, string Id, string Title, string Url);
+
+public enum LaunchWindowBehavior { Keep, Minimize, Hide, Exit }
diff --git a/PCL.Avalonia/Info.plist b/PCL.Avalonia/Info.plist
new file mode 100644
index 000000000..2f5c07fcd
--- /dev/null
+++ b/PCL.Avalonia/Info.plist
@@ -0,0 +1,13 @@
+
+
+
+ CFBundleNamePCL CE
+ CFBundleDisplayNamePCL Community Edition
+ CFBundleIdentifiercc.pclc.ce.desktop
+ CFBundleExecutablePCL-CE
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString2.15.4
+ CFBundleVersion527
+ LSMinimumSystemVersion11.0
+ NSHighResolutionCapable
+
diff --git a/PCL.Avalonia/Licenses/LGPL-2.1.txt b/PCL.Avalonia/Licenses/LGPL-2.1.txt
new file mode 100644
index 000000000..f6683e74e
--- /dev/null
+++ b/PCL.Avalonia/Licenses/LGPL-2.1.txt
@@ -0,0 +1,501 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+ This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it. You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+ When we speak of free software, we are referring to freedom of use,
+not price. Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+ To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights. These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+ For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you. You must make sure that they, too, receive or can get the source
+code. If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it. And you must show them these terms so they know their rights.
+
+ We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+ To protect each distributor, we want to make it very clear that
+there is no warranty for the free library. Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+ Finally, software patents pose a constant threat to the existence of
+any free program. We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder. Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+ Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License. This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License. We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+ When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library. The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom. The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+ We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License. It also provides other free software developers Less
+of an advantage over competing non-free programs. These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries. However, the Lesser license provides advantages in certain
+special circumstances.
+
+ For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard. To achieve this, non-free programs must be
+allowed to use the library. A more frequent case is that a free
+library does the same job as widely used non-free libraries. In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+ In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software. For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+ Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+ The precise terms and conditions for copying, distribution and
+modification follow. Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library". The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+ A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+ The "Library", below, refers to any such software library or work
+which has been distributed under these terms. A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+ "Source code" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+ 1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) The modified work must itself be a software library.
+
+ b) You must cause the files modified to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ c) You must cause the whole of the work to be licensed at no
+ charge to all third parties under the terms of this License.
+
+ d) If a facility in the modified Library refers to a function or a
+ table of data to be supplied by an application program that uses
+ the facility, other than as an argument passed when the facility
+ is invoked, then you must make a good faith effort to ensure that,
+ in the event an application does not supply such function or
+ table, the facility still operates, and performs whatever part of
+ its purpose remains meaningful.
+
+ (For example, a function in a library to compute square roots has
+ a purpose that is entirely well-defined independent of the
+ application. Therefore, Subsection 2d requires that any
+ application-supplied function or table used by this function must
+ be optional: if the application does not supply it, the square
+ root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+ However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+ When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+ 6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:
+
+ a) Accompany the work with the complete corresponding
+ machine-readable source code for the Library including whatever
+ changes were used in the work (which must be distributed under
+ Sections 1 and 2 above); and, if the work is an executable linked
+ with the Library, with the complete machine-readable "work that
+ uses the Library", as object code and/or source code, so that the
+ user can modify the Library and then relink to produce a modified
+ executable containing the modified Library. (It is understood
+ that the user who changes the contents of definitions files in the
+ Library will not necessarily be able to recompile the application
+ to use the modified definitions.)
+
+ b) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (1) uses at run time a
+ copy of the library already present on the user's computer system,
+ rather than copying library functions into the executable, and (2)
+ will operate properly with a modified version of the library, if
+ the user installs one, as long as the modified version is
+ interface-compatible with the version that the work was made with.
+
+ c) Accompany the work with a written offer, valid for at
+ least three years, to give the same user the materials
+ specified in Subsection 6a, above, for a charge no more
+ than the cost of performing this distribution.
+
+ d) If distribution of the work is made by offering access to copy
+ from a designated place, offer equivalent access to copy the above
+ specified materials from the same place.
+
+ e) Verify that the user has already received a copy of these
+ materials or that you have already sent this user a copy.
+
+ For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+ a) Accompany the combined library with a copy of the same work
+ based on the Library, uncombined with any other library
+ facilities. This must be distributed under the terms of the
+ Sections above.
+
+ b) Give prominent notice with the combined library of the fact
+ that part of it is a work based on the Library, and explaining
+ where to find the accompanying uncombined form of the same work.
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Libraries
+
+ If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change. You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+ To apply these terms, attach the following notices to the library. It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the
+ library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+ , 1 April 1990
+ Moe Ghoul, President of Vice
+
+That's all there is to it!
diff --git a/PCL.Avalonia/Licenses/THIRD-PARTY-NOTICES.md b/PCL.Avalonia/Licenses/THIRD-PARTY-NOTICES.md
new file mode 100644
index 000000000..e1881e875
--- /dev/null
+++ b/PCL.Avalonia/Licenses/THIRD-PARTY-NOTICES.md
@@ -0,0 +1,17 @@
+# Third-party notices
+
+The cross-platform desktop links the following media components dynamically:
+
+- **LibVLCSharp / LibVLCSharp.Avalonia 3.10.2**, VideoLAN contributors,
+ LGPL-2.1-or-later. Source: https://code.videolan.org/videolan/LibVLCSharp
+ (NuGet source revision `3082c09ca955131c705fc4767fde03e9c4a14999`).
+- **LibVLC**, when installed by the user: https://www.videolan.org/vlc/libvlc.html.
+ LibVLC is not included in these desktop packages. The system installation's license
+ and codec/plugin notices apply to that installation.
+
+The LGPL 2.1 text is included in `LGPL-2.1.txt`. The managed binding assemblies remain
+separate, replaceable DLLs. The source URLs above provide their corresponding source.
+
+Other dependencies retain their own license information in their upstream projects
+and NuGet packages. PCL.Core remains under the root Apache-2.0 license; the launcher
+business code retains `LICENCE.Launcher`, copied alongside the application.
diff --git a/PCL.Avalonia/MainWindow.Accounts.cs b/PCL.Avalonia/MainWindow.Accounts.cs
new file mode 100644
index 000000000..181440e05
--- /dev/null
+++ b/PCL.Avalonia/MainWindow.Accounts.cs
@@ -0,0 +1,172 @@
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using PCL.Core.Minecraft.Profile.Authentication;
+using PCL.Core.Minecraft.Profile.Models;
+using PCL.CrossPlatform;
+
+namespace PCL.Avalonia;
+
+public sealed partial class MainWindow
+{
+ private Control AccountPages() => Tabs(("账户列表", AccountsListPage()), ("微软登录", AccountPage()), ("第三方登录", ThirdPartyPage()), ("皮肤与披风", SkinPage()));
+ private Control AccountsListPage()
+ {
+ var panel = Page(); var list = new ListBox { Height = 280 };
+ void Refresh() { list.ItemsSource = _settings.Accounts().Select(p => new AccountItem(p)).ToArray(); }
+ var offline = new TextBox { Watermark = "新离线账户名称" };
+ panel.Children.Add(offline); panel.Children.Add(ActionButton("添加离线账户", _ =>
+ {
+ var account = LaunchAccount.Offline(offline.Text ?? "");
+ StoreAuthenticated(new AuthenticationResult { ProfileType = ProfileType.Offline, UserName = account.Name, Uuid = account.Uuid, AccessToken = "0" });
+ Refresh(); return Task.CompletedTask;
+ }));
+ panel.Children.Add(Heading("管理账户")); panel.Children.Add(ActionButton("刷新账户", _ => { Refresh(); return Task.CompletedTask; })); panel.Children.Add(list);
+ panel.Children.Add(Row(ActionButton("使用此账户", _ =>
+ {
+ var selected = list.SelectedItem as AccountItem ?? throw new InvalidOperationException("请选择账户。");
+ _settings.SelectedAccountId = selected.Profile.ProfileId; _profile = selected.Profile; SaveSettings(); RefreshAccount(); Report("当前账户:" + _profile.UserName); return Task.CompletedTask;
+ }), ActionButton("移除所选账户", _ =>
+ {
+ if (list.SelectedItem is not AccountItem selected) throw new InvalidOperationException("请选择账户。");
+ _settings.SelectedAccountId = selected.Profile.ProfileId; _settings.RemoveSelectedAccount(); _profile = _settings.SelectedAccount(); SaveSettings(); RefreshAccount(); Refresh(); return Task.CompletedTask;
+ })));
+ return new ScrollViewer { Content = panel };
+ }
+ private sealed record AccountItem(McProfile Profile) { public override string ToString() => $"{Profile.UserName} · {Profile.ProfileType} · {Profile.Server}"; }
+ private IAuthenticateProvider AccountProvider(McProfile profile) => profile.ProfileType switch
+ {
+ ProfileType.Microsoft => new MicrosoftProvider(_clientId.Text),
+ ProfileType.Authlib => new AuthlibProvider(profile.Server!),
+ ProfileType.YggdrasilConnect => new YggdrasilConnectProvider(profile.DiscoveryAddress!, _settings.ProviderClientIds.GetValueOrDefault(profile.DiscoveryAddress ?? ""), profile.Server),
+ _ => throw new InvalidOperationException("此账户不需要在线认证。")
+ };
+ private Control ThirdPartyPage()
+ {
+ var panel = Page(); var server = new TextBox { Watermark = "Yggdrasil API 地址(https://…/api/yggdrasil)" };
+ var username = new TextBox { Watermark = "用户名 / 邮箱" }; var password = new TextBox { Watermark = "密码", PasswordChar = '●' };
+ var discovery = new TextBox { Watermark = "Yggdrasil Connect 发现地址" }; var clientId = new TextBox { Watermark = "应用客户端 ID(如服务提供方要求)" };
+ panel.Children.Add(Heading("第三方认证")); panel.Children.Add(server); panel.Children.Add(username); panel.Children.Add(password);
+ panel.Children.Add(ActionButton("使用密码登录", async token =>
+ {
+ RequireHttps(server.Text);
+ var result = await new AuthlibProvider(server.Text!).AuthenticateAsync(new AuthenticationRequest
+ { Username = username.Text, Password = password.Text, ProfileSelector = SelectAuthenticationProfile, ForceReselectProfile = true }, token);
+ password.Text = ""; StoreAuthenticated(result);
+ }));
+ panel.Children.Add(discovery); panel.Children.Add(clientId);
+ panel.Children.Add(ActionButton("使用设备码登录", async token =>
+ {
+ RequireHttps(discovery.Text);
+ var provider = new YggdrasilConnectProvider(discovery.Text!, clientId.Text, server.Text);
+ await provider.InitializeAsync(token);
+ var result = await provider.AuthenticateAsync(new AuthenticationRequest { DeviceCodeHandler = PollThirdPartyDevice, ProfileSelector = SelectAuthenticationProfile, ForceReselectProfile = true }, token);
+ _settings.ProviderClientIds[discovery.Text!] = clientId.Text ?? ""; StoreAuthenticated(result);
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+ private void StoreAuthenticated(AuthenticationResult result)
+ {
+ _settings.StoreAccount(result); _profile = _settings.SelectedAccount(); SaveSettings(); RefreshAccount(); Report("登录成功:" + result.UserName);
+ }
+ private async Task SelectAuthenticationProfile(IReadOnlyList profiles, CancellationToken token)
+ {
+ return await Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ var list = new ComboBox { ItemsSource = profiles, SelectedIndex = 0 };
+ var button = new Button { Content = "选择此角色" }; var panel = new StackPanel { Margin = new global::Avalonia.Thickness(22), Spacing = 16 };
+ panel.Children.Add(list); panel.Children.Add(button); var window = new Window { Title = "选择角色", Width = 430, Height = 190, Content = panel };
+ button.Click += (_, _) => window.Close(list.SelectedItem as AuthenticationCandidate);
+ using var registration = token.Register(() => Dispatcher.UIThread.Post(window.Close));
+ return await window.ShowDialog(this);
+ });
+ }
+ private async Task PollThirdPartyDevice(DeviceCodeAuthenticationContext context, CancellationToken token)
+ {
+ await Dispatcher.UIThread.InvokeAsync(() => Report($"登录地址:{context.Data.VerificationUri}\n设备码:{context.Data.UserCode}"));
+ await Dispatcher.UIThread.InvokeAsync(async () => await Launcher.LaunchUriAsync(new Uri(context.Data.VerificationUriComplete ?? context.Data.VerificationUri!)));
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(context.Data.ExpiresIn ?? 900); var interval = Math.Max(context.Data.Interval ?? 5, 1);
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(interval), token); var result = await context.PollAsync(token);
+ if (result is null) continue; if (!result.IsError) return result;
+ if (result.Error == "slow_down") { interval += 5; continue; }
+ if (result.Error != "authorization_pending") throw new InvalidOperationException(result.ErrorDescription ?? result.Error);
+ }
+ throw new TimeoutException("设备授权超时。");
+ }
+ private static void RequireHttps(string? address)
+ {
+ if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme != "https") throw new InvalidOperationException("认证地址必须使用 HTTPS。");
+ }
+ private async Task BuildLaunchOptions(CancellationToken token)
+ {
+ var preferences = InstanceOptions;
+ var jvm = GameProcess.SplitLegacyArguments(preferences.JvmArguments).ToList();
+ if (_useAccount.IsChecked == true && _profile is { ProfileType: ProfileType.Authlib or ProfileType.YggdrasilConnect } profile)
+ {
+ var server = profile.Server ?? throw new InvalidOperationException("第三方账户缺少认证服务器。");
+ if (server.EndsWith("/authserver")) server = server[..^11]; RequireHttps(server);
+ var metadata = JsonNode.Parse(await _http.GetStringAsync("https://authlib-injector.yushi.moe/artifact/latest.json", token))!;
+ var hash = metadata["checksums"]?["sha256"]!.ToString() ?? throw new InvalidDataException("认证补丁缺少校验信息。");
+ if (hash.Length != 64 || !hash.All(Uri.IsHexDigit)) throw new InvalidDataException("认证补丁校验格式无效。");
+ var path = Path.Combine(PCL.Core.App.Paths.SharedLocalData, "authlib-injector-" + hash + ".jar");
+ await new GameInstaller(_http).DownloadAsync(metadata["download_url"]!.ToString(), path, null, null, token);
+ await using var input = File.OpenRead(path);
+ if (!Convert.ToHexString(await SHA256.HashDataAsync(input, token)).Equals(hash, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException("认证补丁校验失败。");
+ jvm.Add("-javaagent:" + path + "=" + server); jvm.Add("-Dauthlibinjector.side=client");
+ }
+ var environment = new Dictionary();
+ foreach (var line in preferences.EnvironmentVariables.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var pair = line.Trim().Split('=', 2); if (pair.Length != 2 || string.IsNullOrWhiteSpace(pair[0])) throw new InvalidDataException("环境变量格式应为 NAME=VALUE。");
+ environment[pair[0]] = pair[1];
+ }
+ return new LaunchOptions { CaptureLogs = _settings.WindowBehavior != LaunchWindowBehavior.Exit || _lobby.IsRunning, GameDirectory = CurrentDataDirectory, JvmArguments = jvm,
+ GameArguments = GameProcess.SplitLegacyArguments(preferences.GameArguments).ToArray(), Environment = environment,
+ Width = preferences.Width, Height = preferences.Height, ServerAddress = preferences.ServerAddress };
+ }
+ private Control SkinPage()
+ {
+ var panel = Page(); var model = new ComboBox { ItemsSource = new[] { "classic", "slim" }, SelectedIndex = 0 }; var capes = new ComboBox(); JsonObject? current = null;
+ McProfile Profile() => _profile is { ProfileType: ProfileType.Microsoft } p ? p : throw new InvalidOperationException("请先选择已登录的微软账户。");
+ async Task Send(HttpMethod method, string path, HttpContent? content, CancellationToken token)
+ {
+ var request = new HttpRequestMessage(method, "https://api.minecraftservices.com/" + path) { Content = content };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Profile().AccessToken);
+ var response = await _http.SendAsync(request, token); request.Dispose(); response.EnsureSuccessStatusCode(); return response;
+ }
+ panel.Children.Add(Heading("皮肤与披风"));
+ panel.Children.Add(ActionButton("读取在线皮肤和披风", async token =>
+ {
+ using var response = await Send(HttpMethod.Get, "minecraft/profile", null, token);
+ current = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject();
+ capes.ItemsSource = current["capes"]!.AsArray().Select(c => new Cape(c!["id"]!.ToString(), c["alias"]?.ToString() ?? c["id"]!.ToString())).ToArray(); capes.SelectedIndex = 0;
+ Report("已读取在线外观。");
+ })); panel.Children.Add(model);
+ panel.Children.Add(Row(ActionButton("上传皮肤", async token =>
+ {
+ var file = await PickFile("选择皮肤 PNG"); if (file is null) return;
+ using var form = new MultipartFormDataContent(); form.Add(new StringContent(model.SelectedItem!.ToString()!), "variant");
+ form.Add(new StreamContent(File.OpenRead(file)), "file", Path.GetFileName(file));
+ using var response = await Send(HttpMethod.Post, "minecraft/profile/skins", form, token); Report("皮肤已更新。");
+ }), ActionButton("重置皮肤", async token => { using var response = await Send(HttpMethod.Delete, "minecraft/profile/skins/active", null, token); Report("皮肤已重置。"); }),
+ ActionButton("下载当前皮肤", async token =>
+ {
+ var url = current?["skins"]?.AsArray().FirstOrDefault()?["url"]?.ToString() ?? throw new InvalidOperationException("请先读取在线皮肤。");
+ var target = await PickSave("保存皮肤", "skin.png"); if (target is null) return;
+ var bytes = await _http.GetByteArrayAsync(url, token); await File.WriteAllBytesAsync(target, bytes, token);
+ })));
+ panel.Children.Add(capes); panel.Children.Add(Row(ActionButton("使用所选披风", async token =>
+ {
+ var cape = capes.SelectedItem as Cape ?? throw new InvalidOperationException("请选择披风。");
+ using var response = await Send(HttpMethod.Put, "minecraft/profile/capes/active", System.Net.Http.Json.JsonContent.Create(new { capeId = cape.Id }), token); Report("披风已切换。");
+ }), ActionButton("隐藏披风", async token => { using var response = await Send(HttpMethod.Delete, "minecraft/profile/capes/active", null, token); Report("披风已隐藏。"); })));
+ return new ScrollViewer { Content = panel };
+ }
+ private sealed record Cape(string Id, string Name) { public override string ToString() => Name; }
+}
diff --git a/PCL.Avalonia/MainWindow.Installation.cs b/PCL.Avalonia/MainWindow.Installation.cs
new file mode 100644
index 000000000..2da93de0a
--- /dev/null
+++ b/PCL.Avalonia/MainWindow.Installation.cs
@@ -0,0 +1,116 @@
+using ResourceProvider = PCL.CrossPlatform.ResourceProvider;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Platform.Storage;
+using PCL.Core.Utils.Secret;
+using PCL.CrossPlatform;
+
+namespace PCL.Avalonia;
+
+public sealed partial class MainWindow
+{
+ private Control InstallationPages() => Tabs(("Minecraft", DownloadPage()), ("加载器", LoaderPage()), ("资源搜索", ResourcesPage()), ("整合包", ModpackPage()), ("Java", JavaDownloadPage()));
+ private static TabControl Tabs(params (string Name, Control Page)[] pages)
+ {
+ var tabs = new TabControl(); foreach (var (name, page) in pages) tabs.Items.Add(new TabItem { Header = name, Content = page }); return tabs;
+ }
+ private Control LoaderPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("安装加载器"));
+ var minecraft = new TextBox { Watermark = "Minecraft 版本,例如 1.20.1" };
+ var kind = new ComboBox { ItemsSource = Enum.GetValues(), SelectedIndex = 0 };
+ var versions = new ListBox { Height = 230 };
+ panel.Children.Add(minecraft); panel.Children.Add(kind);
+ panel.Children.Add(ActionButton("获取加载器版本", async token =>
+ {
+ versions.ItemsSource = await new LoaderInstaller(_http).GetVersionsAsync((LoaderKind)kind.SelectedItem!, minecraft.Text?.Trim() ?? "", token); versions.SelectedIndex = 0;
+ })); panel.Children.Add(versions);
+ panel.Children.Add(Row(ActionButton("安装加载器", async token =>
+ {
+ if (versions.SelectedItem is not LoaderVersion version) throw new InvalidOperationException("请选择加载器版本。");
+ var id = await new LoaderInstaller(_http).InstallAsync(GameRoot, _java.Text!, version, Progress(), token);
+ SelectInstalled(id); Report("加载器安装完成:" + id);
+ }), ActionButton("从本地安装器安装", async token =>
+ {
+ var file = await PickFile("选择加载器安装器"); if (file is null) return;
+ var id = await new LoaderInstaller(_http).InstallLocalAsync(GameRoot, _java.Text!, file, minecraft.Text?.Trim() ?? "", Progress(), token);
+ SelectInstalled(id); Report("加载器安装完成:" + id);
+ })));
+ return new ScrollViewer { Content = panel };
+ }
+ private Control ResourcesPage()
+ {
+ var panel = Page(); var provider = new ComboBox { ItemsSource = Enum.GetValues(), SelectedIndex = 0 };
+ var kind = new ComboBox { ItemsSource = Enum.GetValues(), SelectedIndex = 0 };
+ var query = new TextBox { Watermark = "搜索名称" }; var game = new TextBox { Watermark = "Minecraft 版本筛选(可留空)" };
+ var loader = new TextBox { Watermark = "加载器筛选,例如 fabric、forge(可留空)" };
+ var projects = new ListBox { Height = 180 }; var releases = new ComboBox { HorizontalAlignment = HorizontalAlignment.Stretch };
+ panel.Children.Add(Heading("资源下载")); panel.Children.Add(Row(provider, kind)); panel.Children.Add(query); panel.Children.Add(game); panel.Children.Add(loader);
+ ResourceCatalog Catalog() => new(_http, CurseForgeKey);
+ panel.Children.Add(Row(ActionButton("搜索资源", async token => { projects.ItemsSource = await Catalog().SearchAsync((ResourceProvider)provider.SelectedItem!, (ResourceKind)kind.SelectedItem!, query.Text ?? "", game.Text ?? "", loader.Text ?? "", token); projects.SelectedIndex = 0; }),
+ ActionButton("显示收藏", _ => { projects.ItemsSource = _settings.Favorites.Select(p => new ResourceProject(p.Provider, p.Id, p.Title, "", p.Url)).ToArray(); return Task.CompletedTask; })));
+ panel.Children.Add(projects);
+ panel.Children.Add(Row(ActionButton("获取可用版本", async token =>
+ {
+ var project = projects.SelectedItem as ResourceProject ?? throw new InvalidOperationException("请选择项目。");
+ releases.ItemsSource = await Catalog().VersionsAsync(project, game.Text ?? "", loader.Text ?? "", token); releases.SelectedIndex = 0;
+ }), ActionButton("收藏 / 取消收藏", _ =>
+ {
+ var project = projects.SelectedItem as ResourceProject ?? throw new InvalidOperationException("请选择项目。");
+ var index = _settings.Favorites.FindIndex(p => p.Provider == project.Provider && p.Id == project.Id);
+ if (index >= 0) _settings.Favorites.RemoveAt(index); else _settings.Favorites.Add(new(project.Provider, project.Id, project.Title, project.WebUrl));
+ SaveSettings(); Report("收藏已更新。"); return Task.CompletedTask;
+ }), ActionButton("项目网页", async _ => { if (projects.SelectedItem is ResourceProject project) await Launcher.LaunchUriAsync(new Uri(project.WebUrl)); })));
+ panel.Children.Add(releases);
+ panel.Children.Add(ActionButton("下载并安装所选资源", async token =>
+ {
+ var project = projects.SelectedItem as ResourceProject ?? throw new InvalidOperationException("请选择项目。");
+ var release = releases.SelectedItem as ResourceRelease ?? throw new InvalidOperationException("请选择文件版本。");
+ var resourceKind = (ResourceKind)kind.SelectedItem!;
+ var destination = resourceKind == ResourceKind.Modpack ? Path.Combine(PCL.Core.App.Paths.Temp, "modpacks")
+ : Path.Combine(CurrentDataDirectory, resourceKind switch { ResourceKind.Mod => "mods", ResourceKind.Shader => "shaderpacks", _ => "resourcepacks" });
+ EnsureGameStopped();
+ var files = await Catalog().InstallAsync(project.Provider, release, destination, game.Text ?? "", loader.Text ?? "", token);
+ Report("已安装:" + string.Join(", ", files.Select(Path.GetFileName)));
+ if (resourceKind == ResourceKind.Modpack) Report("整合包已下载到:" + files.Last() + "。请在整合包页导入。");
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+ private Control ModpackPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("整合包导入与导出"));
+ var name = new TextBox { Watermark = "新实例名称" };
+ var optional = new CheckBox { Content = "安装可选文件" };
+ panel.Children.Add(name); panel.Children.Add(optional);
+ panel.Children.Add(ActionButton("导入整合包", async token =>
+ {
+ var file = await PickFile("选择整合包(mrpack / zip)"); if (file is null) return;
+ GameManifest.ValidateId(name.Text ?? "");
+ var result = await new ModpackInstaller(_http, CurseForgeKey).InstallAsync(file,
+ GameManifest.SafePath(GameRoot, "instances/" + name.Text), _java.Text!, optional.IsChecked == true, Progress(), token);
+ _root.Text = result.Root; _settings.GameDirectory = result.Root; SelectInstalled(result.VersionId); SaveSettings();
+ Report("整合包安装完成。");
+ }));
+ panel.Children.Add(ActionButton("导出当前实例", async token =>
+ {
+ EnsureGameStopped(); var target = await PickSave("导出实例", "instance.zip"); if (target is null) return;
+ await InstanceFiles.ExportAsync(CurrentDataDirectory, target, token); Report("实例已导出:" + target);
+ }));
+ panel.Children.Add(Label("导入在独立暂存目录完成;失败或取消不会留下半成品实例。导出排除启动器凭据及密钥。"));
+ return new ScrollViewer { Content = panel };
+ }
+ private string? CurseForgeKey => string.IsNullOrWhiteSpace(_settings.EncryptedCurseForgeKey) ? null : EncryptHelper.SecretDecrypt(_settings.EncryptedCurseForgeKey);
+ private async Task PickFile(string title)
+ {
+ var result = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = title, AllowMultiple = false });
+ return result.FirstOrDefault()?.TryGetLocalPath();
+ }
+ private async Task PickSave(string title, string name)
+ {
+ var result = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = title, SuggestedFileName = name, ShowOverwritePrompt = true });
+ return result?.TryGetLocalPath();
+ }
+ private void SelectInstalled(string id) { _settings.SelectedVersion = id; RefreshInstalled(); _installed.SelectedItem = id; }
+ private void EnsureGameStopped() { if (_game is { HasExited: false }) throw new InvalidOperationException("请先退出游戏。"); }
+}
diff --git a/PCL.Avalonia/MainWindow.Instances.cs b/PCL.Avalonia/MainWindow.Instances.cs
new file mode 100644
index 000000000..76814b36c
--- /dev/null
+++ b/PCL.Avalonia/MainWindow.Instances.cs
@@ -0,0 +1,203 @@
+using ResourceProvider = PCL.CrossPlatform.ResourceProvider;
+using Avalonia.Controls.Templates;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+using PCL.Core.Link.McPing;
+using PCL.Core.Minecraft.Saves;
+using PCL.Core.Minecraft.Saves.Editing;
+using PCL.CrossPlatform;
+
+namespace PCL.Avalonia;
+
+public sealed partial class MainWindow
+{
+ private Control InstancePages() => Tabs(("设置", InstanceSetupPage()), ("Mod", LocalFilesPage("mods")), ("资源包", LocalFilesPage("resourcepacks")),
+ ("光影与投影", Tabs(("光影", LocalFilesPage("shaderpacks")), ("投影", LocalFilesPage("schematics")))), ("存档", SavesPage()), ("服务器", ServersPage()), ("截图", ScreenshotsPage()));
+ private InstancePreferences InstanceOptions => _settings.Instances.TryGetValue(InstanceKey, out var value) ? value : _settings.Instances[InstanceKey] = new();
+ private string InstanceKey => GameRoot + "|" + SelectedVersion;
+ private string CurrentDataDirectory => InstanceFiles.DataDirectory(GameRoot, SelectedVersion, InstanceOptions.Isolated);
+ private string TrashDirectory => Path.Combine(GameRoot, ".pcl-trash");
+
+ private Control InstanceSetupPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("当前实例设置"));
+ var isolated = new CheckBox { Content = "版本隔离(Mod、存档与游戏设置使用独立目录)" };
+ var jvm = new TextBox { Watermark = "附加 JVM 参数" }; var arguments = new TextBox { Watermark = "附加游戏参数" };
+ var environment = new TextBox { Watermark = "环境变量,每行 NAME=VALUE", AcceptsReturn = true, Height = 80 };
+ var width = new NumericUpDown { Minimum = 320, Maximum = 16384, Value = 854 }; var height = new NumericUpDown { Minimum = 240, Maximum = 16384, Value = 480 };
+ var server = new TextBox { Watermark = "启动后加入的服务器地址(可留空)" };
+ var java = new TextBox { Watermark = "此版本的 Java 绝对路径(留空使用全局设置)" };
+ var memory = new NumericUpDown { Minimum = 0, Maximum = 1048576, Value = 0 };
+ panel.Children.Add(ActionButton("载入当前版本设置", _ =>
+ {
+ var options = InstanceOptions; java.Text = options.JavaPath; memory.Value = options.MemoryMb; isolated.IsChecked = options.Isolated; jvm.Text = options.JvmArguments;
+ arguments.Text = options.GameArguments; environment.Text = options.EnvironmentVariables;
+ width.Value = options.Width; height.Value = options.Height; server.Text = options.ServerAddress;
+ Report("已载入 " + SelectedVersion + " 的设置。"); return Task.CompletedTask;
+ }));
+ panel.Children.Add(java); panel.Children.Add(Label("此版本最大内存(MiB,0 使用全局设置)")); panel.Children.Add(memory);
+ panel.Children.Add(isolated); panel.Children.Add(jvm); panel.Children.Add(arguments); panel.Children.Add(environment);
+ panel.Children.Add(Label("游戏窗口宽度与高度")); panel.Children.Add(Row(width, height)); panel.Children.Add(server);
+ panel.Children.Add(ActionButton("保存当前版本设置", _ =>
+ {
+ var options = InstanceOptions; options.JavaPath = java.Text; options.MemoryMb = (int)(memory.Value ?? 0); options.Isolated = isolated.IsChecked == true; options.JvmArguments = jvm.Text ?? "";
+ options.GameArguments = arguments.Text ?? ""; options.EnvironmentVariables = environment.Text ?? "";
+ options.Width = (int)(width.Value ?? 854); options.Height = (int)(height.Value ?? 480); options.ServerAddress = server.Text ?? "";
+ SaveSettings(); Report("实例设置已保存。"); return Task.CompletedTask;
+ }));
+ var copyName = new TextBox { Watermark = "副本版本名称" }; panel.Children.Add(copyName);
+ panel.Children.Add(Row(ActionButton("创建版本副本", async token =>
+ {
+ EnsureGameStopped(); GameManifest.ValidateId(copyName.Text ?? "");
+ var path = GameManifest.VersionPath(GameRoot, copyName.Text!);
+ if (File.Exists(path)) throw new IOException("同名版本已存在。");
+ var version = new System.Text.Json.Nodes.JsonObject { ["id"] = copyName.Text, ["inheritsFrom"] = SelectedVersion };
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!); await File.WriteAllTextAsync(path, version.ToJsonString(), token);
+ SelectInstalled(copyName.Text!); InstanceOptions.Isolated = true; SaveSettings(); Report("版本副本已创建,共享游戏依赖并使用独立数据目录。");
+ }), ActionButton("移除当前版本(保留备份)", _ =>
+ {
+ EnsureGameStopped(); var path = Path.GetDirectoryName(GameManifest.VersionPath(GameRoot, SelectedVersion))!;
+ foreach (var candidate in Directory.GetFiles(Path.Combine(GameRoot, "versions"), "*.json", SearchOption.AllDirectories))
+ if (System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(candidate))?["inheritsFrom"]?.ToString() == SelectedVersion)
+ throw new InvalidOperationException("其他版本依赖此版本,请先移除其派生版本。");
+ Report("版本已移至备份:" + InstanceFiles.Archive(path, TrashDirectory)); RefreshInstalled(); return Task.CompletedTask;
+ })));
+ panel.Children.Add(ActionButton("打开备份目录", async _ => { Directory.CreateDirectory(TrashDirectory); await Launcher.LaunchDirectoryInfoAsync(new DirectoryInfo(TrashDirectory)); }));
+ return new ScrollViewer { Content = panel };
+ }
+
+ private Control LocalFilesPage(string folder, Func? root = null)
+ {
+ var panel = Page(); var files = new ListBox { Height = 250 }; var details = new TextBox { IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, Height = 130 };
+ var game = new TextBox { Watermark = "检查更新时使用的 Minecraft 版本" }; var loader = new TextBox { Watermark = "加载器,例如 fabric" };
+ string DirectoryPath() => Path.Combine(root?.Invoke() ?? CurrentDataDirectory, folder);
+ void Refresh() => files.ItemsSource = InstanceFiles.List(DirectoryPath());
+ InstanceFile Selected() => files.SelectedItem as InstanceFile ?? throw new InvalidOperationException("请选择文件。");
+ panel.Children.Add(Heading(folder));
+ panel.Children.Add(Row(ActionButton("刷新", _ => { Refresh(); return Task.CompletedTask; }), ActionButton("导入文件", async token =>
+ {
+ EnsureGameStopped(); var file = await PickFile("选择资源文件"); if (file is null) return;
+ await InstanceFiles.ImportFileAsync(file, DirectoryPath(), token); Refresh();
+ }), ActionButton("打开目录", async _ => { Directory.CreateDirectory(DirectoryPath()); await Launcher.LaunchDirectoryInfoAsync(new DirectoryInfo(DirectoryPath())); })));
+ panel.Children.Add(files);
+ panel.Children.Add(Row(ActionButton("启用 / 禁用", _ => { EnsureGameStopped(); InstanceFiles.Toggle(Selected().Path); Refresh(); return Task.CompletedTask; }),
+ ActionButton("移至备份", _ => { EnsureGameStopped(); Report("文件已移至:" + InstanceFiles.Archive(Selected().Path, TrashDirectory)); Refresh(); return Task.CompletedTask; }),
+ ActionButton("读取详情与内嵌 Mod", async token =>
+ {
+ var path = Selected().Path;
+ details.Text = await Task.Run(() =>
+ {
+ var info = new LocalComp.LocalCompFile(path); info.LoadBasicInfo();
+ return $"{info.Name}\n{info.Version}\n{info.Description}\n作者:{info.Authors}\n依赖:{string.Join(", ", info.Dependencies.Keys)}\n内嵌:{string.Join(", ", info.EmbeddedMods.Select(m => m.Name))}";
+ }, token);
+ })));
+ panel.Children.Add(details); panel.Children.Add(game); panel.Children.Add(loader);
+ panel.Children.Add(ActionButton("检查并安装选中文件的更新", async token =>
+ {
+ EnsureGameStopped(); var file = Selected(); var catalog = new ResourceCatalog(_http);
+ var update = await catalog.FindUpdateAsync(file.Path, game.Text ?? "", loader.Text ?? "", token);
+ if (update is null) { Report("没有找到匹配的更新。"); return; }
+ var stage = Path.Combine(PCL.Core.App.Paths.Temp, "resource-update-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ var downloads = await catalog.InstallAsync(ResourceProvider.Modrinth, update, stage, game.Text ?? "", loader.Text ?? "", token);
+ foreach (var item in downloads)
+ {
+ var target = Path.Combine(DirectoryPath(), Path.GetFileName(item));
+ if (File.Exists(target)) InstanceFiles.Archive(target, TrashDirectory);
+ File.Move(item, target);
+ }
+ if (File.Exists(file.Path) && !downloads.Any(d => Path.GetFileName(d) == file.Name)) InstanceFiles.Archive(file.Path, TrashDirectory);
+ Refresh(); Report("资源已更新,旧文件已备份。");
+ }
+ finally { if (Directory.Exists(stage)) Directory.Delete(stage, true); }
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+
+ private Control SavesPage()
+ {
+ var panel = Page(); var saves = new ListBox { Height = 230 }; var summary = new TextBlock { TextWrapping = TextWrapping.Wrap };
+ var difficulty = new ComboBox { ItemsSource = Enum.GetValues(), SelectedIndex = 2 };
+ var commands = new CheckBox { Content = "允许作弊命令" }; var locked = new CheckBox { Content = "锁定难度" };
+ SaveInfo Selected() => saves.SelectedItem as SaveInfo ?? throw new InvalidOperationException("请选择存档。");
+ panel.Children.Add(Heading("存档管理"));
+ async Task Refresh(CancellationToken token)
+ {
+ var list = new List(); await foreach (var save in new SaveManager().LoadSavesAsync(Path.Combine(CurrentDataDirectory, "saves"), token)) list.Add(save);
+ saves.ItemsSource = list; saves.ItemTemplate = new FuncDataTemplate((save, _) => new TextBlock { Text = $"{save?.LevelName} · {save?.VersionName} · {save?.GameMode}" });
+ }
+ saves.SelectionChanged += (_, _) => { if (saves.SelectedItem is SaveInfo save) { summary.Text = $"种子:{save.Seed} · 时间:{save.PlayTime}\n{save.FolderPath}"; commands.IsChecked = save.AllowCommands; locked.IsChecked = save.IsDifficultyLocked; difficulty.SelectedItem = save.Difficulty ?? Difficulty.Normal; } };
+ panel.Children.Add(ActionButton("读取存档", Refresh)); panel.Children.Add(saves); panel.Children.Add(summary);
+ panel.Children.Add(ActionButton("管理所选存档的数据包", async _ =>
+ {
+ var save = Selected();
+ var dialog = new Window { Title = "数据包 · " + save.LevelName, Width = 780, Height = 650,
+ Content = LocalFilesPage("datapacks", () => save.FolderPath) };
+ await dialog.ShowDialog(this);
+ }));
+ panel.Children.Add(Row(difficulty, commands, locked));
+ panel.Children.Add(Row(ActionButton("保存存档设置", async token =>
+ {
+ EnsureGameStopped(); var save = Selected();
+ var backup = Path.Combine(TrashDirectory, "save-" + Guid.NewGuid().ToString("N") + ".zip"); Directory.CreateDirectory(TrashDirectory);
+ await InstanceFiles.ExportAsync(save.FolderPath, backup, token);
+ await new SaveManager().ApplyChangesAsync(save.FolderPath, new SaveChanges { AllowCommands = new(commands.IsChecked == true), LockDifficulty = new(locked.IsChecked == true), Difficulty = new((Difficulty)difficulty.SelectedItem!) }, token);
+ await Refresh(token); Report("存档设置已保存,原存档已备份。");
+ }), ActionButton("导出存档", async token => { var target = await PickSave("导出存档", "world.zip"); if (target is not null) await InstanceFiles.ExportAsync(Selected().FolderPath, target, token); }),
+ ActionButton("移至备份", async token => { EnsureGameStopped(); InstanceFiles.Archive(Selected().FolderPath, TrashDirectory); await Refresh(token); })));
+ panel.Children.Add(ActionButton("导入存档压缩包", async token =>
+ {
+ EnsureGameStopped(); var source = await PickFile("选择存档压缩包"); if (source is null) return;
+ var stage = Path.Combine(PCL.Core.App.Paths.Temp, "save-import-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(stage);
+ try
+ {
+ await InstanceFiles.ExtractAsync(source, stage, token: token);
+ var root = PCL.Core.Minecraft.SaveImportHelper.GetSaveRootDirectory(stage) ?? throw new InvalidDataException("没有找到 level.dat。");
+ var name = Path.GetFileNameWithoutExtension(source); GameManifest.ValidateId(name);
+ var destination = GameManifest.SafePath(Path.Combine(CurrentDataDirectory, "saves"), name);
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!); Directory.Move(root, destination); await Refresh(token);
+ }
+ finally { if (Directory.Exists(stage)) Directory.Delete(stage, true); }
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+
+ private Control ServersPage()
+ {
+ var panel = Page(); var list = new ListBox { Height = 200 }; var name = new TextBox { Watermark = "服务器名称" }; var address = new TextBox { Watermark = "地址,支持 IPv6 与端口" };
+ string FilePath() => Path.Combine(CurrentDataDirectory, "servers.dat");
+ void Refresh() => list.ItemsSource = ServerStore.Read(FilePath());
+ panel.Children.Add(Heading("服务器收藏")); panel.Children.Add(ActionButton("读取服务器列表", _ => { Refresh(); return Task.CompletedTask; })); panel.Children.Add(list);
+ list.SelectionChanged += (_, _) => { if (list.SelectedItem is SavedServer item) { name.Text = item.Name; address.Text = item.Address; } };
+ panel.Children.Add(name); panel.Children.Add(address);
+ panel.Children.Add(Row(ActionButton("保存服务器", _ =>
+ {
+ EnsureGameStopped(); if (string.IsNullOrWhiteSpace(address.Text)) throw new InvalidOperationException("请填写地址。");
+ var servers = ServerStore.Read(FilePath()).ToList(); if (list.SelectedItem is SavedServer old) servers.Remove(old);
+ servers.Add(new(name.Text ?? address.Text, address.Text)); ServerStore.Write(FilePath(), servers); Refresh(); return Task.CompletedTask;
+ }), ActionButton("移除收藏", _ => { EnsureGameStopped(); var servers = ServerStore.Read(FilePath()).ToList(); if (list.SelectedItem is SavedServer old) servers.Remove(old); ServerStore.Write(FilePath(), servers); Refresh(); return Task.CompletedTask; }),
+ ActionButton("检查服务器状态", async token =>
+ {
+ var uri = new Uri("minecraft://" + address.Text); var port = uri.Port < 0 ? 25565 : uri.Port;
+ var result = await McPingServiceFactory.CreateService(uri.Host, port).PingAsync(token); Report(System.Text.Json.JsonSerializer.Serialize(result));
+ })));
+ panel.Children.Add(ActionButton("启动并加入此服务器", async token => { InstanceOptions.ServerAddress = address.Text ?? ""; SaveSettings(); await Launch(token); }));
+ return new ScrollViewer { Content = panel };
+ }
+
+ private Control ScreenshotsPage()
+ {
+ var panel = Page(); var list = new ListBox { Height = 160 }; var image = new Image { Height = 260, Stretch = Stretch.Uniform }; Bitmap? bitmap = null;
+ panel.Children.Add(Heading("游戏截图"));
+ panel.Children.Add(ActionButton("刷新截图", _ => { list.ItemsSource = InstanceFiles.List(Path.Combine(CurrentDataDirectory, "screenshots")); return Task.CompletedTask; })); panel.Children.Add(list);
+ list.SelectionChanged += (_, _) => { bitmap?.Dispose(); bitmap = null; if (list.SelectedItem is InstanceFile file) { bitmap = new Bitmap(file.Path); image.Source = bitmap; } };
+ panel.Children.Add(image); panel.Children.Add(Row(ActionButton("打开截图", async _ => { if (list.SelectedItem is InstanceFile file) await Launcher.LaunchFileInfoAsync(new FileInfo(file.Path)); }),
+ ActionButton("移至备份", _ => { if (list.SelectedItem is InstanceFile file) { image.Source = null; bitmap?.Dispose(); bitmap = null; InstanceFiles.Archive(file.Path, TrashDirectory); list.ItemsSource = InstanceFiles.List(Path.Combine(CurrentDataDirectory, "screenshots")); } return Task.CompletedTask; })));
+ Closed += (_, _) => bitmap?.Dispose(); return new ScrollViewer { Content = panel };
+ }
+}
diff --git a/PCL.Avalonia/MainWindow.Preferences.cs b/PCL.Avalonia/MainWindow.Preferences.cs
new file mode 100644
index 000000000..5f999e3fa
--- /dev/null
+++ b/PCL.Avalonia/MainWindow.Preferences.cs
@@ -0,0 +1,162 @@
+using Avalonia.Controls.Primitives;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+using LibVLCSharp.Avalonia;
+using PCL.Core.IO.Net.Http;
+using PCL.CrossPlatform;
+using PCL.Core.Platform;
+using PCL.Core.Utils.Secret;
+
+namespace PCL.Avalonia;
+
+public sealed partial class MainWindow
+{
+ private readonly DesktopMediaPlayer _mediaPlayer = new();
+ private readonly Image _background = new() { Stretch = Stretch.UniformToFill, Opacity = .18, IsHitTestVisible = false };
+ private Bitmap? _backgroundBitmap;
+ private readonly DesktopLocalization _localization = new();
+ private Control PreferencePages() => Tabs(("常规", SettingsPage()), ("下载与网络", NetworkPage()), ("外观与媒体", AppearancePage()), ("语言", LanguagePage()));
+ private Control NetworkPage()
+ {
+ var panel = Page(); var mirror = new TextBox { Text = _settings.DownloadMirror, Watermark = "BMCLAPI 兼容镜像地址(留空为官方源)" };
+ var key = new TextBox { PasswordChar = '●', Watermark = "CurseForge API 密钥(不修改时留空)" };
+ var proxyMode = new ComboBox { ItemsSource = Enum.GetValues(), SelectedItem = _settings.ProxyMode };
+ var proxy = new TextBox { Text = _settings.ProxyAddress, Watermark = "代理地址,例如 http://127.0.0.1:7890" };
+ panel.Children.Add(Heading("下载源与代理")); panel.Children.Add(mirror); panel.Children.Add(key); panel.Children.Add(proxyMode); panel.Children.Add(proxy);
+ panel.Children.Add(ActionButton("保存下载与网络设置", _ =>
+ {
+ DownloadRoutingHandler.Map(new Uri("https://piston-meta.mojang.com/test"), mirror.Text);
+ _settings.DownloadMirror = mirror.Text ?? "";
+ if (!string.IsNullOrWhiteSpace(key.Text)) { _settings.EncryptedCurseForgeKey = EncryptHelper.SecretEncrypt(key.Text); key.Text = ""; }
+ _settings.ProxyMode = (HttpProxyManager.ProxyMode)proxyMode.SelectedItem!; _settings.ProxyAddress = proxy.Text ?? "";
+ ApplyProxySettings(); SaveSettings(); Report("网络设置已保存,新请求将使用新设置。"); return Task.CompletedTask;
+ }));
+ panel.Children.Add(ActionButton("清除 CurseForge 密钥", _ => { _settings.EncryptedCurseForgeKey = null; SaveSettings(); return Task.CompletedTask; }));
+ return new ScrollViewer { Content = panel };
+ }
+ private void ApplyProxySettings()
+ {
+ var proxy = HttpProxyManager.Instance;
+ if (_settings.ProxyMode == HttpProxyManager.ProxyMode.CustomProxy)
+ proxy.CustomProxyAddress = Uri.TryCreate(_settings.ProxyAddress, UriKind.Absolute, out var uri) ? uri : throw new InvalidOperationException("代理地址无效。");
+ proxy.Mode = _settings.ProxyMode;
+ }
+ private Control AppearancePage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("外观与媒体"));
+ panel.Children.Add(Row(ActionButton("选择背景图片", async _ =>
+ {
+ var file = await PickFile("选择背景图片"); if (file is null) return; _settings.BackgroundImage = file; ApplyBackground(); SaveSettings();
+ }), ActionButton("清除背景", _ => { _settings.BackgroundImage = null; ApplyBackground(); SaveSettings(); return Task.CompletedTask; })));
+ var opacity = new Slider { Minimum = 0, Maximum = 1, Value = _settings.BackgroundOpacity };
+ opacity.ValueChanged += (_, _) => { _settings.BackgroundOpacity = opacity.Value; _background.Opacity = opacity.Value; };
+ var font = new TextBox { Text = _settings.FontName, Watermark = "字体名称(留空使用默认字体)" };
+ var accent = new TextBox { Text = _settings.AccentColor, Watermark = "主题色,例如 #3978D5" };
+ panel.Children.Add(Label("背景不透明度")); panel.Children.Add(opacity); panel.Children.Add(font); panel.Children.Add(accent);
+ panel.Children.Add(ActionButton("保存外观", _ =>
+ {
+ if (!string.IsNullOrWhiteSpace(accent.Text) && !Color.TryParse(accent.Text, out var parsedColor)) throw new InvalidOperationException("主题色格式无效。");
+ _settings.FontName = font.Text; _settings.AccentColor = accent.Text; ApplyAppearance(); SaveSettings(); return Task.CompletedTask;
+ }));
+ var vlc = new TextBox { Text = _settings.VlcLibraryDirectory, Watermark = "libvlc 所在目录(使用系统 VLC 时可留空)" };
+ var loop = new CheckBox { Content = "循环播放", IsChecked = _settings.LoopMusic };
+ var volume = new Slider { Minimum = 0, Maximum = 100, Value = _settings.MusicVolume };
+ volume.ValueChanged += (_, _) => _mediaPlayer.Volume((int)volume.Value);
+ panel.Children.Add(vlc); panel.Children.Add(loop); panel.Children.Add(Label("媒体音量")); panel.Children.Add(volume);
+ void Configure() { _settings.VlcLibraryDirectory = vlc.Text; _settings.MusicVolume = (int)volume.Value; _settings.LoopMusic = loop.IsChecked == true; _mediaPlayer.Initialize(vlc.Text); }
+ panel.Children.Add(Row(ActionButton("选择并播放音乐", async _ => { var file = await PickFile("选择音乐文件"); if (file is null) return; Configure(); _settings.MusicFile = file; _mediaPlayer.Play(file, _settings.MusicVolume, _settings.LoopMusic); SaveSettings(); }),
+ ActionButton("暂停 / 继续", _ => { _mediaPlayer.Pause(); return Task.CompletedTask; }), ActionButton("停止播放", _ => { _mediaPlayer.Stop(); return Task.CompletedTask; })));
+ panel.Children.Add(ActionButton("选择并播放背景视频", async _ =>
+ {
+ var file = await PickFile("选择视频文件"); if (file is null) return; Configure();
+ var layout = Content as Control; Content = null;
+ if (layout is VideoView previous) { layout = previous.Content as Control; previous.Content = null; previous.MediaPlayer = null; }
+ var video = new VideoView { MediaPlayer = _mediaPlayer.Player, Content = layout }; Content = video;
+ _mediaPlayer.Play(file, _settings.MusicVolume, _settings.LoopMusic);
+ }));
+ panel.Children.Add(ActionButton("保存媒体设置", _ => { _settings.VlcLibraryDirectory = vlc.Text; _settings.MusicVolume = (int)volume.Value; _settings.LoopMusic = loop.IsChecked == true; SaveSettings(); return Task.CompletedTask; }));
+ return new ScrollViewer { Content = panel };
+ }
+ private void ApplyAppearance()
+ {
+ FontFamily = string.IsNullOrWhiteSpace(_settings.FontName) ? global::Avalonia.Media.FontFamily.Default : new FontFamily(_settings.FontName);
+ if (Color.TryParse(_settings.AccentColor, out var color) && Application.Current is { } app)
+ {
+ app.Resources["SystemAccentColor"] = color;
+ app.Resources["SystemAccentColorLight1"] = color;
+ app.Resources["SystemAccentColorDark1"] = color;
+ }
+ _background.Opacity = Math.Clamp(_settings.BackgroundOpacity, 0, 1);
+ }
+ private void ApplyBackground()
+ {
+ ApplyAppearance();
+ _background.Source = null; _backgroundBitmap?.Dispose(); _backgroundBitmap = null;
+ if (_settings.BackgroundImage is { } file && File.Exists(file)) { _backgroundBitmap = new Bitmap(file); _background.Source = _backgroundBitmap; }
+ }
+ private Control LanguagePage()
+ {
+ var panel = Page(); var language = new ComboBox { ItemsSource = new[] { "zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "fr-FR", "es-ES" }, SelectedItem = _settings.Language };
+ panel.Children.Add(Heading("语言与区域")); panel.Children.Add(language);
+ panel.Children.Add(ActionButton("保存语言", _ =>
+ {
+ _settings.Language = language.SelectedItem!.ToString()!; _localization.ApplyLanguageResources("zh-CN", _settings.Language); PlatformServices.Localization = _localization;
+ TranslateVisuals(this); SaveSettings(); Report("语言设置已保存。"); return Task.CompletedTask;
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+ private void TranslateVisuals(Control control)
+ {
+ if (control is TextBlock text) text.Text = _localization.Translate(text.Text ?? "");
+ if (control is ContentControl { Content: string value } content) content.Content = _localization.Translate(value);
+ if (control is HeaderedContentControl { Header: string header } h) h.Header = _localization.Translate(header);
+ foreach (var child in global::Avalonia.LogicalTree.LogicalExtensions.GetLogicalChildren(control).OfType()) TranslateVisuals(child);
+ }
+ private Control HomepagePage() => Tabs(("主页", CustomHomepagePage()), ("Minecraft 新闻", NewsPage()));
+ private Control CustomHomepagePage()
+ {
+ var panel = Page(); var content = new StackPanel { Spacing = 14 };
+ panel.Children.Add(Heading("自定义主页"));
+ panel.Children.Add(Row(ActionButton("载入自定义主页", async token =>
+ {
+ var file = await PickFile("选择主页 XAML"); if (file is null) return;
+ var xml = await File.ReadAllTextAsync(file, token);
+ var control = CustomHomeRenderer.Render(xml, uri => _ = Launcher.LaunchUriAsync(uri));
+ content.Children.Clear(); content.Children.Add(control); _settings.HomepageFile = file; SaveSettings();
+ }), ActionButton("恢复默认主页", _ => { content.Children.Clear(); _settings.HomepageFile = null; SaveSettings(); return Task.CompletedTask; })));
+ content.Children.Add(Label("欢迎使用 PCL Community Edition。选择版本后即可启动,也可在此载入自定义主页。"));
+ if (_settings.HomepageFile is { } path && File.Exists(path))
+ {
+ try { content.Children.Clear(); content.Children.Add(CustomHomeRenderer.Render(File.ReadAllText(path), uri => _ = Launcher.LaunchUriAsync(uri))); }
+ catch (Exception ex) { content.Children.Add(Label("主页无法载入:" + ex.Message)); }
+ }
+ panel.Children.Add(content); return new ScrollViewer { Content = panel };
+ }
+ private Control NewsPage()
+ {
+ var panel = Page(); var news = new ListBox { Height = 330 };
+ news.ItemTemplate = new global::Avalonia.Controls.Templates.FuncDataTemplate((item, _) =>
+ new TextBlock { Text = item?.Title + "\n" + item?.Description, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(8) });
+ panel.Children.Add(Heading("Minecraft 新闻")); var page = 0;
+ panel.Children.Add(ActionButton("载入更多新闻", async token =>
+ {
+ var url = "https://net-secondary.web.minecraft-services.net/api/v1.0/zh-cn/search?pageSize=24&sortType=Recent&category=News&newsOnly=true&page=" + (++page);
+ var json = await _http.GetStringAsync(url, token);
+ var response = System.Text.Json.JsonSerializer.Deserialize(json);
+ var items = news.ItemsSource?.Cast().ToList() ?? [];
+ foreach (var item in response?.Result.Results ?? []) { item.Description = System.Net.WebUtility.HtmlDecode(item.Description); items.Add(item); }
+ news.ItemsSource = items;
+ })); panel.Children.Add(news);
+ panel.Children.Add(ActionButton("阅读所选新闻", async _ =>
+ {
+ var item = news.SelectedItem as PCL.Core.Model.Tools.News.NewsItem ?? throw new InvalidOperationException("请选择新闻。");
+ if (!PCL.Core.ViewModel.Homepage.NewsViewModel.IsSafeNewsLink(item.Url)) throw new InvalidDataException("新闻链接不属于可信来源。");
+ await Launcher.LaunchUriAsync(new Uri(item.Url));
+ })); return new ScrollViewer { Content = panel };
+ }
+
+}
diff --git a/PCL.Avalonia/MainWindow.Tools.cs b/PCL.Avalonia/MainWindow.Tools.cs
new file mode 100644
index 000000000..0a44ef808
--- /dev/null
+++ b/PCL.Avalonia/MainWindow.Tools.cs
@@ -0,0 +1,124 @@
+using Avalonia.Platform.Storage;
+using System.Diagnostics;
+using System.Text.Json.Nodes;
+using Avalonia.Controls;
+using PCL.Core.Link.EasyTier;
+using PCL.Core.Link.Scaffolding.EasyTier;
+using PCL.Core.Platform;
+using PCL.CrossPlatform;
+
+namespace PCL.Avalonia;
+
+public sealed partial class MainWindow
+{
+ private readonly PortableLobby _lobby = new();
+ private Control ToolsPages() => Tabs(("联机", LobbyPage()), ("崩溃分析", DiagnosticsPage()), ("更新", UpdatesPage()), ("帮助与关于", AboutPage()));
+ private Control LobbyPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("联机大厅"));
+ var code = new TextBox { Watermark = "大厅码" }; var port = new NumericUpDown { Minimum = 1, Maximum = 65535, Value = 25565 };
+ var directory = new TextBox { Text = _settings.EasyTierDirectory, Watermark = "EasyTier 可执行文件所在目录(留空使用自动安装位置)" };
+ var relays = new TextBox { Watermark = "自定义中继地址,每行一个,可留空", AcceptsReturn = true, Height = 75 };
+ panel.Children.Add(directory);
+ panel.Children.Add(Row(ActionButton("下载当前系统 EasyTier", async token => { await PortableLobby.InstallDependencyAsync(_http, token); Report("联机组件已安装。"); }),
+ ActionButton("选择已有 EasyTier", async _ =>
+ {
+ var folders = await StorageProvider.OpenFolderPickerAsync(new global::Avalonia.Platform.Storage.FolderPickerOpenOptions { Title = "选择 EasyTier 目录" });
+ if (folders.FirstOrDefault()?.TryGetLocalPath() is { } path) { directory.Text = path; _settings.EasyTierDirectory = path; SaveSettings(); }
+ })));
+ panel.Children.Add(port); panel.Children.Add(code); panel.Children.Add(relays);
+ var players = new ListBox { Height = 130 };
+ panel.Children.Add(ActionButton("刷新大厅成员", _ => { players.ItemsSource = _lobby.Players.ToArray(); return Task.CompletedTask; }));
+ panel.Children.Add(players);
+ void Configure()
+ {
+ EasyTierMetadata.DirectoryOverride = string.IsNullOrWhiteSpace(directory.Text) ? null : directory.Text;
+ ETRelay.RelayList = (relays.Text ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(url => new ETRelay { Url = url, Name = "Custom", Type = ETRelayType.Custom }).ToList();
+ }
+ panel.Children.Add(Row(ActionButton("创建大厅", async token => { Configure(); code.Text = await _lobby.HostAsync((int)(port.Value ?? 25565), _profile?.UserName ?? _offlineName.Text ?? "Player", token); Report("大厅已创建,可分享大厅码。"); }),
+ ActionButton("加入大厅", async token => { Configure(); var address = await _lobby.JoinAsync(code.Text ?? "", _profile?.UserName ?? _offlineName.Text ?? "Player", token); InstanceOptions.ServerAddress = address; Report("大厅已加入,游戏连接地址:" + address); }),
+ ActionButton("复制大厅码", async _ => { if (Clipboard is not null) await Clipboard.SetTextAsync(code.Text); }),
+ ActionButton("退出大厅", async _ => { await _lobby.DisposeAsync(); Report("已退出大厅。"); })));
+ panel.Children.Add(ActionButton("检测 NAT 与 IPv6", async token =>
+ {
+ Configure(); var result = await CliNetTest.GetNetStatusAsync(token)
+ ?? throw new InvalidOperationException("网络检测未返回有效结果。");
+ Report($"UDP NAT:{result.UdpNatType}\nTCP NAT:{result.TcpNatType}\nIPv6:{result.SupportIPv6}");
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+ private Control DiagnosticsPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("日志与崩溃分析"));
+ var content = new TextBox { AcceptsReturn = true, TextWrapping = global::Avalonia.Media.TextWrapping.Wrap, Height = 200, Watermark = "粘贴游戏日志或载入文件" };
+ var output = new TextBox { IsReadOnly = true, AcceptsReturn = true, TextWrapping = global::Avalonia.Media.TextWrapping.Wrap, Height = 180 };
+ panel.Children.Add(content);
+ panel.Children.Add(Row(ActionButton("载入日志文件", async token => { var file = await PickFile("选择游戏日志或崩溃报告"); if (file is not null) content.Text = await File.ReadAllTextAsync(file, token); }),
+ ActionButton("读取最新游戏日志", async token => { content.Text = await File.ReadAllTextAsync(Path.Combine(CurrentDataDirectory, "logs", "latest.log"), token); }),
+ ActionButton("分析原因", async token =>
+ {
+ var text = content.Text ?? ""; var findings = await Task.Run(() => PortableDiagnostics.Analyze(text), token);
+ output.Text = findings.Count == 0 ? "未匹配到已知原因。请同时提供 crash-report、hs_err 及 Mod 列表。" : string.Join("\n\n", findings.Select(f => $"{f.Cause} · {f.Confidence}\n{string.Join('\n', f.Evidence)}"));
+ })));
+ panel.Children.Add(output);
+ panel.Children.Add(ActionButton("导出诊断报告", async token => { var target = await PickSave("导出诊断", "diagnostics.txt"); if (target is not null) await File.WriteAllTextAsync(target, output.Text + "\n\n" + content.Text, token); }));
+ return new ScrollViewer { Content = panel };
+ }
+ private Control UpdatesPage()
+ {
+ var panel = Page(); var description = new TextBlock { TextWrapping = global::Avalonia.Media.TextWrapping.Wrap };
+ var prerelease = new CheckBox { Content = "包含测试版" }; DesktopRelease? release = null; PreparedUpdate? prepared = null;
+ var automatic = new CheckBox { Content = "启动时检查更新", IsChecked = _settings.CheckUpdatesOnStartup };
+ automatic.IsCheckedChanged += (_, _) => { _settings.CheckUpdatesOnStartup = automatic.IsChecked == true; SaveSettings(); };
+ panel.Children.Add(automatic); panel.Children.Add(Heading("启动器更新")); panel.Children.Add(prerelease); panel.Children.Add(description);
+ panel.Children.Add(ActionButton("检查最新版本", async token =>
+ {
+ release = await new DesktopUpdater(_http).LatestAsync(prerelease.IsChecked == true, token);
+ description.Text = release.Version + "\n\n" + release.Notes;
+ }));
+ panel.Children.Add(ActionButton("下载并准备更新", async token =>
+ {
+ if (release is null) throw new InvalidOperationException("请先检查版本。");
+ var archive = Path.Combine(PCL.Core.App.Paths.Temp, Guid.NewGuid().ToString("N") + ".tar.gz");
+ try
+ {
+ await new DesktopUpdater(_http).DownloadAsync(release, archive, token);
+ prepared = await DesktopUpdater.PrepareAsync(archive, AppContext.BaseDirectory, PCL.Core.App.Paths.Temp, token);
+ Report("更新已准备好。点击安装更新后会退出启动器,替换程序并重新启动;旧程序将保留在更新目录备份中。");
+ }
+ finally { if (File.Exists(archive)) File.Delete(archive); }
+ }));
+ panel.Children.Add(ActionButton("安装更新并重启", async _ =>
+ {
+ EnsureGameStopped(); if (prepared is null) throw new InvalidOperationException("请先下载并准备更新。");
+ await _lobby.DisposeAsync();
+ var helper = Process.Start(DesktopUpdater.CreateApplyProcess(prepared, Environment.ProcessId)) ?? throw new InvalidOperationException("无法启动更新助手。");
+ global::Avalonia.Threading.Dispatcher.UIThread.Post(Close);
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+ private Control AboutPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("PCL Community Edition"));
+ panel.Children.Add(Label("上游作者:龙腾猫跃 / PCL Community\n本仓库:bit-torch/PCL-CE\n跨平台界面:Avalonia\n媒体后端:LibVLCSharp / LibVLC(动态链接)"));
+ panel.Children.Add(Label($"运行环境:{System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}\n{System.Runtime.InteropServices.RuntimeInformation.OSDescription}\n配置:{DesktopSettings.FilePath}"));
+ panel.Children.Add(Row(ActionButton("项目主页", async _ => await Launcher.LaunchUriAsync(new Uri("https://github.com/bit-torch/PCL-CE"))),
+ ActionButton("使用文档", async _ => await Launcher.LaunchUriAsync(new Uri("https://docs.pclc.cc/ce/"))),
+ ActionButton("问题反馈", async _ => await Launcher.LaunchUriAsync(new Uri("https://github.com/bit-torch/PCL-CE/pulls")))));
+ panel.Children.Add(ActionButton("查看许可证", async _ => await Launcher.LaunchFileInfoAsync(new FileInfo(Path.Combine(AppContext.BaseDirectory, "LICENSE")))));
+ return new ScrollViewer { Content = panel };
+ }
+ private Control JavaDownloadPage()
+ {
+ var panel = Page(); var major = new ComboBox { ItemsSource = new[] { 8, 11, 17, 21, 25 }, SelectedItem = 21 };
+ panel.Children.Add(Heading("Java 运行时")); panel.Children.Add(major);
+ panel.Children.Add(ActionButton("下载并选择 Java", async token =>
+ {
+ _java.Text = await new JavaRuntimeInstaller(_http).InstallAsync((int)major.SelectedItem!, Path.Combine(PCL.Core.App.Paths.SharedLocalData, "Java"), Progress(), token);
+ SaveSettings(); Report("Java 安装完成。");
+ }));
+ panel.Children.Add(ActionButton("扫描本地 Java", ScanJava));
+ return new ScrollViewer { Content = panel };
+ }
+}
diff --git a/PCL.Avalonia/MainWindow.cs b/PCL.Avalonia/MainWindow.cs
new file mode 100644
index 000000000..64e93d4b8
--- /dev/null
+++ b/PCL.Avalonia/MainWindow.cs
@@ -0,0 +1,427 @@
+using System.Diagnostics;
+using System.Net.Http;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using PCL.Core.Minecraft.Java;
+using PCL.Core.Minecraft.Java.Parser;
+using PCL.Core.Minecraft.Java.Scanner;
+using PCL.Core.Minecraft.Profile.Authentication;
+using PCL.Core.Minecraft.Profile.Models;
+using PCL.CrossPlatform;
+
+namespace PCL.Avalonia;
+
+public sealed partial class MainWindow : Window
+{
+ private readonly DesktopSettings _settings;
+ private readonly bool _canSave;
+ private readonly HttpClient _http;
+ private readonly List _operationControls = [];
+ private readonly ComboBox _installed = new() { MinWidth = 320, HorizontalAlignment = HorizontalAlignment.Stretch };
+ private readonly ListBox _available = new() { Height = 280 };
+ private readonly TextBox _root = new();
+ private readonly TextBox _java = new();
+ private readonly TextBox _offlineName = new();
+ private readonly TextBox _clientId = new();
+ private readonly NumericUpDown _memory = new() { Minimum = 256, Maximum = 131072, Increment = 512 };
+ private readonly CheckBox _useAccount = new() { Content = "使用当前账户" };
+ private readonly TextBlock _account = new() { Text = "尚未登录", TextWrapping = TextWrapping.Wrap };
+ private readonly TextBlock _status = new() { Text = "就绪", TextWrapping = TextWrapping.Wrap };
+ private readonly TextBox _log = new() { IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinHeight = 300 };
+ private readonly ProgressBar _progress = new() { Minimum = 0, Maximum = 100, Height = 5 };
+ private readonly Button _cancel = new() { Content = "取消任务", IsEnabled = false };
+ private readonly Button _stop = new() { Content = "停止游戏", IsEnabled = false };
+ private CancellationTokenSource? _operation;
+ private Process? _game;
+ private bool _allowClose;
+ private TaskCompletionSource? _operationFinished;
+ private McProfile? _profile;
+
+ public MainWindow(DesktopSettings? settings = null)
+ {
+ Title = "PCL Community Edition · 跨平台预览";
+ Width = 960; Height = 720; MinWidth = 760; MinHeight = 580;
+ string? startupError = null;
+ try { _settings = settings ?? DesktopSettings.Load(); _canSave = true; }
+ catch (Exception ex) { _settings = new(); startupError = "设置文件无法读取,已保留原文件:" + ex.Message; }
+ _http = new HttpClient(new DownloadRoutingHandler(() => _settings.DownloadMirror, new SocketsHttpHandler { Proxy = PCL.Core.IO.Net.Http.HttpProxyManager.Instance })) { Timeout = TimeSpan.FromMinutes(10) };
+ _http.DefaultRequestHeaders.UserAgent.ParseAdd("PCL-CE/1.0");
+ ApplyProxySettings();
+ _localization.ApplyLanguageResources("zh-CN", _settings.Language);
+ PCL.Core.Platform.PlatformServices.Localization = _localization;
+ _root.Text = _settings.GameDirectory;
+ _java.Text = _settings.JavaPath;
+ _offlineName.Text = _settings.OfflineName;
+ _clientId.Text = _settings.MicrosoftClientId;
+ _memory.Value = Math.Clamp(_settings.MemoryMb, 256, 131072);
+ ApplyTheme(_settings.DarkTheme);
+ try { _profile = _settings.SelectedAccount(); }
+ catch (Exception ex) { startupError = "已保存的账户无法解密,原数据已保留:" + ex.Message; }
+ RefreshAccount();
+
+ var header = new StackPanel { Margin = new Thickness(26, 22, 26, 12), Spacing = 6 };
+ header.Children.Add(new TextBlock { Text = "PCL Community Edition", FontSize = 27, FontWeight = FontWeight.SemiBold });
+ header.Children.Add(new TextBlock { Text = $"跨平台预览 · {RuntimePlatform.Current.Os} / {RuntimePlatform.Current.Architecture}", Opacity = .65 });
+ var tabs = new TabControl { Margin = new Thickness(20, 0) };
+ tabs.Items.Add(new TabItem { Header = "主页", Content = HomepagePage() });
+ tabs.Items.Add(new TabItem { Header = "启动", Content = LaunchPage() });
+ tabs.Items.Add(new TabItem { Header = "安装与资源", Content = InstallationPages() });
+ tabs.Items.Add(new TabItem { Header = "实例管理", Content = InstancePages() });
+ tabs.Items.Add(new TabItem { Header = "账户", Content = AccountPages() });
+ tabs.Items.Add(new TabItem { Header = "设置", Content = PreferencePages() });
+ tabs.Items.Add(new TabItem { Header = "工具", Content = ToolsPages() });
+ tabs.Items.Add(new TabItem { Header = "日志", Content = LogsPage() });
+ var footer = new StackPanel { Margin = new Thickness(26, 12, 26, 18), Spacing = 10 };
+ footer.Children.Add(_progress);
+ footer.Children.Add(_status);
+ footer.Children.Add(Row(_cancel, _stop));
+ _cancel.Click += (_, _) => _operation?.Cancel();
+ _stop.Click += (_, _) =>
+ {
+ try { if (_game is { HasExited: false }) _game.Kill(entireProcessTree: true); }
+ catch (Exception ex) { Report(ex.Message); }
+ };
+ var layout = new Grid { RowDefinitions = new RowDefinitions("Auto,*,Auto") };
+ Grid.SetRowSpan(_background, 3); layout.Children.Add(_background);
+ layout.Children.Add(header); Grid.SetRow(tabs, 1); layout.Children.Add(tabs);
+ Grid.SetRow(footer, 2); layout.Children.Add(footer); Content = layout;
+ ApplyBackground(); TranslateVisuals(this);
+ Closing += async (_, e) =>
+ {
+ if (_allowClose) return;
+ if (_game is { HasExited: false })
+ {
+ e.Cancel = true; Report("游戏正在运行,请先退出游戏或点击“停止游戏”。"); return;
+ }
+ if (_operationFinished is not null || _lobby.IsRunning)
+ {
+ e.Cancel = true; _operation?.Cancel();
+ if (_operationFinished is { } pending) await pending.Task;
+ await _lobby.DisposeAsync(); _allowClose = true; Close();
+ }
+ };
+ Closed += async (_, _) => { await _lobby.DisposeAsync(); _mediaPlayer.Dispose(); _backgroundBitmap?.Dispose(); _http.Dispose(); _game?.Dispose(); };
+ Opened += async (_, _) =>
+ {
+ RefreshInstalled();
+ if (startupError is not null) Report(startupError);
+ else if (_java.Text is "java" or "java.exe") await RunOperation(ScanJava);
+ if (_settings.CheckUpdatesOnStartup) await RunOperation(async token =>
+ {
+ var release = await new DesktopUpdater(_http).LatestAsync(false, token);
+ var current = PCL.Core.App.Basics.VersionName;
+ Report(release.Version.TrimStart('v') == current ? "已是最新版本。" : "可用更新:" + release.Version + ",可在工具的更新页下载。");
+ });
+ };
+ }
+
+ private Control LaunchPage()
+ {
+ var panel = Page();
+ panel.Children.Add(Heading("开始游戏"));
+ panel.Children.Add(Label("选择本地版本")); panel.Children.Add(_installed);
+ panel.Children.Add(Row(ActionButton("刷新版本", _ => { RefreshInstalled(); return Task.CompletedTask; }),
+ ActionButton("打开游戏目录", async _ => await Launcher.LaunchDirectoryInfoAsync(new DirectoryInfo(GameRoot)))));
+ panel.Children.Add(Label("离线用户名")); panel.Children.Add(_offlineName); panel.Children.Add(_useAccount);
+ panel.Children.Add(Row(ActionButton("启动游戏", Launch), ActionButton("检查并修复文件", Repair)));
+ panel.Children.Add(new TextBlock
+ {
+ Text = "可在“安装与资源”安装加载器和整合包,在“实例管理”管理 Mod、存档与服务器。",
+ TextWrapping = TextWrapping.Wrap, Opacity = .65, Margin = new Thickness(0, 18, 0, 0)
+ });
+ return new ScrollViewer { Content = panel };
+ }
+
+ private Control DownloadPage()
+ {
+ var panel = Page();
+ panel.Children.Add(Heading("安装 Minecraft"));
+ var snapshots = new CheckBox { Content = "包含快照版本" };
+ panel.Children.Add(snapshots);
+ panel.Children.Add(ActionButton("获取版本列表", async token =>
+ {
+ var versions = await new GameInstaller(_http).GetVersionsAsync(token);
+ _available.ItemsSource = versions.Where(v => snapshots.IsChecked == true || v.Type == "release").ToArray();
+ _available.SelectedIndex = 0; Report($"已获取 {_available.ItemCount} 个版本。");
+ }));
+ panel.Children.Add(_available);
+ panel.Children.Add(ActionButton("安装所选版本", async token =>
+ {
+ if (_available.SelectedItem is not AvailableVersion version) throw new InvalidOperationException("请先选择要安装的版本。");
+ SaveSettings();
+ await new GameInstaller(_http).InstallAsync(GameRoot, version, Progress(), token);
+ _settings.SelectedVersion = version.Id; RefreshInstalled(); Report($"{version.Id} 安装完成。");
+ }));
+ return new ScrollViewer { Content = panel };
+ }
+
+ private Control AccountPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("账户")); panel.Children.Add(_account);
+ panel.Children.Add(Label("Microsoft OAuth 客户端 ID")); panel.Children.Add(_clientId);
+ panel.Children.Add(new TextBlock { Text = "使用已获 Minecraft API 权限的应用客户端 ID。授权时会显示设备码,并在浏览器中完成登录。", TextWrapping = TextWrapping.Wrap, Opacity = .65 });
+ panel.Children.Add(Row(ActionButton("微软账户登录", Login), ActionButton("退出账户", _ =>
+ {
+ _settings.RemoveSelectedAccount(); _profile = _settings.SelectedAccount(); SaveSettings(); RefreshAccount(); return Task.CompletedTask;
+ })));
+ return new ScrollViewer { Content = panel };
+ }
+
+ private Control SettingsPage()
+ {
+ var panel = Page(); panel.Children.Add(Heading("启动设置"));
+ panel.Children.Add(Label("Minecraft 目录")); panel.Children.Add(_root);
+ panel.Children.Add(ActionButton("选择文件夹", async _ =>
+ {
+ var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "选择 Minecraft 目录", AllowMultiple = false });
+ if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } path) { _root.Text = path; RefreshInstalled(); }
+ }));
+ panel.Children.Add(Label("Java 可执行文件")); panel.Children.Add(_java);
+ panel.Children.Add(Row(ActionButton("自动检测 Java", ScanJava), ActionButton("选择 Java", async _ =>
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "选择 Java 可执行文件", AllowMultiple = false });
+ if (files.Count > 0 && files[0].TryGetLocalPath() is { } path) _java.Text = path;
+ })));
+ panel.Children.Add(Label("最大内存(MiB)")); panel.Children.Add(_memory);
+ panel.Children.Add(Label("游戏启动后的窗口行为"));
+ var behavior = new ComboBox { ItemsSource = Enum.GetValues(), SelectedItem = _settings.WindowBehavior };
+ behavior.SelectionChanged += (_, _) => { if (behavior.SelectedItem is LaunchWindowBehavior value) _settings.WindowBehavior = value; }; panel.Children.Add(behavior);
+ var analyze = new CheckBox { Content = "游戏异常退出后自动分析日志", IsChecked = _settings.AutoAnalyzeCrash };
+ analyze.IsCheckedChanged += (_, _) => _settings.AutoAnalyzeCrash = analyze.IsChecked == true; panel.Children.Add(analyze);
+ var dark = new CheckBox { Content = "深色主题", IsChecked = _settings.DarkTheme };
+ dark.IsCheckedChanged += (_, _) => { _settings.DarkTheme = dark.IsChecked == true; ApplyTheme(_settings.DarkTheme); };
+ panel.Children.Add(dark);
+ panel.Children.Add(ActionButton("保存设置", _ => { SaveSettings(); Report("设置已保存。"); return Task.CompletedTask; }));
+ return new ScrollViewer { Content = panel };
+ }
+
+ private async Task Login(CancellationToken token)
+ {
+ var provider = new MicrosoftProvider(_clientId.Text);
+ var result = await provider.AuthenticateAsync(new AuthenticationRequest
+ {
+ DeviceCodeHandler = async (context, ct) =>
+ {
+ var data = context.Data;
+ var uri = new Uri(data.VerificationUriComplete ?? data.VerificationUri!);
+ var code = $"登录地址:{uri}\n设备码:{data.UserCode}";
+ await Dispatcher.UIThread.InvokeAsync(() => Report(code));
+ await Dispatcher.UIThread.InvokeAsync(async () => { await Launcher.LaunchUriAsync(uri); });
+ var until = DateTimeOffset.UtcNow.AddSeconds(data.ExpiresIn ?? 900);
+ var interval = Math.Max(data.Interval ?? 5, 1);
+ while (DateTimeOffset.UtcNow < until)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(interval), ct);
+ var auth = await context.PollAsync(ct);
+ if (auth is null) continue;
+ if (!auth.IsError) return auth;
+ if (auth.Error == "slow_down") { interval += 5; continue; }
+ if (auth.Error == "authorization_pending") continue;
+ throw new InvalidOperationException(auth.ErrorDescription ?? auth.Error);
+ }
+ throw new TimeoutException("设备授权已超时,请重新登录。");
+ }
+ }, token);
+ _settings.StoreAccount(result); _profile = _settings.SelectedAccount(); SaveSettings(); RefreshAccount();
+ Report("登录成功。");
+ }
+
+ private async Task Launch(CancellationToken token)
+ {
+ if (_game is { HasExited: false }) throw new InvalidOperationException("游戏已经在运行。");
+ var id = SelectedVersion; SaveSettings();
+ var javaPath = (string.IsNullOrWhiteSpace(InstanceOptions.JavaPath) ? _java.Text : InstanceOptions.JavaPath)?.Trim() ?? "";
+ if (!Path.IsPathFullyQualified(javaPath) || !File.Exists(javaPath))
+ throw new InvalidOperationException("请在设置中选择 Java,或使用自动检测。");
+ IJavaParser parser = OperatingSystem.IsWindows() ? new PeHeaderParser() : new ReleaseFileJavaParser();
+ var java = parser.Parse(javaPath).Match(value => value, error => throw error);
+ var manifest = GameManifest.Load(GameRoot, id);
+ var required = manifest["javaVersion"]?["majorVersion"]?.GetValue() ?? 8;
+ if (java.MajorVersion < required) throw new InvalidOperationException($"此版本至少需要 Java {required},当前为 {java.MajorVersion}。");
+ LaunchAccount account;
+ if (_useAccount.IsChecked == true)
+ {
+ var profile = _profile ?? throw new InvalidOperationException("请先登录账户。");
+ if (profile.ProfileType != PCL.Core.Minecraft.Profile.Models.ProfileType.Offline && (profile.IsExpired || profile.ProfileType == PCL.Core.Minecraft.Profile.Models.ProfileType.Authlib))
+ {
+ var provider = AccountProvider(profile);
+ if (provider is YggdrasilConnectProvider connect) await connect.InitializeAsync(token);
+ var refreshed = await provider.RefreshAsync(profile, token);
+ _settings.StoreAccount(refreshed); _profile = _settings.SelectedAccount(); SaveSettings(); RefreshAccount();
+ profile = _profile!;
+ }
+ account = new(profile.UserName, profile.Uuid, profile.AccessToken, profile.ProfileType switch { PCL.Core.Minecraft.Profile.Models.ProfileType.Microsoft => "msa", PCL.Core.Minecraft.Profile.Models.ProfileType.Offline => "legacy", _ => "mojang" });
+ }
+ else account = LaunchAccount.Offline(_offlineName.Text ?? "");
+ token.ThrowIfCancellationRequested();
+ var root = GameRoot;
+ var launchOptions = await BuildLaunchOptions(token);
+ var memory = InstanceOptions.MemoryMb > 0 ? InstanceOptions.MemoryMb : (int)(_memory.Value ?? 2048);
+ _game = await Task.Run(() => new GameProcess().Start(root, id, javaPath, account,
+ memory, line => Dispatcher.UIThread.Post(() => AppendLog(line)), launchOptions), token);
+ _stop.IsEnabled = true; Report($"{id} 已启动。");
+ if (_settings.WindowBehavior == LaunchWindowBehavior.Exit && !_lobby.IsRunning)
+ { _game.Dispose(); _game = null; Dispatcher.UIThread.Post(Close); return; }
+ if (_settings.WindowBehavior == LaunchWindowBehavior.Hide) Hide();
+ else if (_settings.WindowBehavior == LaunchWindowBehavior.Minimize) WindowState = WindowState.Minimized;
+ _ = ObserveGame(_game, launchOptions.GameDirectory!);
+ }
+
+ private async Task ObserveGame(Process process, string gameDirectory)
+ {
+ await process.WaitForExitAsync();
+ var exitCode = process.ExitCode;
+ string? diagnosis = null;
+ if (exitCode != 0 && _settings.AutoAnalyzeCrash)
+ {
+ var latest = Path.Combine(gameDirectory, "logs", "latest.log");
+ if (File.Exists(latest))
+ {
+ try
+ {
+ var text = await File.ReadAllTextAsync(latest);
+ var findings = await Task.Run(() => PortableDiagnostics.Analyze(text));
+ diagnosis = string.Join("\n", findings.Select(f => "诊断:" + f.Cause + " · " + string.Join(", ", f.Evidence)));
+ }
+ catch (Exception ex) { diagnosis = "自动诊断失败:" + ex.Message; }
+ }
+ }
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ if (ReferenceEquals(_game, process))
+ {
+ Show(); WindowState = WindowState.Normal;
+ Report($"游戏已退出,退出码:{exitCode}"); _stop.IsEnabled = false;
+ if (!string.IsNullOrWhiteSpace(diagnosis)) Report(diagnosis);
+ _game = null;
+ }
+ process.Dispose();
+ });
+ }
+
+ private async Task Repair(CancellationToken token)
+ {
+ SaveSettings(); await new GameInstaller(_http).RepairAsync(GameRoot, SelectedVersion, Progress(), token);
+ Report("游戏文件检查完成。");
+ }
+
+ private async Task ScanJava(CancellationToken token)
+ {
+ var paths = await Task.Run(() =>
+ {
+ var results = new List();
+ new PathEnvironmentScanner().Scan(results); new UnixJavaScanner().Scan(results);
+ if (OperatingSystem.IsWindows()) { new RegistryJavaScanner().Scan(results); new DefaultPathsScanner().Scan(results); }
+ IJavaParser parser = OperatingSystem.IsWindows() ? new PeHeaderParser() : new ReleaseFileJavaParser();
+ return results.Distinct().Select(path => parser.Parse(path).Match(value => value, _ => null))
+ .Where(value => value is not null).OrderByDescending(value => value!.Version).ToArray();
+ }, token);
+ token.ThrowIfCancellationRequested();
+ if (paths.Length == 0) throw new InvalidOperationException("未找到可用 Java,请安装 JDK 后手动选择可执行文件。");
+ _java.Text = paths[0]!.JavaExePath;
+ Report($"已找到 {paths.Length} 个 Java,选用 {paths[0]}");
+ }
+
+ private void RefreshInstalled()
+ {
+ try
+ {
+ var versions = Path.Combine(GameRoot, "versions");
+ var ids = Directory.Exists(versions) ? Directory.GetDirectories(versions).Select(Path.GetFileName)
+ .Where(id => id is not null && File.Exists(GameManifest.VersionPath(GameRoot, id))).OrderDescending().ToArray() : [];
+ _installed.ItemsSource = ids;
+ _installed.SelectedItem = ids.FirstOrDefault(id => id == _settings.SelectedVersion) ?? ids.FirstOrDefault();
+ }
+ catch (Exception ex) { Report(ex.Message); }
+ }
+
+ private string GameRoot => Path.GetFullPath(string.IsNullOrWhiteSpace(_root.Text) ? _settings.GameDirectory : _root.Text.Trim());
+ private string SelectedVersion => _installed.SelectedItem as string ?? throw new InvalidOperationException("请先安装或选择一个游戏版本。");
+ private IProgress Progress() => new Progress(value =>
+ {
+ _progress.Value = value.Total == 0 ? 0 : value.Completed * 100.0 / value.Total;
+ _status.Text = $"{value.Message} · {value.Completed}/{value.Total}";
+ });
+
+ private void SaveSettings()
+ {
+ if (!_canSave) throw new InvalidOperationException("原设置文件无法读取。请修复设置文件后重启,以免覆盖数据。");
+ _settings.GameDirectory = GameRoot; _settings.JavaPath = _java.Text ?? "";
+ _settings.MemoryMb = (int)(_memory.Value ?? 2048); _settings.OfflineName = _offlineName.Text ?? "Player";
+ _settings.MicrosoftClientId = _clientId.Text ?? ""; _settings.SelectedVersion = _installed.SelectedItem as string;
+ _settings.Save();
+ }
+
+ private void RefreshAccount()
+ {
+ _account.Text = _profile is null ? "尚未登录,可使用离线用户名启动游戏。" : $"当前账户:{_profile.UserName}";
+ _useAccount.IsChecked = _profile is not null;
+ }
+ private static void ApplyTheme(bool dark) => Application.Current!.RequestedThemeVariant = dark ? ThemeVariant.Dark : ThemeVariant.Light;
+ private void Report(string text) { _status.Text = text; AppendLog(text); }
+ private string _logText = "";
+ private readonly TextBox _logSearch = new() { Watermark = "筛选日志(忽略大小写)" };
+ private Control LogsPage()
+ {
+ var panel = Page(); panel.Children.Add(_logSearch);
+ _logSearch.TextChanged += (_, _) => RefreshLogView();
+ panel.Children.Add(Row(ActionButton("导出日志", async token =>
+ {
+ var target = await PickSave("导出启动器日志", "launcher.log");
+ if (target is not null) await File.WriteAllTextAsync(target, _logText, token);
+ }), ActionButton("复制筛选结果", async _ => { if (Clipboard is not null) await Clipboard.SetTextAsync(_log.Text); }),
+ ActionButton("清空日志", _ => { _logText = ""; RefreshLogView(); return Task.CompletedTask; })));
+ panel.Children.Add(_log); return new ScrollViewer { Content = panel };
+ }
+ private void AppendLog(string text)
+ {
+ var combined = _logText + text + Environment.NewLine;
+ _logText = combined.Length > 40000 ? combined[^30000..] : combined;
+ RefreshLogView();
+ }
+ private void RefreshLogView()
+ {
+ var query = _logSearch.Text;
+ _log.Text = string.IsNullOrWhiteSpace(query) ? _logText : string.Join(Environment.NewLine,
+ _logText.Split(Environment.NewLine).Where(line => line.Contains(query, StringComparison.OrdinalIgnoreCase)));
+ _log.CaretIndex = _log.Text.Length;
+ }
+
+ private async Task RunOperation(Func action)
+ {
+ if (_operation is not null) return;
+ using var source = new CancellationTokenSource(); _operation = source;
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _operationFinished = completion;
+ foreach (var control in _operationControls) control.IsEnabled = false;
+ _cancel.IsEnabled = true; _progress.Value = 0;
+ try { await action(source.Token); }
+ catch (OperationCanceledException) { Report("任务已取消。"); }
+ catch (Exception ex) { Report("操作失败:" + ex.Message); }
+ finally
+ {
+ _operation = null; _operationFinished = null; completion.TrySetResult(); _cancel.IsEnabled = false;
+ foreach (var control in _operationControls) control.IsEnabled = true;
+ }
+ }
+
+ private Button ActionButton(string text, Func action)
+ {
+ var button = new Button { Content = text };
+ if (text is "启动游戏" or "安装所选版本") button.Classes.Add("accent");
+ _operationControls.Add(button);
+ button.Click += async (_, _) => await RunOperation(action); return button;
+ }
+ private static StackPanel Page() => new() { Margin = new Thickness(6, 20), Spacing = 12, MaxWidth = 760, HorizontalAlignment = HorizontalAlignment.Stretch };
+ private static TextBlock Heading(string text) => new() { Text = text, FontSize = 21, FontWeight = FontWeight.SemiBold, Margin = new Thickness(0, 0, 0, 8) };
+ private static TextBlock Label(string text) => new() { Text = text, Margin = new Thickness(0, 6, 0, 0), Opacity = .75 };
+ private static StackPanel Row(params Control[] children)
+ {
+ var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
+ foreach (var child in children) row.Children.Add(child); return row;
+ }
+}
diff --git a/PCL.Avalonia/PCL.Avalonia.csproj b/PCL.Avalonia/PCL.Avalonia.csproj
new file mode 100644
index 000000000..825eccfd6
--- /dev/null
+++ b/PCL.Avalonia/PCL.Avalonia.csproj
@@ -0,0 +1,26 @@
+
+
+ WinExe
+ net10.0
+ PCL.Avalonia
+ PCL-CE
+ enable
+ enable
+ 15
+ ../Plain Craft Launcher 2/Images/icon.ico
+ true
+ Debug;CI;Release;Beta
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PCL.Avalonia/Program.cs b/PCL.Avalonia/Program.cs
new file mode 100644
index 000000000..3ffadf87c
--- /dev/null
+++ b/PCL.Avalonia/Program.cs
@@ -0,0 +1,30 @@
+using Avalonia;
+
+namespace PCL.Avalonia;
+
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ var identity = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)));
+ using var instance = new PCL.CrossPlatform.SingleInstanceHost("PCLCE-" + Convert.ToHexString(identity)[..24]);
+ if (!instance.OwnsInstance)
+ {
+ try { instance.ActivatePrimaryAsync().GetAwaiter().GetResult(); }
+ catch (Exception ex) when (ex is IOException or TimeoutException) { Console.Error.WriteLine("无法激活已运行的启动器:" + ex.Message); }
+ return 0;
+ }
+ instance.ActivateRequested += () => global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ if (Application.Current?.ApplicationLifetime is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow is { } window)
+ { window.Show(); window.WindowState = global::Avalonia.Controls.WindowState.Normal; window.Activate(); }
+ });
+ instance.Listen();
+ return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+
+ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure()
+ .UsePlatformDetect().WithInterFont().LogToTrace();
+}
diff --git a/PCL.Core.Test/ArchitectureTest.cs b/PCL.Core.Test/ArchitectureTest.cs
index a7eba751c..4ca279302 100644
--- a/PCL.Core.Test/ArchitectureTest.cs
+++ b/PCL.Core.Test/ArchitectureTest.cs
@@ -6,7 +6,6 @@ namespace PCL.Test;
/// 架构边界:业务程序集不得引用 WPF。
[TestClass]
-[TestCategory("KnownFailure")]
public sealed class ArchitectureTest
{
[TestMethod]
diff --git a/PCL.Core.Wpf/PCL.Core.Wpf.csproj b/PCL.Core.Wpf/PCL.Core.Wpf.csproj
index 32a9f7555..0eb9f44b9 100644
--- a/PCL.Core.Wpf/PCL.Core.Wpf.csproj
+++ b/PCL.Core.Wpf/PCL.Core.Wpf.csproj
@@ -22,6 +22,9 @@
+
+
+
diff --git a/PCL.Core/App/Basics.cs b/PCL.Core/App/Basics.cs
index 386580fe4..731ab88dc 100644
--- a/PCL.Core/App/Basics.cs
+++ b/PCL.Core/App/Basics.cs
@@ -21,8 +21,17 @@ public static class Basics
///
/// 启动器元数据。
///
- public static MetadataModel Metadata { get; } = JsonSerializer.Deserialize(
- Assembly.GetEntryAssembly()!.GetManifestResourceStream("PCL.metadata.json")!, JsonCompat.SerializerOptions)!;
+ public static MetadataModel Metadata { get; } = _LoadMetadata();
+
+ private static MetadataModel _LoadMetadata()
+ {
+ // Libraries may be hosted by testhost or another executable without launcher resources.
+ using var stream = Assembly.GetEntryAssembly()?.GetManifestResourceStream("PCL.metadata.json")
+ ?? typeof(Basics).Assembly.GetManifestResourceStream("PCL.metadata.json")
+ ?? throw new InvalidDataException("Launcher metadata resource is missing.");
+ return JsonSerializer.Deserialize(stream, JsonCompat.SerializerOptions)
+ ?? throw new InvalidDataException("Launcher metadata resource is empty.");
+ }
///
/// 版本名称。
diff --git a/PCL.Core/App/Paths.cs b/PCL.Core/App/Paths.cs
index 9a2c8d583..dee47d15a 100644
--- a/PCL.Core/App/Paths.cs
+++ b/PCL.Core/App/Paths.cs
@@ -71,7 +71,9 @@ static Paths()
const string oldName = ".PCLCE";
#endif
// fill paths
- _data = Path.Combine(DefaultDirectory, "PCL");
+ _data = OperatingSystem.IsWindows()
+ ? Path.Combine(DefaultDirectory, "PCL")
+ : GetSpecialPath(Special.ApplicationData, name);
_sharedData = GetSpecialPath(Special.ApplicationData, name);
_sharedLocalData = GetSpecialPath(Special.LocalApplicationData, name);
_temp = Path.Combine(Path.GetTempPath(), name);
diff --git a/PCL.Core/AssemblyInfo.cs b/PCL.Core/AssemblyInfo.cs
index 01ffdd851..74590f99d 100644
--- a/PCL.Core/AssemblyInfo.cs
+++ b/PCL.Core/AssemblyInfo.cs
@@ -2,4 +2,5 @@
[assembly: DisableRuntimeMarshalling]
[assembly: InternalsVisibleTo("PCL.Core.Test")]
-[assembly: InternalsVisibleTo("PCL.Core.Wpf")]
\ No newline at end of file
+[assembly: InternalsVisibleTo("PCL.Core.Wpf")]
+[assembly: InternalsVisibleTo("PCL.CrossPlatform.Test")]
diff --git a/PCL.Core/IO/Net/Http/HttpProxyManager.cs b/PCL.Core/IO/Net/Http/HttpProxyManager.cs
index 0c35f8733..b1251cf99 100644
--- a/PCL.Core/IO/Net/Http/HttpProxyManager.cs
+++ b/PCL.Core/IO/Net/Http/HttpProxyManager.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
+using System.Net.Http;
using Microsoft.Win32;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
@@ -26,10 +27,13 @@ public enum ProxyMode
private readonly WebProxy _systemWebProxy = new() { BypassProxyOnLocal = true };
private const string ProxyRegPathFull = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
private const string ProxyRegPath = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
- private readonly RegistryChangeMonitor _proxyMonitor = new(ProxyRegPath);
+ private readonly RegistryChangeMonitor? _proxyMonitor;
+ private readonly IWebProxy _portableSystemProxy = HttpClient.DefaultProxy;
private HttpProxyManager()
{
+ if (!OperatingSystem.IsWindows()) return;
+ _proxyMonitor = new RegistryChangeMonitor(ProxyRegPath);
RefreshSystemProxy(); // 初始化系统代理
_proxyMonitor.Changed += _OnSystemProxyChanged;
}
@@ -109,6 +113,7 @@ private static ProxyProtocol _ParseProtocol(string scheme)
/// 刷新系统代理设置
public void RefreshSystemProxy()
{
+ if (!OperatingSystem.IsWindows()) return;
lock (_lock)
{
try
@@ -176,7 +181,8 @@ public bool BypassOnLocal
return _mode switch
{
ProxyMode.NoProxy => null, // 返回 null 表明没有代理
- ProxyMode.SystemProxy => _systemWebProxy.GetProxy(destination),
+ ProxyMode.SystemProxy => OperatingSystem.IsWindows()
+ ? _systemWebProxy.GetProxy(destination) : _portableSystemProxy.GetProxy(destination),
ProxyMode.CustomProxy => _customWebProxy.GetProxy(destination),
_ => null
};
@@ -190,7 +196,8 @@ public bool IsBypassed(Uri host)
return _mode switch
{
ProxyMode.NoProxy => true,
- ProxyMode.SystemProxy => _systemWebProxy.IsBypassed(host),
+ ProxyMode.SystemProxy => OperatingSystem.IsWindows()
+ ? _systemWebProxy.IsBypassed(host) : _portableSystemProxy.IsBypassed(host),
ProxyMode.CustomProxy => _customWebProxy.IsBypassed(host),
_ => true
};
@@ -220,7 +227,7 @@ public ICredentials? Credentials
public void Dispose()
{
- _proxyMonitor.Dispose();
+ _proxyMonitor?.Dispose();
GC.SuppressFinalize(this);
}
}
diff --git a/PCL.Core/IO/Net/NetworkService.cs b/PCL.Core/IO/Net/NetworkService.cs
index fcf972f3f..a0430311a 100644
--- a/PCL.Core/IO/Net/NetworkService.cs
+++ b/PCL.Core/IO/Net/NetworkService.cs
@@ -61,12 +61,17 @@ public partial class NetworkService
private static IHttpClientFactory? _factory;
[LifecycleStart]
- private static void _Start()
+ private static void _Start() => Initialize();
+
+ /// Initializes named clients for a desktop host. Hosts without ConfigService use system networking.
+ public static void Initialize(bool useConfiguration = true)
{
var services = new ServiceCollection();
services.ConfigureHttpClientDefaults(b => b
- .ConfigurePrimaryHttpMessageHandler(_GetSocketsHttpHandler)
+ .ConfigurePrimaryHttpMessageHandler(() => useConfiguration
+ ? _GetSocketsHttpHandler() : new SocketsHttpHandler
+ { AutomaticDecompression = DecompressionMethods.All, Proxy = HttpProxyManager.Instance })
.ConfigureHttpClient(c => c.DefaultRequestHeaders
.UserAgent.Add(new ProductInfoHeaderValue("PCL-CE", Basics.VersionName)))
.SetHandlerLifetime(TimeSpan.FromMinutes(LifeTime)));
diff --git a/PCL.Core/Link/Scaffolding/EasyTier/CliNetTest.cs b/PCL.Core/Link/Scaffolding/EasyTier/CliNetTest.cs
index e52c6e7bd..cb4ed4f81 100644
--- a/PCL.Core/Link/Scaffolding/EasyTier/CliNetTest.cs
+++ b/PCL.Core/Link/Scaffolding/EasyTier/CliNetTest.cs
@@ -1,6 +1,6 @@
using System;
using System.Diagnostics;
-using System.IO.Pipelines;
+using System.Threading;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
@@ -32,12 +32,14 @@ public record NetStatus
public required bool SupportIPv6;
}
- public async static Task GetNetStatusAsync()
+ public static Task GetNetStatusAsync() => GetNetStatusAsync(CancellationToken.None);
+
+ public async static Task GetNetStatusAsync(CancellationToken cancellationToken)
{
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
- FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
+ FileName = EasyTierMetadata.ExecutablePath("easytier-cli"),
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
Arguments = $"-o json stun",
ErrorDialog = false,
@@ -53,18 +55,30 @@ public record NetStatus
};
cliProcess.EnableRaisingEvents = true;
cliProcess.Start();
- var reader = PipeReader.Create(cliProcess.StandardOutput.BaseStream);
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(30));
+ using var registration = timeout.Token.Register(() =>
+ {
+ try { if (!cliProcess.HasExited) cliProcess.Kill(entireProcessTree: true); }
+ catch (InvalidOperationException) { }
+ });
+ // Drain stderr concurrently; a full redirected buffer must not deadlock STUN.
+ var stderr = cliProcess.StandardError.ReadToEndAsync(timeout.Token);
StunInfo? stunInfo = null;
try
{
- stunInfo = await JsonSerializer.DeserializeAsync(reader, JsonCompat.SerializerOptions);
+ stunInfo = await JsonSerializer.DeserializeAsync(cliProcess.StandardOutput.BaseStream, JsonCompat.SerializerOptions, timeout.Token);
}
+ catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
LogWrapper.Warn(ex, "Link", "Failed to do net test");
}
- if (stunInfo is null) return null;
+ await cliProcess.WaitForExitAsync(timeout.Token);
+ await stderr;
+ cancellationToken.ThrowIfCancellationRequested();
+ if (stunInfo is null || cliProcess.ExitCode != 0) return null;
var supportIPv6 = false;
foreach (var ip in stunInfo.Ips)
diff --git a/PCL.Core/Link/Scaffolding/EasyTier/EasyTierEntity.cs b/PCL.Core/Link/Scaffolding/EasyTier/EasyTierEntity.cs
index c936cac56..c392ab8a8 100644
--- a/PCL.Core/Link/Scaffolding/EasyTier/EasyTierEntity.cs
+++ b/PCL.Core/Link/Scaffolding/EasyTier/EasyTierEntity.cs
@@ -69,9 +69,9 @@ public EasyTierEntity(LobbyInfo lobby, int minecraftPort, int scfPort, bool asHo
LogWrapper.Info("EasyTier", $"EasyTier folder path: {EasyTierMetadata.EasyTierFilePath}");
- if (!(File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\easytier-core.exe") &&
- File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe") &&
- File.Exists($"{EasyTierMetadata.EasyTierFilePath}\\Packet.dll")))
+ if (!(File.Exists(EasyTierMetadata.ExecutablePath("easytier-core")) &&
+ File.Exists(EasyTierMetadata.ExecutablePath("easytier-cli")) &&
+ (!OperatingSystem.IsWindows() || File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "Packet.dll")))))
{
LogWrapper.Error("EasyTier", "EasyTier was broken.");
@@ -156,7 +156,7 @@ private async Task _BuildProcessAsync(bool asHost)
EnableRaisingEvents = true,
StartInfo = new ProcessStartInfo
{
- FileName = Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-core.exe"),
+ FileName = EasyTierMetadata.ExecutablePath("easytier-core"),
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
WindowStyle = ProcessWindowStyle.Hidden
}
@@ -168,12 +168,12 @@ private async Task _BuildProcessAsync(bool asHost)
.AddFlag("multi-thread")
.AddFlag("enable-kcp-proxy")
.AddFlag("enable-quic-proxy")
- .AddFlagIf(!Config.Link.TryPunchSym, "disable-sys-hole-punching")
- .AddFlagIf(!Config.Link.EnableIPv6, "disable-ipv6")
- .AddFlagIf(Config.Link.UseLatencyFirstMode, "latency-first")
+ .AddFlagIf(PCL.Core.App.Configuration.ConfigService.IsInitialized && !Config.Link.TryPunchSym, "disable-sys-hole-punching")
+ .AddFlagIf(PCL.Core.App.Configuration.ConfigService.IsInitialized && !Config.Link.EnableIPv6, "disable-ipv6")
+ .AddFlagIf(PCL.Core.App.Configuration.ConfigService.IsInitialized && Config.Link.UseLatencyFirstMode, "latency-first")
.Add("encryption-algorithm", "aes-gcm")
.Add("compression", "zstd")
- .Add("default-protocol", Config.Link.ProtocolPreference.ToString().ToLowerInvariant())
+ .Add("default-protocol", PCL.Core.App.Configuration.ConfigService.IsInitialized ? Config.Link.ProtocolPreference.ToString().ToLowerInvariant() : "tcp")
.Add("network-name", _lobby.NetworkName)
.Add("network-secret", _lobby.NetworkSecret)
//.Add("relay-network-whitelist", _lobby.NetworkName)
@@ -223,7 +223,7 @@ private async Task _BuildProcessAsync(bool asHost)
process.StartInfo.Arguments = args.GetResult();
- LogWrapper.Debug("EasyTier", process.StartInfo.Arguments);
+ LogWrapper.Debug("EasyTier", process.StartInfo.Arguments.Replace(_lobby.NetworkSecret, "[redacted]"));
return process;
}
@@ -370,7 +370,7 @@ public async Task AddPortForwardAsync(string targetIp, int targetPort)
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
- FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
+ FileName = EasyTierMetadata.ExecutablePath("easytier-cli"),
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
ErrorDialog = false,
CreateNoWindow = true,
@@ -423,7 +423,7 @@ private async Task _GetCliOutputDebugAsync()
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
- FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
+ FileName = EasyTierMetadata.ExecutablePath("easytier-cli"),
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
Arguments = $"--rpc-portal 127.0.0.1:{_rpcPort} peer",
ErrorDialog = false,
@@ -469,7 +469,7 @@ private async Task _GetPlayersAsync()
using var cliProcess = new Process();
cliProcess.StartInfo = new ProcessStartInfo
{
- FileName = $"{EasyTierMetadata.EasyTierFilePath}\\easytier-cli.exe",
+ FileName = EasyTierMetadata.ExecutablePath("easytier-cli"),
WorkingDirectory = EasyTierMetadata.EasyTierFilePath,
Arguments = $"--rpc-portal 127.0.0.1:{_rpcPort} -o json peer",
ErrorDialog = false,
diff --git a/PCL.Core/Link/Scaffolding/EasyTier/EasyTierMetadata.cs b/PCL.Core/Link/Scaffolding/EasyTier/EasyTierMetadata.cs
index ababe9f55..b13d7a1e5 100644
--- a/PCL.Core/Link/Scaffolding/EasyTier/EasyTierMetadata.cs
+++ b/PCL.Core/Link/Scaffolding/EasyTier/EasyTierMetadata.cs
@@ -1,3 +1,4 @@
+using System;
using System.IO;
using System.Runtime.InteropServices;
using PCL.Core.App;
@@ -8,7 +9,14 @@ public static class EasyTierMetadata
{
public const string CurrentEasyTierVer = "2.6.4";
- public static string EasyTierFilePath => Path.Combine(Paths.SharedLocalData, "EasyTier",
+ public static string? DirectoryOverride { get; set; }
+ public static string ExecutableName(string name) => name + (OperatingSystem.IsWindows() ? ".exe" : "");
+ public static string ExecutablePath(string name) => Path.Combine(EasyTierFilePath, ExecutableName(name));
+ public static string PlatformName => OperatingSystem.IsWindows() ? "windows" : OperatingSystem.IsMacOS() ? "macos" : "linux";
+ public static string ArchitectureName => RuntimeInformation.OSArchitecture == Architecture.Arm64
+ ? (OperatingSystem.IsWindows() ? "arm64" : "aarch64") : "x86_64";
+
+ public static string EasyTierFilePath => DirectoryOverride ?? Path.Combine(Paths.SharedLocalData, "EasyTier",
CurrentEasyTierVer,
- $"easytier-windows-{(RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x86_64")}");
+ $"easytier-{PlatformName}-{ArchitectureName}");
}
\ No newline at end of file
diff --git a/PCL.Core/Logging/LogService.cs b/PCL.Core/Logging/LogService.cs
index 7defa2dbb..313db4b19 100644
--- a/PCL.Core/Logging/LogService.cs
+++ b/PCL.Core/Logging/LogService.cs
@@ -8,7 +8,7 @@
using PCL.Core.App.Localization;
using PCL.Core.UI;
using PCL.Core.UI.Dialogs;
-using System.Windows;
+using PCL.Core.Platform;
namespace PCL.Core.Logging;
@@ -46,7 +46,7 @@ private LogService()
public Task StartAsync()
{
Context.Trace("正在初始化 Logger 实例");
- var config = new LoggerConfiguration(Path.Combine(Basics.ExecutableDirectory, "PCL", "Log"));
+ var config = new LoggerConfiguration(Path.Combine(Paths.Data, "Log"));
_logger = new Logger(config);
Context.Trace("正在注册日志事件");
LogWrapper.OnLog += _OnWrapperLog;
@@ -120,11 +120,10 @@ private static void _LogAction(in LogEntry entry, ActionLevel actionLevel)
"SystemDialog.Fatal.Message.WithFeedbackGuidance",
_ComposeUserError(plain, entry.Exception));
- MessageBox.Show(
- message,
- Lang.Text("SystemDialog.Fatal.Title"),
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ if (PlatformServices.DialogService is { } dialogs)
+ dialogs.ShowFatal(message, Lang.Text("SystemDialog.Fatal.Title"));
+ else
+ Console.Error.WriteLine(message);
break;
}
diff --git a/PCL.Core/Minecraft/Java/JavaInstallation.cs b/PCL.Core/Minecraft/Java/JavaInstallation.cs
index d2dc7fb6f..106a812d0 100644
--- a/PCL.Core/Minecraft/Java/JavaInstallation.cs
+++ b/PCL.Core/Minecraft/Java/JavaInstallation.cs
@@ -12,11 +12,12 @@ public sealed record JavaInstallation(
bool Is64Bit,
bool IsJre)
{
- public string JavaExePath => Path.Combine(JavaFolder, "java.exe");
+ public string JavaExePath => Path.Combine(JavaFolder, OperatingSystem.IsWindows() ? "java.exe" : "java");
public string? JavawExePath
{
get
{
+ if (!OperatingSystem.IsWindows()) return null;
var javaw = Path.Combine(JavaFolder, "javaw.exe");
return File.Exists(javaw) ? javaw : null;
}
diff --git a/PCL.Core/Minecraft/Java/JavaManager.cs b/PCL.Core/Minecraft/Java/JavaManager.cs
index 8e1a1edcf..ed0cf9cf0 100644
--- a/PCL.Core/Minecraft/Java/JavaManager.cs
+++ b/PCL.Core/Minecraft/Java/JavaManager.cs
@@ -131,7 +131,7 @@ bool ShouldSkip()
private void _ScanInternal()
{
- var pathSet = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ var pathSet = new ConcurrentDictionary(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
Parallel.ForEach(_scanners, scanner =>
{
@@ -183,6 +183,7 @@ private void _ScanInternal()
private static bool _ShouldEnableByDefault(JavaInstallation inst)
{
+ if (!OperatingSystem.IsWindows()) return inst.IsStillAvailable;
var libDir = Path.Combine(Directory.GetParent(inst.JavaFolder)!.FullName, "lib");
var isUsable = (!inst.IsJre && File.Exists(Path.Combine(libDir, "jvm.lib"))) ||
(inst.IsJre && File.Exists(Path.Combine(libDir, "rt.jar")));
diff --git a/PCL.Core/Minecraft/Java/JavaService.cs b/PCL.Core/Minecraft/Java/JavaService.cs
index fc0a55604..62909b8e6 100644
--- a/PCL.Core/Minecraft/Java/JavaService.cs
+++ b/PCL.Core/Minecraft/Java/JavaService.cs
@@ -1,5 +1,6 @@
using PCL.Core.Minecraft.Java.Parser;
using PCL.Core.Minecraft.Java.Scanner;
+using System;
using System.Threading.Tasks;
using PCL.Core.App.IoC;
using PCL.Core.Minecraft.Java;
@@ -22,10 +23,11 @@ private static async Task _StartAsync()
Context.Info("Initializing Java Manager...");
_javaManager = new JavaManager(
- new PeHeaderParser(),
+ OperatingSystem.IsWindows() ? new PeHeaderParser() : new ReleaseFileJavaParser(),
[
new RegistryJavaScanner(),
new DefaultPathsScanner(),
+ new UnixJavaScanner(),
new PathEnvironmentScanner(),
new MicrosoftStoreJavaScanner(),
new WhereCommandScanner()
diff --git a/PCL.Core/Minecraft/Java/Parser/ReleaseFileJavaParser.cs b/PCL.Core/Minecraft/Java/Parser/ReleaseFileJavaParser.cs
new file mode 100644
index 000000000..cf99f00af
--- /dev/null
+++ b/PCL.Core/Minecraft/Java/Parser/ReleaseFileJavaParser.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using PCL.Core.Utils;
+
+namespace PCL.Core.Minecraft.Java.Parser;
+
+/// Reads the JDK release file on Unix without executing discovered binaries.
+public sealed class ReleaseFileJavaParser : IJavaParser
+{
+ public Result Parse(string javaExePath)
+ {
+ try
+ {
+ var file = new FileInfo(javaExePath);
+ if (!file.Exists) throw new FileNotFoundException("Java executable was not found.", javaExePath);
+ file = file.ResolveLinkTarget(true) as FileInfo ?? file;
+ var bin = file.Directory!.FullName;
+ var home = file.Directory.Parent!.FullName;
+ var release = Path.Combine(home, "release");
+ if (!File.Exists(release) && Directory.GetParent(home) is { } parent)
+ release = Path.Combine(parent.FullName, "release"); // JDK 8's jre/bin
+ var properties = File.ReadLines(release)
+ .Where(line => line.Contains('='))
+ .Select(line => line.Split('=', 2))
+ .ToDictionary(parts => parts[0], parts => parts[1].Trim().Trim('"'));
+ var versionText = properties["JAVA_VERSION"];
+ var match = Regex.Match(versionText, @"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[_\.](\d+))?");
+ if (!match.Success) throw new FormatException($"Invalid Java release version: {versionText}");
+ int Part(int n) => match.Groups[n].Success ? int.Parse(match.Groups[n].Value) : 0;
+ var version = new Version(Part(1), Part(2), Part(3), Part(4));
+ var arch = properties.GetValueOrDefault("OS_ARCH") switch
+ {
+ "x86_64" or "amd64" => MachineType.AMD64,
+ "aarch64" or "arm64" => MachineType.ARM64,
+ "x86" or "i386" or "i686" => MachineType.I386,
+ "arm" or "arm32" => MachineType.ARM,
+ var unknown => throw new PlatformNotSupportedException($"Unsupported Java architecture: {unknown}")
+ };
+ var vendor = properties.GetValueOrDefault("IMPLEMENTOR", "");
+ var brand = vendor.Contains("Adoptium", StringComparison.OrdinalIgnoreCase) ? JavaBrandType.EclipseTemurin
+ : vendor.Contains("Oracle", StringComparison.OrdinalIgnoreCase) ? JavaBrandType.Oracle
+ : vendor.Contains("Microsoft", StringComparison.OrdinalIgnoreCase) ? JavaBrandType.Microsoft
+ : JavaBrandType.OpenJDK;
+ return Result.Ok(new JavaInstallation(bin, version, brand, arch,
+ arch is MachineType.AMD64 or MachineType.ARM64, !File.Exists(Path.Combine(bin, "javac"))));
+ }
+ catch (Exception ex)
+ {
+ return Result.Err(ex);
+ }
+ }
+}
diff --git a/PCL.Core/Minecraft/Java/Scanner/DefaultPathsScanner.cs b/PCL.Core/Minecraft/Java/Scanner/DefaultPathsScanner.cs
index 6c73cb94f..04e8f9b75 100644
--- a/PCL.Core/Minecraft/Java/Scanner/DefaultPathsScanner.cs
+++ b/PCL.Core/Minecraft/Java/Scanner/DefaultPathsScanner.cs
@@ -98,7 +98,7 @@ private static void _BfsSearch(string rootPath, ICollection results)
{
foreach (var subDir in Directory.EnumerateDirectories(current))
{
- var javaExe = Path.Combine(subDir, "java.exe");
+ var javaExe = Path.Combine(subDir, OperatingSystem.IsWindows() ? "java.exe" : "java");
if (File.Exists(javaExe))
{
results.Add(javaExe);
diff --git a/PCL.Core/Minecraft/Java/Scanner/MicrosoftStoreJavaScanner.cs b/PCL.Core/Minecraft/Java/Scanner/MicrosoftStoreJavaScanner.cs
index 95b9e6316..cf0e45f51 100644
--- a/PCL.Core/Minecraft/Java/Scanner/MicrosoftStoreJavaScanner.cs
+++ b/PCL.Core/Minecraft/Java/Scanner/MicrosoftStoreJavaScanner.cs
@@ -11,6 +11,7 @@ public class MicrosoftStoreJavaScanner : IJavaScanner
public void Scan(ICollection results)
{
+ if (!OperatingSystem.IsWindows()) return;
try
{
var basePath = Path.Combine(
diff --git a/PCL.Core/Minecraft/Java/Scanner/PathEnvironmentScanner.cs b/PCL.Core/Minecraft/Java/Scanner/PathEnvironmentScanner.cs
index 5d387ea4b..86b39eca5 100644
--- a/PCL.Core/Minecraft/Java/Scanner/PathEnvironmentScanner.cs
+++ b/PCL.Core/Minecraft/Java/Scanner/PathEnvironmentScanner.cs
@@ -14,11 +14,11 @@ public void Scan(ICollection results)
var pathVar = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(pathVar)) return;
- foreach (var dir in pathVar.Split(';', StringSplitOptions.RemoveEmptyEntries))
+ foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
if (!Directory.Exists(dir)) continue;
- var javaExe = Path.Combine(dir, "java.exe");
+ var javaExe = Path.Combine(dir, OperatingSystem.IsWindows() ? "java.exe" : "java");
if (File.Exists(javaExe)) results.Add(javaExe);
}
}
diff --git a/PCL.Core/Minecraft/Java/Scanner/RegistryJavaScanner.cs b/PCL.Core/Minecraft/Java/Scanner/RegistryJavaScanner.cs
index 9531f653b..eec075882 100644
--- a/PCL.Core/Minecraft/Java/Scanner/RegistryJavaScanner.cs
+++ b/PCL.Core/Minecraft/Java/Scanner/RegistryJavaScanner.cs
@@ -25,6 +25,7 @@ public class RegistryJavaScanner : IJavaScanner
public void Scan(ICollection results)
{
+ if (!OperatingSystem.IsWindows()) return;
try
{
_ScanJavaSoftRegistry(results);
diff --git a/PCL.Core/Minecraft/Java/Scanner/UnixJavaScanner.cs b/PCL.Core/Minecraft/Java/Scanner/UnixJavaScanner.cs
new file mode 100644
index 000000000..6cc009d45
--- /dev/null
+++ b/PCL.Core/Minecraft/Java/Scanner/UnixJavaScanner.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace PCL.Core.Minecraft.Java.Scanner;
+
+/// Conventional Linux, macOS, SDKMAN and JAVA_HOME installations.
+public sealed class UnixJavaScanner : IJavaScanner
+{
+ public void Scan(ICollection results)
+ {
+ if (OperatingSystem.IsWindows()) return;
+ AddHome(Environment.GetEnvironmentVariable("JAVA_HOME"));
+ var user = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ string[] roots = ["/usr/lib/jvm", "/usr/java", "/opt/java", "/Library/Java/JavaVirtualMachines",
+ Path.Combine(user, "Library/Java/JavaVirtualMachines"), Path.Combine(user, ".sdkman/candidates/java")];
+ foreach (var root in roots)
+ {
+ if (!Directory.Exists(root)) continue;
+ try
+ {
+ foreach (var dir in Directory.EnumerateDirectories(root))
+ {
+ AddHome(dir);
+ AddHome(Path.Combine(dir, "Contents", "Home"));
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+ return;
+
+ void AddHome(string? home)
+ {
+ if (string.IsNullOrWhiteSpace(home)) return;
+ var executable = Path.Combine(home, "bin", "java");
+ if (File.Exists(executable)) results.Add(executable);
+ }
+ }
+}
diff --git a/PCL.Core/PCL.Core.csproj b/PCL.Core/PCL.Core.csproj
index b8618bcf9..236649df3 100644
--- a/PCL.Core/PCL.Core.csproj
+++ b/PCL.Core/PCL.Core.csproj
@@ -17,8 +17,7 @@
AnyCPU;x64;ARM64
{A0C2209D-64FB-4C11-9459-8E86304B6F94}
PCL.Core
- net10.0-windows
- true
+ net10.0
true
15.0
enable
@@ -46,7 +45,6 @@
-
@@ -88,6 +86,9 @@
+
+
+
diff --git a/PCL.Core/UI/Theme/SystemThemeHelper.cs b/PCL.Core/UI/Theme/SystemThemeHelper.cs
index 3c15dc1f3..b16ba7afa 100644
--- a/PCL.Core/UI/Theme/SystemThemeHelper.cs
+++ b/PCL.Core/UI/Theme/SystemThemeHelper.cs
@@ -15,6 +15,7 @@ public static class SystemThemeHelper {
///
/// 如果系统使用深色模式,则返回 true;否则返回 false(包括注册表不可访问的情况)。
public static bool IsSystemInDarkMode() {
+ if (!OperatingSystem.IsWindows()) return false;
try {
using var registryKey = Registry.CurrentUser.OpenSubKey(ThemeRegistryPath);
if (registryKey is null) {
diff --git a/PCL.Core/Utils/OS/KernelInterop.cs b/PCL.Core/Utils/OS/KernelInterop.cs
index 5068a8d69..69134c3bb 100644
--- a/PCL.Core/Utils/OS/KernelInterop.cs
+++ b/PCL.Core/Utils/OS/KernelInterop.cs
@@ -82,13 +82,14 @@ private struct MEMORYSTATUSEX
///
/// 获取当前线程的 Win32 Thread ID。若无特殊情况请用 而不是这个方法。
///
- public static uint CurrentNativeThreadId => _GetCurrentThreadId();
+ public static uint CurrentNativeThreadId => OperatingSystem.IsWindows()
+ ? _GetCurrentThreadId() : (uint)Environment.CurrentManagedThreadId;
///
/// 直接结束当前进程。若无特殊情况请使用
///
/// 退出状态码 (返回值)
- public static void ExitProcess(int statusCode = 0) => _ExitProcess((uint)statusCode);
+ public static void ExitProcess(int statusCode = 0) => Environment.Exit(statusCode);
///
/// 获取指定命名管道当前连接的客户端进程 ID
@@ -107,6 +108,7 @@ public static uint GetNamedPipeClientProcessId(IntPtr pipeHandle)
///
public static int GetPerformanceLogicalProcessorCount()
{
+ if (!OperatingSystem.IsWindows()) return Environment.ProcessorCount;
var cores = QueryProcessorCoreRelationships();
if (cores.Count == 0)
{
@@ -145,6 +147,7 @@ public sealed record ProcessorCore(byte EfficiencyClass, ulong Mask);
// Partly generated by o4-mini-high (20250709)
public static List QueryProcessorCoreRelationships()
{
+ if (!OperatingSystem.IsWindows()) return [];
uint returnedLength = 0;
// 第一次调用仅为了获取所需缓冲区大小
@@ -221,6 +224,7 @@ public static List QueryProcessorCoreRelationships()
///
public static ulong GetAvailablePhysicalMemoryBytes()
{
+ if (!OperatingSystem.IsWindows()) return PortableMemory.GetPhysicalMemoryBytes().Available;
var status = CreateStatus();
if (!GlobalMemoryStatusEx(ref status)) _ThrowLastWin32Error();
return status.ullAvailPhys;
@@ -231,6 +235,7 @@ public static ulong GetAvailablePhysicalMemoryBytes()
///
public static (ulong Total, ulong Available) GetPhysicalMemoryBytes()
{
+ if (!OperatingSystem.IsWindows()) return PortableMemory.GetPhysicalMemoryBytes();
var status = CreateStatus();
if (!GlobalMemoryStatusEx(ref status)) _ThrowLastWin32Error();
return (status.ullTotalPhys, status.ullAvailPhys);
@@ -241,6 +246,11 @@ public static (ulong Total, ulong Available) GetPhysicalMemoryBytes()
///
public static double GetMemoryLoadPercent()
{
+ if (!OperatingSystem.IsWindows())
+ {
+ var (total, available) = GetPhysicalMemoryBytes();
+ return total == 0 ? 0 : 100.0 * (total - available) / total;
+ }
var status = CreateStatus();
if (!GlobalMemoryStatusEx(ref status)) _ThrowLastWin32Error();
return status.dwMemoryLoad;
@@ -253,6 +263,7 @@ public static double GetMemoryLoadPercent()
///
public static void AllocateConsole()
{
+ if (!OperatingSystem.IsWindows()) return;
if (_AllocConsole()) return;
var lastError = Marshal.GetLastWin32Error();
if (lastError != ERROR_ACCESS_DENIED) _ThrowLastWin32Error(lastError);
@@ -262,11 +273,11 @@ public static void AllocateConsole()
/// 释放当前进程的终端窗口。
/// 若进程不存在关联的终端窗口,该方法将无任何作用。
///
- public static void FreeConsole() => _FreeConsole();
+ public static void FreeConsole() { if (OperatingSystem.IsWindows()) _FreeConsole(); }
///
/// 获取当前进程关联的终端窗口句柄。
///
/// 代表终端窗口的 HWND,若当前进程无关联的终端窗口,则该值为
- public static nint GetConsoleWindow() => _GetConsoleWindow();
+ public static nint GetConsoleWindow() => OperatingSystem.IsWindows() ? _GetConsoleWindow() : nint.Zero;
}
diff --git a/PCL.Core/Utils/OS/PortableMemory.cs b/PCL.Core/Utils/OS/PortableMemory.cs
new file mode 100644
index 000000000..9c765efa8
--- /dev/null
+++ b/PCL.Core/Utils/OS/PortableMemory.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+
+namespace PCL.Core.Utils.OS;
+
+/// Portable memory information; does not load kernel32 on Unix.
+internal static class PortableMemory
+{
+ public static (ulong Total, ulong Available) GetPhysicalMemoryBytes()
+ {
+ if (OperatingSystem.IsLinux())
+ {
+ var values = File.ReadLines("/proc/meminfo")
+ .Select(line => line.Split(':', 2))
+ .Where(parts => parts.Length == 2)
+ .ToDictionary(parts => parts[0], parts => ulong.Parse(
+ parts[1].Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)[0],
+ CultureInfo.InvariantCulture) * 1024);
+ var total = values["MemTotal"];
+ var available = values.TryGetValue("MemAvailable", out var value) ? value : values["MemFree"];
+ return (total, Math.Min(total, available));
+ }
+
+ // The GC accounts for the host's memory limit on macOS and other runtimes.
+ // This fallback is an estimate, rather than a Windows API failure at startup.
+ var info = GC.GetGCMemoryInfo();
+ var limit = (ulong)Math.Max(info.TotalAvailableMemoryBytes, 0);
+ var used = (ulong)Math.Max(info.MemoryLoadBytes, 0);
+ return (limit, limit - Math.Min(limit, used));
+ }
+}
diff --git a/PCL.Core/Utils/OS/ProcessInterop.cs b/PCL.Core/Utils/OS/ProcessInterop.cs
index 3ebe46fad..996b8acc5 100644
--- a/PCL.Core/Utils/OS/ProcessInterop.cs
+++ b/PCL.Core/Utils/OS/ProcessInterop.cs
@@ -15,6 +15,7 @@ public class ProcessInterop {
///
/// 如果当前用户具有管理员权限,则返回 true;否则返回 false。
public static bool IsAdmin() =>
+ OperatingSystem.IsWindows() &&
new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
///
@@ -23,6 +24,8 @@ public static bool IsAdmin() =>
/// 进程 ID
/// 命令行参数文本
public static string? GetCommandLine(int processId) {
+ if (!OperatingSystem.IsWindows())
+ throw new PlatformNotSupportedException("WMI process command lines are Windows-only.");
var query = $"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {processId}";
using var searcher = new ManagementObjectSearcher(query);
return searcher.Get().GetEnumerator().Current["CommandLine"].ToString();
@@ -38,6 +41,8 @@ public static bool IsAdmin() =>
public static Process? Start(string path, string? arguments = null, bool runAsAdmin = false) {
var psi = new ProcessStartInfo(path);
if (arguments is not null) psi.Arguments = arguments;
+ if (runAsAdmin && !OperatingSystem.IsWindows())
+ throw new PlatformNotSupportedException("Elevation requires the platform desktop session.");
if (runAsAdmin)
{
psi.UseShellExecute = true;
@@ -77,8 +82,7 @@ public static bool IsAdmin() =>
/// 指定是否强制结束,若为 true 将通过带 /F 参数的 TASKKILL.EXE 结束进程
/// 进程返回值,若等待超时将返回
public static int Kill(Process process, int timeout = 3000, bool force = false) {
- if (force) Process.Start(new ProcessStartInfo("TASKKILL.EXE", $"/PID {process.Id} /F") { UseShellExecute = false });
- else process.Kill();
+ process.Kill(entireProcessTree: force);
if (timeout == -1) process.WaitForExit();
else if (timeout != 0) process.WaitForExit(timeout);
return process.HasExited ? process.ExitCode : int.MinValue;
@@ -94,6 +98,7 @@ public static int Kill(Process process, int timeout = 3000, bool force = false)
/// 当安全策略不允许访问注册表时抛出
/// 当注册表操作失败时抛出
public static void SetGpuPreference(string executable, bool wantHighPerformance = true) {
+ if (!OperatingSystem.IsWindows()) return;
// 参数验证
if (string.IsNullOrWhiteSpace(executable)) {
throw new ArgumentException("可执行文件路径不能为空或仅包含空白字符", nameof(executable));
diff --git a/PCL.Core/Utils/OS/SystemInfo.cs b/PCL.Core/Utils/OS/SystemInfo.cs
index 6b5756772..ca14e3de0 100644
--- a/PCL.Core/Utils/OS/SystemInfo.cs
+++ b/PCL.Core/Utils/OS/SystemInfo.cs
@@ -18,7 +18,7 @@ public static class SystemInfo
///
/// 是否使用 GBK 编码。
///
- public static readonly bool IsGBKEncoding = KernelInterop.GetACP() == 936;
+ public static readonly bool IsGBKEncoding = OperatingSystem.IsWindows() && KernelInterop.GetACP() == 936;
///
/// 系统信息描述,例如 Microsoft Windows 11 专业工作站版 10.0.22635.0
diff --git a/PCL.Core/Utils/OS/SystemTheme.cs b/PCL.Core/Utils/OS/SystemTheme.cs
index 38313e115..1e199b06a 100644
--- a/PCL.Core/Utils/OS/SystemTheme.cs
+++ b/PCL.Core/Utils/OS/SystemTheme.cs
@@ -15,6 +15,7 @@ public class SystemTheme {
///
/// 如果系统使用深色模式,则返回 true;否则返回 false(包括注册表不可访问的情况)。
public static bool IsSystemInDarkMode() {
+ if (!OperatingSystem.IsWindows()) return false;
try {
using var registryKey = Registry.CurrentUser.OpenSubKey(ThemeRegistryPath);
if (registryKey is null) {
diff --git a/PCL.Core/Utils/Secret/EncryptHelper.cs b/PCL.Core/Utils/Secret/EncryptHelper.cs
index c518d7388..354bef6b6 100644
--- a/PCL.Core/Utils/Secret/EncryptHelper.cs
+++ b/PCL.Core/Utils/Secret/EncryptHelper.cs
@@ -150,7 +150,7 @@ public static bool IsValid(ReadOnlySpan data)
///
/// 用户密钥。只在成功获取后缓存——失败不会被记住,下次访问会重新尝试。
- /// 注意:密钥文件的 version(1 = 旧版 CAPI DPAPI,2 = CNG DPAPI)与
+ /// 注意:密钥文件的 version(1 = 旧版 CAPI DPAPI,2 = CNG DPAPI,3 = Unix owner-only key file)与
/// 的数据算法 version(0/1/2)语义无关。
///
internal static byte[] EncryptionKey
@@ -158,14 +158,12 @@ internal static byte[] EncryptionKey
get { lock (_KeyLock) return _cachedKey ??= _LoadOrCreateKey(); }
}
- //internal static string KeyFilePath => Path.Combine(Paths.SharedData, "UserKey.bin");
-
// NOTE: only used for unit test
// NOTE: do not touch on production env
private static string? _keyFileverride;
internal static void OverrideKeyFile(string? path) => _keyFileverride = path;
- internal static string KeyFilePath => _keyFileverride ?? Path.Combine(Paths.SharedData, "UserKey.bin");
+ internal static string KeyFilePath => _keyFileverride ?? Path.Combine(Paths.SharedData, OperatingSystem.IsWindows() ? "UserKey.bin" : "UserKey.unix.bin");
internal static void ResetKeyCache() { lock (_KeyLock) _cachedKey = null; }
@@ -192,6 +190,12 @@ private static byte[] _ReadKey(string keyFile)
var data = EncryptionData.FromBytes(File.ReadAllBytes(keyFile));
switch (data.Version)
{
+ case 3 when !OperatingSystem.IsWindows():
+ var permissions = File.GetUnixFileMode(keyFile);
+ if ((permissions & (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.OtherRead | UnixFileMode.OtherWrite)) != 0)
+ throw new UnauthorizedAccessException("User key must only be accessible to its owner.");
+ if (data.Data.Length != 32) throw new CryptographicException("Invalid Unix user key length.");
+ return data.Data;
case 2:
return CngProtectedData.Unprotect(data.Data, _Key, DataProtectionScope.CurrentUser);
case 1:
@@ -215,7 +219,7 @@ private static byte[] _CreateKey(string keyFile)
if (!_WriteKeyFile(keyFile, storeData))
LogWrapper.Error("Encryption", $"用户密钥写入失败,本次运行仅使用内存密钥:{keyFile}");
else
- LogWrapper.Warn("Encryption", "用户密钥已重建(version 2)。旧密钥保护的数据无法恢复,需要重新登录。");
+ LogWrapper.Warn("Encryption", "用户密钥已重建。旧密钥保护的数据无法恢复,需要重新登录。");
return key;
}
@@ -228,6 +232,11 @@ private static bool _TryProtect(byte[] rawKey, out byte[] storeData)
{
try
{
+ if (!OperatingSystem.IsWindows())
+ {
+ storeData = EncryptionData.ToBytes(new EncryptionData { Version = 3, Data = rawKey });
+ return true;
+ }
var blob = CngProtectedData.Protect(rawKey, _Key, DataProtectionScope.CurrentUser);
var roundTrip = CngProtectedData.Unprotect(blob, _Key, DataProtectionScope.CurrentUser);
storeData = EncryptionData.ToBytes(new EncryptionData { Version = 2, Data = blob });
@@ -247,7 +256,14 @@ private static bool _WriteKeyFile(string keyFile, byte[] storeData)
var tmpFile = $"{keyFile}.tmp{RandomUtils.NextInt(10000, 99999)}";
try
{
- using (var fs = new FileStream(tmpFile, FileMode.Create, FileAccess.ReadWrite, FileShare.None))
+ var options = new FileStreamOptions
+ {
+ Mode = FileMode.CreateNew, Access = FileAccess.ReadWrite, Share = FileShare.None
+ };
+ // The UnixCreateMode setter itself throws on Windows, even for a null value.
+ if (!OperatingSystem.IsWindows())
+ options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ using (var fs = new FileStream(tmpFile, options))
{
fs.Write(storeData);
fs.Flush(true);
diff --git a/PCL.Core/Utils/Secret/Identify.cs b/PCL.Core/Utils/Secret/Identify.cs
index 564a97449..4f259a16a 100644
--- a/PCL.Core/Utils/Secret/Identify.cs
+++ b/PCL.Core/Utils/Secret/Identify.cs
@@ -14,6 +14,9 @@ public class Identify
private static byte[] _GetRawId()
{
+ if (!OperatingSystem.IsWindows())
+ return Encoding.UTF8.GetBytes(SHA512Provider.Instance.ComputeHash(
+ Environment.MachineName + "|" + Environment.UserName).ToHexString());
var code = new StringBuilder();
try
{
diff --git a/PCL.Core/ViewModel/Homepage/NewsViewModel.cs b/PCL.Core/ViewModel/Homepage/NewsViewModel.cs
index 68a927653..dcd801fcf 100644
--- a/PCL.Core/ViewModel/Homepage/NewsViewModel.cs
+++ b/PCL.Core/ViewModel/Homepage/NewsViewModel.cs
@@ -89,7 +89,7 @@ private void _OpenRead(string? url)
Basics.OpenPath(url!);
}
- internal static bool IsSafeNewsLink(string? url)
+ public static bool IsSafeNewsLink(string? url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
if (!uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) &&
diff --git a/PCL.CrossPlatform.Test/DesktopTests.cs b/PCL.CrossPlatform.Test/DesktopTests.cs
new file mode 100644
index 000000000..0b2a0c234
--- /dev/null
+++ b/PCL.CrossPlatform.Test/DesktopTests.cs
@@ -0,0 +1,68 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.Avalonia;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+public class DesktopTests
+{
+ [TestMethod]
+ public void AvaloniaWindowLoadsAllPagesWithoutWpf()
+ {
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ AppBuilder.Configure().WithInterFont().UseSkia().UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
+ .SetupWithoutStarting();
+ var home = CustomHomeRenderer.Render("", _ => { });
+ Assert.IsInstanceOfType(home);
+ var homeGrid = (Grid)home;
+ Assert.AreEqual(2, homeGrid.RowDefinitions.Count);
+ Assert.AreEqual(1, Grid.GetRow(homeGrid.Children[1]));
+ Assert.IsInstanceOfType(homeGrid.Children[1]);
+ var window = new MainWindow(new DesktopSettings { JavaPath = "/test/java" });
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ var tabs = window.GetVisualDescendants().OfType().First();
+ Assert.AreEqual(8, tabs.ItemCount);
+ var visited = 0;
+ void Visit(TabControl navigation, string path)
+ {
+ for (var i = 0; i < navigation.ItemCount; i++)
+ {
+ navigation.SelectedIndex = i; Dispatcher.UIThread.RunJobs();
+ Assert.IsNotNull(navigation.SelectedContent); visited++;
+ var next = path + "-" + i;
+ if (navigation.SelectedContent is TabControl nested) Visit(nested, next);
+ else if (Environment.GetEnvironmentVariable("PCL_TEST_SCREENSHOTS") is { } folder)
+ {
+ Directory.CreateDirectory(folder);
+ using var frameImage = window.CaptureRenderedFrame();
+ Assert.IsNotNull(frameImage); frameImage.Save(Path.Combine(folder, "desktop" + next + ".png"));
+ }
+ }
+ }
+ Visit(tabs, "");
+ Assert.IsTrue(visited >= 30, "Feature pages must be connected to navigation.");
+ var dispatcher = new AvaloniaUiDispatcher();
+ Assert.IsTrue(dispatcher.CheckAccess());
+ var frame = dispatcher.CreateModalFrame();
+ dispatcher.Post(frame.Exit); frame.Run();
+ window.Close();
+ }
+ catch (Exception ex) { failure = ex; }
+ });
+ thread.IsBackground = true;
+ if (OperatingSystem.IsWindows()) thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ Assert.IsTrue(thread.Join(TimeSpan.FromSeconds(30)), "Avalonia initialization timed out.");
+ if (failure is not null) throw new AssertFailedException("Avalonia window failed to initialize: " + failure, failure);
+ }
+}
diff --git a/PCL.CrossPlatform.Test/GameTests.cs b/PCL.CrossPlatform.Test/GameTests.cs
new file mode 100644
index 000000000..9b0ebd3d6
--- /dev/null
+++ b/PCL.CrossPlatform.Test/GameTests.cs
@@ -0,0 +1,127 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.CrossPlatform;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+public class GameTests
+{
+ [TestMethod]
+ public void DownloadAndArchivePathsCannotEscapeTheirRoot()
+ {
+ using var temp = new TemporaryDirectory();
+ foreach (var path in new[] { "../escape", "/escape", "C:/escape", "..\\escape", "a/../../escape" })
+ Assert.ThrowsExactly(() => GameManifest.SafePath(temp.Path, path));
+ Assert.AreEqual(Path.Combine(temp.Path, "a", "b.jar"), GameManifest.SafePath(temp.Path, "a/b.jar"));
+ Assert.ThrowsExactly(() => GameManifest.ValidateId("../version"));
+ }
+
+ [TestMethod]
+ public void InheritedVersionsKeepBaseJarAndReplaceLibraryVersions()
+ {
+ using var temp = new TemporaryDirectory();
+ WriteManifest(temp.Path, "base", """
+ {"id":"base","mainClass":"BaseMain","libraries":[{"name":"a:b:1"}],"arguments":{"jvm":["-Dbase=true"],"game":["--username","${auth_player_name}"]}}
+ """);
+ WriteManifest(temp.Path, "modded", """
+ {"id":"modded","inheritsFrom":"base","mainClass":"LoaderMain","libraries":[{"name":"a:b:2"}],"arguments":{"jvm":["-Dloader=true"]}}
+ """);
+ var manifest = GameManifest.Load(temp.Path, "modded");
+ Assert.AreEqual("base", manifest["jar"]!.ToString());
+ Assert.AreEqual("LoaderMain", manifest["mainClass"]!.ToString());
+ Assert.AreEqual(1, manifest["libraries"]!.AsArray().Count);
+ Assert.AreEqual("a:b:2", manifest["libraries"]![0]!["name"]!.ToString());
+ Assert.AreEqual(2, manifest["arguments"]!["jvm"]!.AsArray().Count);
+ WriteManifest(temp.Path, "loop", """{"inheritsFrom":"loop"}""");
+ Assert.ThrowsExactly(() => GameManifest.Load(temp.Path, "loop"));
+ }
+
+ [TestMethod]
+ public void LaunchUsesArgumentListAndHostSpecificClasspathAndNatives()
+ {
+ using var temp = new TemporaryDirectory();
+ var root = Path.Combine(temp.Path, "game with spaces");
+ WriteManifest(root, "base", """
+ {"id":"base","mainClass":"Main","libraries":[
+ {"name":"a:b:1","downloads":{"artifact":{"path":"a/b.jar"}}},
+ {"name":"win:only:1","rules":[{"action":"allow","os":{"name":"windows"}}],"downloads":{"artifact":{"path":"win.jar"}}}
+ ],"arguments":{"jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}"],
+ "game":["--username","${auth_player_name}","--gameDir","${game_directory}",
+ {"rules":[{"action":"allow","features":{"is_demo_user":true}}],"value":"--demo"}]}}
+ """);
+ Directory.CreateDirectory(Path.Combine(root, "libraries/a"));
+ File.WriteAllText(Path.Combine(root, "libraries/a/b.jar"), "jar");
+ File.WriteAllText(GameManifest.VersionPath(root, "base", ".jar"), "jar");
+ foreach (var os in new[] { "linux", "osx" })
+ {
+ var info = new GameProcess(new RuntimePlatform(os, "amd64", "1", ':'))
+ .CreateStartInfo(root, "base", "/java", LaunchAccount.Offline("Player"), 2048, "/native dir");
+ var args = info.ArgumentList.ToList();
+ Assert.AreEqual("-Djava.library.path=/native dir", args[1]);
+ var classpath = args[args.IndexOf("-cp") + 1];
+ Assert.IsTrue(classpath.Contains(':'));
+ Assert.IsFalse(classpath.Contains("win.jar"));
+ Assert.IsFalse(args.Contains("--demo"));
+ Assert.IsTrue(args.Contains(Path.GetFullPath(root)));
+ Assert.AreEqual(os == "osx", args.Contains("-XstartOnFirstThread"));
+ Assert.AreEqual("", info.Arguments);
+ Assert.IsFalse(info.UseShellExecute);
+ }
+ Assert.AreEqual("a01e3843e5213998958af459800e4d11", LaunchAccount.Offline("Player").Uuid);
+ }
+
+ [TestMethod]
+ public async Task DownloadsValidateHashesAndPreserveExistingFilesOnFailure()
+ {
+ using var temp = new TemporaryDirectory();
+ var data = Encoding.UTF8.GetBytes("valid file");
+ var hash = Convert.ToHexString(SHA1.HashData(data));
+ var handler = new FakeHandler(data);
+ using var client = new HttpClient(handler);
+ var installer = new GameInstaller(client);
+ var target = Path.Combine(temp.Path, "file.jar");
+ await installer.DownloadAsync("https://example.test/file", target, hash, data.Length, default);
+ await installer.DownloadAsync("https://example.test/file", target, hash, data.Length, default);
+ Assert.AreEqual(1, handler.Requests);
+ File.WriteAllText(target, "keep me");
+ await Assert.ThrowsExactlyAsync(() => installer.DownloadAsync(
+ "https://example.test/file", target, new string('0', 40), data.Length, default));
+ Assert.AreEqual("keep me", File.ReadAllText(target));
+ Assert.AreEqual(0, Directory.GetFiles(temp.Path, "*.part").Length);
+ await Assert.ThrowsExactlyAsync(() => installer.DownloadAsync(
+ "http://example.test/file", target, hash, data.Length, default));
+ }
+
+ [TestMethod]
+ public async Task CancellationLeavesNoPartialFile()
+ {
+ using var temp = new TemporaryDirectory();
+ using var client = new HttpClient(new FakeHandler([1, 2]));
+ var target = Path.Combine(temp.Path, "file.jar");
+ using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
+ await Assert.ThrowsAsync(() => new GameInstaller(client)
+ .DownloadAsync("https://example.test/file", target, null, null, cancellation.Token));
+ Assert.IsFalse(File.Exists(target));
+ Assert.AreEqual(0, Directory.GetFiles(temp.Path, "*.part").Length);
+ }
+
+ internal static void WriteManifest(string root, string id, string content)
+ {
+ var path = GameManifest.VersionPath(root, id);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, content);
+ }
+
+ private sealed class FakeHandler(byte[] bytes) : HttpMessageHandler
+ {
+ public int Requests { get; private set; }
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Requests++; cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) });
+ }
+ }
+}
diff --git a/PCL.CrossPlatform.Test/InstallerIntegrationTests.cs b/PCL.CrossPlatform.Test/InstallerIntegrationTests.cs
new file mode 100644
index 000000000..d515d9de8
--- /dev/null
+++ b/PCL.CrossPlatform.Test/InstallerIntegrationTests.cs
@@ -0,0 +1,134 @@
+using System.IO.Compression;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.CrossPlatform;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+public class InstallerIntegrationTests
+{
+ [TestMethod]
+ public async Task InstallSelectsLinuxFilesDownloadsAssetsAndRepairsCorruption()
+ {
+ using var temp = new TemporaryDirectory();
+ var artifact = Encoding.UTF8.GetBytes("library");
+ var native = Zip("native.so", "native library");
+ var jar = Encoding.UTF8.GetBytes("client jar");
+ var asset = Encoding.UTF8.GetBytes("game asset");
+ var hash = Convert.ToHexString(SHA1.HashData(asset)).ToLowerInvariant();
+ var index = Encoding.UTF8.GetBytes(new JsonObject { ["objects"] = new JsonObject
+ { ["a/test.txt"] = new JsonObject { ["hash"] = hash, ["size"] = asset.Length } } }.ToJsonString());
+ var manifest = new JsonObject
+ {
+ ["id"] = "test", ["mainClass"] = "Main",
+ ["downloads"] = new JsonObject { ["client"] = Download("client", jar) },
+ ["assetIndex"] = Download("index", index),
+ ["libraries"] = new JsonArray(new JsonObject
+ {
+ ["name"] = "test:library:1", ["natives"] = new JsonObject { ["linux"] = "natives-linux" },
+ ["downloads"] = new JsonObject
+ {
+ ["artifact"] = Download("library", artifact, "test/library.jar"),
+ ["classifiers"] = new JsonObject { ["natives-linux"] = Download("native", native, "test/native.jar") }
+ }
+ }, new JsonObject
+ {
+ ["name"] = "windows:only:1", ["rules"] = JsonNode.Parse("""[{"action":"allow","os":{"name":"windows"}}]"""),
+ ["downloads"] = new JsonObject { ["artifact"] = Download("not-requested", artifact, "windows.jar") }
+ })
+ };
+ manifest["assetIndex"]!["id"] = "test-assets";
+ var metadata = Encoding.UTF8.GetBytes(manifest.ToJsonString());
+ var responses = new Dictionary
+ {
+ ["https://example.test/metadata"] = metadata, ["https://example.test/client"] = jar,
+ ["https://example.test/library"] = artifact, ["https://example.test/native"] = native,
+ ["https://example.test/index"] = index,
+ [$"https://resources.download.minecraft.net/{hash[..2]}/{hash}"] = asset
+ };
+ var handler = new MappedHandler(responses);
+ using var client = new HttpClient(handler);
+ var installer = new GameInstaller(client, new RuntimePlatform("linux", "amd64", "6.1", ':'));
+ var version = new AvailableVersion("test", "release", "https://example.test/metadata", Convert.ToHexString(SHA1.HashData(metadata)));
+ await installer.InstallAsync(temp.Path, version);
+ Assert.AreEqual(6, handler.Requests);
+ Assert.IsFalse(File.Exists(Path.Combine(temp.Path, "libraries/windows.jar")));
+ Assert.IsTrue(File.Exists(Path.Combine(temp.Path, "assets/objects", hash[..2], hash)));
+ File.WriteAllText(Path.Combine(temp.Path, "libraries/test/library.jar"), "bad data");
+ await installer.RepairAsync(temp.Path, "test");
+ Assert.AreEqual(7, handler.Requests);
+ CollectionAssert.AreEqual(artifact, File.ReadAllBytes(Path.Combine(temp.Path, "libraries/test/library.jar")));
+ }
+
+ [TestMethod]
+ public void NativeArchiveTraversalIsRejectedBeforeStartingJava()
+ {
+ using var temp = new TemporaryDirectory();
+ GameTests.WriteManifest(temp.Path, "test", """
+ {"mainClass":"Main","libraries":[{"name":"x:y:1","natives":{"linux":"natives-linux"},
+ "downloads":{"classifiers":{"natives-linux":{"path":"bad.jar"}}}}]}
+ """);
+ Directory.CreateDirectory(Path.Combine(temp.Path, "libraries"));
+ File.WriteAllBytes(Path.Combine(temp.Path, "libraries/bad.jar"), Zip("../escape.so", "bad"));
+ Assert.ThrowsExactly(() => new GameProcess(new RuntimePlatform("linux", "amd64", "6.1", ':'))
+ .Start(temp.Path, "test", "must-never-start", LaunchAccount.Offline("Player"), 1024));
+ }
+
+ [TestMethod]
+ public async Task UnixLaunchStartsProcessWithSpacesAndCleansNativeDirectory()
+ {
+ if (OperatingSystem.IsWindows()) Assert.Inconclusive("This test exercises Unix-specific behavior.");
+ using var temp = new TemporaryDirectory();
+ GameTests.WriteManifest(temp.Path, "test", """{"mainClass":"Main","libraries":[],"minecraftArguments":"--username ${auth_player_name}"}""");
+ File.WriteAllText(GameManifest.VersionPath(temp.Path, "test", ".jar"), "test");
+ var java = Path.Combine(temp.Path, "fake java");
+ await File.WriteAllTextAsync(java, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n");
+ File.SetUnixFileMode(java, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ var lines = new List();
+ using var process = new GameProcess().Start(temp.Path, "test", java, LaunchAccount.Offline("Player"), 1024,
+ line => { lock (lines) lines.Add(line); });
+ await process.WaitForExitAsync();
+ process.WaitForExit();
+ Assert.AreEqual(0, process.ExitCode);
+ lock (lines)
+ {
+ Assert.Contains("Player", lines);
+ Assert.Contains("Main", lines);
+ var nativeArg = lines.Single(line => line.StartsWith("-Djava.library.path="));
+ Assert.IsFalse(Directory.Exists(nativeArg["-Djava.library.path=".Length..]));
+ }
+ }
+
+ private static JsonObject Download(string file, byte[] bytes, string? path = null)
+ {
+ var result = new JsonObject
+ {
+ ["url"] = "https://example.test/" + file,
+ ["sha1"] = Convert.ToHexString(SHA1.HashData(bytes)), ["size"] = bytes.Length
+ };
+ if (path is not null) result["path"] = path;
+ return result;
+ }
+ private static byte[] Zip(string name, string content)
+ {
+ using var output = new MemoryStream();
+ using (var zip = new ZipArchive(output, ZipArchiveMode.Create, true))
+ using (var writer = new StreamWriter(zip.CreateEntry(name).Open())) writer.Write(content);
+ return output.ToArray();
+ }
+ private sealed class MappedHandler(Dictionary responses) : HttpMessageHandler
+ {
+ private int _requests;
+ public int Requests => _requests;
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref _requests);
+ var data = responses[request.RequestUri!.ToString()];
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(data) });
+ }
+ }
+}
diff --git a/PCL.CrossPlatform.Test/MigrationTests.cs b/PCL.CrossPlatform.Test/MigrationTests.cs
new file mode 100644
index 000000000..170cc606e
--- /dev/null
+++ b/PCL.CrossPlatform.Test/MigrationTests.cs
@@ -0,0 +1,134 @@
+using System.IO.Compression;
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using fNbt;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.Avalonia;
+using PCL.CrossPlatform;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+public class MigrationTests
+{
+ [TestMethod]
+ public async Task ArchiveRejectsSymlinksAndTraversalEvenInExcludedEntries()
+ {
+ using var temp = new TemporaryDirectory();
+ var archive = Path.Combine(temp.Path, "unsafe.zip"); var destination = Path.Combine(temp.Path, "output"); Directory.CreateDirectory(destination);
+ WriteZip(archive, ("../outside.txt", "bad"));
+ await Assert.ThrowsExactlyAsync(() => InstanceFiles.ExtractAsync(archive, destination, "overrides/"));
+ Assert.IsFalse(File.Exists(Path.Combine(temp.Path, "outside.txt")));
+ }
+
+ [TestMethod]
+ public async Task ImportToggleArchiveAndExportPreserveDataAndExcludeCredentials()
+ {
+ using var temp = new TemporaryDirectory();
+ var source = Path.Combine(temp.Path, "example.jar"); await File.WriteAllTextAsync(source, "mod");
+ var root = Path.Combine(temp.Path, "instance"); var mods = Path.Combine(root, "mods");
+ await InstanceFiles.ImportFileAsync(source, mods);
+ await Assert.ThrowsExactlyAsync(() => InstanceFiles.ImportFileAsync(source, mods));
+ var disabled = InstanceFiles.Toggle(Path.Combine(mods, "example.jar")); Assert.IsFalse(InstanceFiles.List(mods).Single().Enabled);
+ var enabled = InstanceFiles.Toggle(disabled); Assert.IsTrue(File.Exists(enabled));
+ await File.WriteAllTextAsync(Path.Combine(root, "launcher_accounts.json"), "private token");
+ Directory.CreateDirectory(Path.Combine(root, "PCL")); await File.WriteAllTextAsync(Path.Combine(root, "PCL/UserKey.bin"), "key");
+ var archive = Path.Combine(temp.Path, "export.zip"); await InstanceFiles.ExportAsync(root, archive);
+ using (var zip = ZipFile.OpenRead(archive)) CollectionAssert.AreEqual(new[] { "mods/example.jar" }, zip.Entries.Select(e => e.FullName).ToArray());
+ var backup = InstanceFiles.Archive(enabled, Path.Combine(temp.Path, "backups")); Assert.AreEqual("mod", await File.ReadAllTextAsync(backup));
+ }
+
+ [TestMethod]
+ public void ServerEditsPreserveUnrecognizedNbtAndCreateBackup()
+ {
+ using var temp = new TemporaryDirectory(); var file = Path.Combine(temp.Path, "servers.dat");
+ var original = new NbtFile(new NbtCompound("")); original.RootTag.Add(new NbtString("custom", "keep"));
+ var servers = new NbtList("servers", NbtTagType.Compound); servers.Add(new NbtCompound { new NbtString("name", "Old"), new NbtString("ip", "example.test"), new NbtString("icon", "keep-icon") });
+ original.RootTag.Add(servers); original.SaveToFile(file, NbtCompression.None);
+ ServerStore.Write(file, [new("New name", "example.test"), new("Local", "[::1]:25565")]);
+ Assert.AreEqual("New name", ServerStore.Read(file)[0].Name);
+ var result = new NbtFile(file); Assert.AreEqual("keep", result.RootTag.Get("custom")!.Value);
+ Assert.AreEqual("keep-icon", ((NbtCompound)result.RootTag.Get("servers")![0]).Get("icon")!.Value);
+ Assert.IsTrue(File.Exists(file + ".bak"));
+ }
+
+ [TestMethod]
+ public void LegacyLoaderArgumentsRetainTheBaseGameAndIsolatedDirectory()
+ {
+ using var temp = new TemporaryDirectory();
+ GameTests.WriteManifest(temp.Path, "base", """{"mainClass":"Main","libraries":[],"minecraftArguments":"--username ${auth_player_name} --gameDir ${game_directory}"}""");
+ GameTests.WriteManifest(temp.Path, "loader", """{"inheritsFrom":"base","mainClass":"Loader","arguments":{"jvm":["-Dloader=true"],"game":["--tweakClass","ExampleTweaker"]}}""");
+ File.WriteAllText(GameManifest.VersionPath(temp.Path, "base", ".jar"), "jar");
+ var isolated = Path.Combine(temp.Path, "isolated data");
+ var info = new GameProcess().CreateStartInfo(temp.Path, "loader", "java", LaunchAccount.Offline("Player"), 1024, "natives", new LaunchOptions
+ { CaptureLogs = false, GameDirectory = isolated, Environment = new Dictionary { ["PCL_TEST"] = "yes" }, JvmArguments = ["-Dcustom=some value"] });
+ Assert.IsFalse(info.RedirectStandardOutput); Assert.IsFalse(info.RedirectStandardError);
+ var args = info.ArgumentList.ToArray();
+ Assert.Contains("--username", args); Assert.Contains("--tweakClass", args); Assert.Contains("-cp", args);
+ Assert.Contains("-Dcustom=some value", args); Assert.Contains(isolated, args);
+ Assert.AreEqual(isolated, info.WorkingDirectory); Assert.AreEqual("yes", info.Environment["PCL_TEST"]);
+ }
+
+ [TestMethod]
+ public void QuickPlayUsesModernFlagsAndAvoidsObsoleteServerArguments()
+ {
+ using var temp = new TemporaryDirectory();
+ GameTests.WriteManifest(temp.Path, "test", """
+ {"mainClass":"Main","libraries":[],"arguments":{"game":[
+ {"rules":[{"action":"allow","features":{"is_quick_play_multiplayer":true}}],"value":["--quickPlayMultiplayer","${quickPlayMultiplayer}"]}]}}
+ """);
+ File.WriteAllText(GameManifest.VersionPath(temp.Path, "test", ".jar"), "jar");
+ var info = new GameProcess().CreateStartInfo(temp.Path, "test", "java", LaunchAccount.Offline("Player"), 1024, "natives", new LaunchOptions { ServerAddress = "server.test:25566" });
+ var args = info.ArgumentList.ToArray(); Assert.Contains("--quickPlayMultiplayer", args); Assert.Contains("server.test:25566", args); Assert.DoesNotContain("--server", args);
+ }
+
+ [TestMethod]
+ public void DownloadMirrorNeverRoutesAuthenticationOrArbitraryHosts()
+ {
+ const string mirror = "https://mirror.test";
+ Assert.AreEqual("https://mirror.test/assets/a/b", DownloadRoutingHandler.Map(new Uri("https://resources.download.minecraft.net/a/b"), mirror).AbsoluteUri);
+ Assert.AreEqual("https://mirror.test/maven/a.jar", DownloadRoutingHandler.Map(new Uri("https://libraries.minecraft.net/a.jar"), mirror).AbsoluteUri);
+ Assert.AreEqual("https://api.minecraftservices.com/minecraft/profile", DownloadRoutingHandler.Map(new Uri("https://api.minecraftservices.com/minecraft/profile"), mirror).AbsoluteUri);
+ Assert.ThrowsExactly(() => DownloadRoutingHandler.Map(new Uri("https://piston-meta.mojang.com/x"), "http://mirror.test"));
+ }
+
+ [TestMethod]
+ public void ExistingCrashRulesRemainAvailableToPortableHost()
+ {
+ var results = PortableDiagnostics.Analyze("java.lang.OutOfMemoryError: Java heap space\n at net.minecraft.client.Main.main(Main.java:1)");
+ Assert.IsTrue(results.Any(f => f.Cause == "OutOfMemory"));
+ }
+
+ [TestMethod]
+ public void CustomHomeNeverEvaluatesCodeOrMarkupExtensions()
+ {
+ Assert.ThrowsExactly(() => CustomHomeRenderer.Render("", _ => { }));
+ Assert.ThrowsExactly(() => CustomHomeRenderer.Render("", _ => { }));
+ Assert.ThrowsExactly(() => CustomHomeRenderer.Render("]>&a;", _ => { }));
+ }
+
+ internal static void WriteZip(string path, params (string Name, string Content)[] entries)
+ {
+ using var output = new FileStream(path, FileMode.Create); using var archive = new ZipArchive(output, ZipArchiveMode.Create);
+ foreach (var (name, content) in entries) { using var writer = new StreamWriter(archive.CreateEntry(name).Open()); writer.Write(content); }
+ }
+}
+
+internal sealed class FixtureHttp : HttpMessageHandler
+{
+ public Dictionary Files { get; } = new();
+ public List Requests { get; } = [];
+ public List ApiKeys { get; } = [];
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken token)
+ {
+ token.ThrowIfCancellationRequested(); var url = request.RequestUri!.AbsoluteUri;
+ lock (Requests) { Requests.Add(url); ApiKeys.Add(request.Headers.TryGetValues("x-api-key", out var values) ? values.Single() : null); }
+ var match = Files.Keys.OrderByDescending(key => key.Length).FirstOrDefault(key => url == key || key.EndsWith('*') && url.StartsWith(key[..^1], StringComparison.Ordinal));
+ return Task.FromResult(new HttpResponseMessage(match is null ? HttpStatusCode.NotFound : HttpStatusCode.OK) { Content = new ByteArrayContent(match is null ? [] : Files[match]) });
+ }
+ public void Json(string url, JsonNode json) => Files[url] = Encoding.UTF8.GetBytes(json.ToJsonString());
+ public void Json(string url, string json) => Files[url] = Encoding.UTF8.GetBytes(json);
+ public static string Hash(byte[] data) => Convert.ToHexString(SHA1.HashData(data)).ToLowerInvariant();
+}
diff --git a/PCL.CrossPlatform.Test/PCL.CrossPlatform.Test.csproj b/PCL.CrossPlatform.Test/PCL.CrossPlatform.Test.csproj
new file mode 100644
index 000000000..c0fed307d
--- /dev/null
+++ b/PCL.CrossPlatform.Test/PCL.CrossPlatform.Test.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ enable
+ enable
+ 15
+ true
+ false
+ Debug;CI;Release;Beta
+
+
+
+
+
+
+
+
+
+
diff --git a/PCL.CrossPlatform.Test/PlatformTests.cs b/PCL.CrossPlatform.Test/PlatformTests.cs
new file mode 100644
index 000000000..e01dc4a28
--- /dev/null
+++ b/PCL.CrossPlatform.Test/PlatformTests.cs
@@ -0,0 +1,134 @@
+using System.Text.Json.Nodes;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.Core.Minecraft.Java.Parser;
+using PCL.Core.Minecraft.Java.Scanner;
+using PCL.Core.Utils;
+using PCL.Core.Utils.OS;
+using PCL.CrossPlatform;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+public class PlatformTests
+{
+ [TestMethod]
+ public void CoreMetadataIsAvailableUnderAnUnrelatedTestHost()
+ {
+ Assert.IsFalse(string.IsNullOrWhiteSpace(PCL.Core.App.Basics.VersionName));
+ Assert.IsTrue(PCL.Core.App.Basics.VersionCode > 0);
+ Assert.IsTrue(Path.IsPathFullyQualified(PCL.Core.App.Basics.ExecutableDirectory));
+ }
+
+ [TestMethod]
+ public void SecondaryInstanceActivatesPrimaryAcrossThreads()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ try { using var probe = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.Unix, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Unspecified); }
+ catch (System.Net.Sockets.SocketException ex) when (ex.SocketErrorCode == System.Net.Sockets.SocketError.AccessDenied)
+ { Assert.Inconclusive("This container prohibits Unix-domain sockets; IPC is verified by the three-system CI."); }
+ }
+ var name = "pcl-test-" + Guid.NewGuid().ToString("N");
+ using var primary = new SingleInstanceHost(name);
+ Assert.IsTrue(primary.OwnsInstance);
+ using var activated = new ManualResetEventSlim();
+ primary.ActivateRequested += () => activated.Set(); primary.Listen();
+ Exception? failure = null;
+ var secondaryThread = new Thread(() =>
+ {
+ try
+ {
+ using var secondary = new SingleInstanceHost(name);
+ Assert.IsFalse(secondary.OwnsInstance);
+ secondary.ActivatePrimaryAsync().GetAwaiter().GetResult();
+ }
+ catch (Exception ex) { failure = ex; }
+ });
+ secondaryThread.Start();
+ Assert.IsTrue(secondaryThread.Join(TimeSpan.FromSeconds(8)));
+ if (failure is not null) throw failure;
+ Assert.IsTrue(activated.Wait(TimeSpan.FromSeconds(5)));
+ }
+
+ [TestMethod]
+ public void DomainAssembliesDoNotDependOnDesktopFrameworks()
+ {
+ foreach (var assembly in new[] { typeof(PCL.Core.App.Basics).Assembly, typeof(LauncherCommon).Assembly })
+ {
+ var references = assembly.GetReferencedAssemblies().Select(value => value.Name).ToArray();
+ foreach (var forbidden in new[] { "PresentationFramework", "PresentationCore", "WindowsBase", "Avalonia.Controls" })
+ Assert.DoesNotContain(forbidden, references);
+ }
+ }
+
+ [TestMethod]
+ public void RulesUseLastMatchingActionAndPlatformFeatures()
+ {
+ var rules = JsonNode.Parse("""
+ [{"action":"allow"},{"action":"disallow","os":{"name":"osx"}},
+ {"action":"allow","os":{"name":"osx","arch":"aarch64"}}]
+ """)!.AsArray();
+ Assert.IsTrue(new RuntimePlatform("linux", "amd64", "6.1", ':').Allows(rules));
+ Assert.IsFalse(new RuntimePlatform("osx", "amd64", "14.0", ':').Allows(rules));
+ Assert.IsTrue(new RuntimePlatform("osx", "aarch64", "14.0", ':').Allows(rules));
+ var feature = JsonNode.Parse("""[{"action":"allow","features":{"is_demo_user":true}}]""")!.AsArray();
+ Assert.IsFalse(RuntimePlatform.Current.Allows(feature));
+ Assert.IsTrue(RuntimePlatform.Current.Allows(feature, new Dictionary { ["is_demo_user"] = true }));
+ Assert.IsFalse(RuntimePlatform.Current.Allows(new JsonArray()));
+ }
+
+ [TestMethod]
+ public void Arm64RequiresAnArmNativeInsteadOfSilentlyLoadingX64()
+ {
+ var platform = new RuntimePlatform("linux", "aarch64", "6.1", ':');
+ var library = JsonNode.Parse("""
+ {"name":"test:lwjgl:1","natives":{"linux":"natives-linux"},"downloads":{"classifiers":{"natives-linux":{},"natives-linux-arm64":{}}}}
+ """)!.AsObject();
+ Assert.AreEqual("natives-linux-arm64", platform.NativeClassifier(library));
+ library["downloads"]!["classifiers"]!.AsObject().Remove("natives-linux-arm64");
+ Assert.ThrowsExactly(() => platform.NativeClassifier(library));
+ }
+
+ [TestMethod]
+ public void RuntimeSystemInformationDoesNotLoadWindowsLibrariesOnUnix()
+ {
+ Assert.IsTrue(KernelInterop.GetPhysicalMemoryBytes().Total > 0);
+ Assert.IsTrue(KernelInterop.GetMemoryLoadPercent() is >= 0 and <= 100);
+ Assert.IsTrue(KernelInterop.GetPerformanceLogicalProcessorCount() > 0);
+ _ = SystemInfo.OSInfo;
+ if (!OperatingSystem.IsWindows())
+ {
+ Assert.IsFalse(SystemInfo.IsGBKEncoding);
+ new RegistryJavaScanner().Scan(new List());
+ new MicrosoftStoreJavaScanner().Scan(new List());
+ ProcessInterop.SetGpuPreference("/not/a/file");
+ }
+ }
+
+ [TestMethod]
+ public void UnixJavaParserResolvesSymlinksWithoutExecutingJava()
+ {
+ if (OperatingSystem.IsWindows()) Assert.Inconclusive("This test exercises Unix-specific behavior.");
+ using var temp = new TemporaryDirectory();
+ var home = Path.Combine(temp.Path, "jdk");
+ Directory.CreateDirectory(Path.Combine(home, "bin"));
+ File.WriteAllText(Path.Combine(home, "bin/java"), "This must not be executed");
+ File.WriteAllText(Path.Combine(home, "bin/javac"), "");
+ File.WriteAllText(Path.Combine(home, "release"), "JAVA_VERSION=\"21.0.4\"\nOS_ARCH=\"aarch64\"\nIMPLEMENTOR=\"Eclipse Adoptium\"\n");
+ var link = Path.Combine(temp.Path, "java");
+ File.CreateSymbolicLink(link, Path.Combine(home, "bin/java"));
+ var installation = new ReleaseFileJavaParser().Parse(link).Match(value => value, error => throw error);
+ Assert.AreEqual(21, installation.MajorVersion);
+ Assert.AreEqual(MachineType.ARM64, installation.Architecture);
+ Assert.IsFalse(installation.IsJre);
+ Assert.AreEqual(Path.Combine(home, "bin/java"), installation.JavaExePath);
+ Assert.IsNull(installation.JavawExePath);
+ }
+}
+
+internal sealed class TemporaryDirectory : IDisposable
+{
+ public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "pcl-test-" + Guid.NewGuid().ToString("N"));
+ public TemporaryDirectory() => Directory.CreateDirectory(Path);
+ public void Dispose() => Directory.Delete(Path, true);
+}
diff --git a/PCL.CrossPlatform.Test/ResourceAndPackTests.cs b/PCL.CrossPlatform.Test/ResourceAndPackTests.cs
new file mode 100644
index 000000000..ba870f7a6
--- /dev/null
+++ b/PCL.CrossPlatform.Test/ResourceAndPackTests.cs
@@ -0,0 +1,134 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.CrossPlatform;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+public class ResourceAndPackTests
+{
+ [TestMethod]
+ public async Task RequiredResourceDependenciesAreResolvedOnceWithCyclesAndHashes()
+ {
+ using var temp = new TemporaryDirectory(); var handler = new FixtureHttp(); using var client = new HttpClient(handler);
+ var a = Release("a", "b"); var b = Release("b", "a");
+ handler.Json("https://api.modrinth.com/v2/version/a", a); handler.Json("https://api.modrinth.com/v2/version/b", b);
+ handler.Files["https://files.test/a.jar"] = Encoding.UTF8.GetBytes("a"); handler.Files["https://files.test/b.jar"] = Encoding.UTF8.GetBytes("b");
+ var files = await new ResourceCatalog(client).InstallAsync(ResourceProvider.Modrinth, new("a", "A", a), temp.Path, "1.20.1", "fabric", default);
+ Assert.AreEqual(2, files.Count); Assert.AreEqual("a", File.ReadAllText(Path.Combine(temp.Path, "a.jar"))); Assert.AreEqual("b", File.ReadAllText(Path.Combine(temp.Path, "b.jar")));
+ Assert.AreEqual(1, handler.Requests.Count(url => url == "https://files.test/a.jar"));
+ }
+
+ [TestMethod]
+ public async Task ExistingResourceIsBackedUpAndInvalidReplacementLeavesItUntouched()
+ {
+ using var temp = new TemporaryDirectory(); var handler = new FixtureHttp(); using var client = new HttpClient(handler);
+ var release = Release("a", "unused"); release["dependencies"] = new JsonArray();
+ handler.Files["https://files.test/a.jar"] = Encoding.UTF8.GetBytes("a");
+ var original = Path.Combine(temp.Path, "a.jar"); File.WriteAllText(original, "previous");
+ var catalog = new ResourceCatalog(client);
+ await catalog.InstallAsync(ResourceProvider.Modrinth, new("a", "A", release), temp.Path, "", "", default);
+ Assert.AreEqual("a", File.ReadAllText(original));
+ Assert.IsTrue(Directory.GetFiles(Path.Combine(temp.Path, ".pcl-trash"), "*", SearchOption.AllDirectories).Any(f => File.ReadAllText(f) == "previous"));
+ handler.Files["https://files.test/a.jar"] = Encoding.UTF8.GetBytes("corrupt");
+ await Assert.ThrowsExactlyAsync(() => catalog.InstallAsync(ResourceProvider.Modrinth, new("a", "A", release), temp.Path, "", "", default));
+ Assert.AreEqual("a", File.ReadAllText(original));
+ Assert.AreEqual(0, Directory.GetFiles(temp.Path, "*.update-*", SearchOption.AllDirectories).Length);
+ }
+
+ [TestMethod]
+ public async Task LabyModUsesSupportedVersionManifestAndRejectsUnsupportedMinecraft()
+ {
+ var handler = new FixtureHttp(); using var client = new HttpClient(handler);
+ handler.Json("https://releases.r2.labymod.net/api/v1/manifest/production/latest.json", JsonNode.Parse("""{"labyModVersion":"4-test","commitReference":"abc","minecraftVersions":[{"version":"1.21.1","customManifestUrl":"https://files.test/laby.json"}]}""")!);
+ var installer = new LoaderInstaller(client);
+ var supported = await installer.GetVersionsAsync(LoaderKind.LabyMod, "1.21.1", default);
+ Assert.AreEqual("https://files.test/laby.json", supported.Single().Url);
+ Assert.AreEqual(0, (await installer.GetVersionsAsync(LoaderKind.LabyMod, "unsupported", default)).Count);
+ }
+
+ [TestMethod]
+ public async Task CurseForgeRespectsAuthorDownloadRestrictions()
+ {
+ using var temp = new TemporaryDirectory(); using var client = new HttpClient(new FixtureHttp());
+ var release = JsonNode.Parse("""{"id":1,"fileName":"restricted.jar","downloadUrl":null,"dependencies":[]}""")!.AsObject();
+ await Assert.ThrowsExactlyAsync(() => new ResourceCatalog(client, "test-key").InstallAsync(ResourceProvider.CurseForge, new("1", "restricted", release), temp.Path, "", "", default));
+ Assert.AreEqual(0, Directory.GetFiles(temp.Path).Length);
+ }
+
+ [TestMethod]
+ public async Task FabricInstallRetainsParentVersionAndDownloadsItsLibraries()
+ {
+ using var temp = new TemporaryDirectory(); var handler = BaseGame(); using var client = new HttpClient(handler);
+ handler.Json("https://meta.fabricmc.net/profile", """{"id":"fabric-test","inheritsFrom":"base","mainClass":"KnotClient","libraries":[{"name":"fabric:loader:1","downloads":{"artifact":{"path":"fabric/loader.jar","url":"https://files.test/loader.jar"}}}]}""");
+ handler.Files["https://files.test/loader.jar"] = [1, 2, 3];
+ var id = await new LoaderInstaller(client).InstallAsync(temp.Path, "java", new(LoaderKind.Fabric, "1", "base", "https://meta.fabricmc.net/profile"), null, default);
+ Assert.AreEqual("fabric-test", id); Assert.IsTrue(File.Exists(GameManifest.VersionPath(temp.Path, "base", ".jar")));
+ Assert.AreEqual("base", GameManifest.Load(temp.Path, id)["jar"]!.ToString()); Assert.IsTrue(File.Exists(Path.Combine(temp.Path, "libraries/fabric/loader.jar")));
+ }
+
+ [TestMethod]
+ public async Task FailedLoaderInstallRemovesNewProfileButPreservesBase()
+ {
+ using var temp = new TemporaryDirectory(); var handler = BaseGame(); using var client = new HttpClient(handler);
+ handler.Json("https://meta.fabricmc.net/profile", """{"id":"broken-loader","inheritsFrom":"base","libraries":[{"name":"missing:library:1","downloads":{"artifact":{"path":"missing.jar","url":"https://files.test/missing.jar"}}}]}""");
+ await Assert.ThrowsAsync(() => new LoaderInstaller(client).InstallAsync(temp.Path, "java", new(LoaderKind.Fabric, "1", "base", "https://meta.fabricmc.net/profile"), null, default));
+ Assert.IsFalse(File.Exists(GameManifest.VersionPath(temp.Path, "broken-loader"))); Assert.IsTrue(File.Exists(GameManifest.VersionPath(temp.Path, "base")));
+ }
+
+ [TestMethod]
+ public async Task ModrinthPackSkipsServerOnlyAndOptionalFilesAndPublishesAtomically()
+ {
+ using var temp = new TemporaryDirectory(); var handler = BaseGame(); using var client = new HttpClient(handler);
+ var pack = Path.Combine(temp.Path, "pack.mrpack"); var data = Encoding.UTF8.GetBytes("mod"); handler.Files["https://files.test/mod.jar"] = data;
+ var file = new JsonObject { ["path"] = "mods/mod.jar", ["fileSize"] = data.Length, ["hashes"] = new JsonObject { ["sha1"] = FixtureHttp.Hash(data) }, ["downloads"] = new JsonArray("https://files.test/mod.jar") };
+ var optional = file.DeepClone(); optional!["path"] = "mods/optional.jar"; optional["env"] = new JsonObject { ["client"] = "optional" };
+ var server = file.DeepClone(); server!["path"] = "mods/server.jar"; server["env"] = new JsonObject { ["client"] = "unsupported" };
+ var index = new JsonObject { ["formatVersion"] = 1, ["game"] = "minecraft", ["dependencies"] = new JsonObject { ["minecraft"] = "base" }, ["files"] = new JsonArray(file, optional, server) };
+ MigrationTests.WriteZip(pack, ("modrinth.index.json", index.ToJsonString()), ("overrides/config/example.txt", "settings"));
+ var destination = Path.Combine(temp.Path, "installed"); var result = await new ModpackInstaller(client).InstallAsync(pack, destination, "java", false, null, default);
+ Assert.AreEqual("base", result.VersionId); Assert.AreEqual("mod", File.ReadAllText(Path.Combine(destination, "mods/mod.jar")));
+ Assert.IsFalse(File.Exists(Path.Combine(destination, "mods/server.jar"))); Assert.IsFalse(File.Exists(Path.Combine(destination, "mods/optional.jar")));
+ Assert.AreEqual("settings", File.ReadAllText(Path.Combine(destination, "config/example.txt")));
+ Assert.AreEqual(0, Directory.GetDirectories(temp.Path, "*.install-*").Length);
+ }
+
+ [TestMethod]
+ public async Task FailedPackLeavesNoPublishedInstanceOrStagingDirectory()
+ {
+ using var temp = new TemporaryDirectory(); var handler = BaseGame(); using var client = new HttpClient(handler);
+ var pack = Path.Combine(temp.Path, "pack.mrpack");
+ MigrationTests.WriteZip(pack, ("modrinth.index.json", """{"formatVersion":1,"game":"minecraft","dependencies":{"minecraft":"base"},"files":[]}"""), ("../escape", "bad"));
+ var destination = Path.Combine(temp.Path, "installed");
+ await Assert.ThrowsExactlyAsync(() => new ModpackInstaller(client).InstallAsync(pack, destination, "java", false, null, default));
+ Assert.IsFalse(Directory.Exists(destination)); Assert.AreEqual(0, Directory.GetDirectories(temp.Path, "*.install-*").Length);
+ }
+
+ [TestMethod]
+ public async Task MultiMcPackInstallsComponentsAndAppliesLocalPatches()
+ {
+ using var temp = new TemporaryDirectory(); var handler = BaseGame(); using var client = new HttpClient(handler);
+ var pack = Path.Combine(temp.Path, "pack.zip");
+ MigrationTests.WriteZip(pack, ("MyPack/mmc-pack.json", """{"components":[{"uid":"net.minecraft","version":"base"}]}"""),
+ ("MyPack/.minecraft/options.txt", "music:0"), ("MyPack/patches/custom.json", """{"uid":"custom","mainClass":"CustomMain","+jvmArgs":["-Dcustom=true"]}"""));
+ var destination = Path.Combine(temp.Path, "installed"); var result = await new ModpackInstaller(client).InstallAsync(pack, destination, "java", false, null, default);
+ Assert.AreEqual("pack-custom", result.VersionId); Assert.AreEqual("CustomMain", GameManifest.Load(destination, result.VersionId)["mainClass"]!.ToString());
+ Assert.AreEqual("music:0", File.ReadAllText(Path.Combine(destination, "options.txt")));
+ }
+
+ private static JsonObject Release(string id, string dependency) => new()
+ {
+ ["id"] = id, ["name"] = id,
+ ["dependencies"] = new JsonArray(new JsonObject { ["dependency_type"] = "required", ["version_id"] = dependency }),
+ ["files"] = new JsonArray(new JsonObject { ["filename"] = id + ".jar", ["url"] = "https://files.test/" + id + ".jar", ["hashes"] = new JsonObject { ["sha1"] = FixtureHttp.Hash(Encoding.UTF8.GetBytes(id)) } })
+ };
+ internal static FixtureHttp BaseGame()
+ {
+ var handler = new FixtureHttp(); var data = Encoding.UTF8.GetBytes("client jar"); handler.Files["https://files.test/client.jar"] = data;
+ var game = new JsonObject { ["id"] = "base", ["mainClass"] = "Main", ["libraries"] = new JsonArray(), ["downloads"] = new JsonObject { ["client"] = new JsonObject { ["url"] = "https://files.test/client.jar", ["sha1"] = FixtureHttp.Hash(data), ["size"] = data.Length } } };
+ handler.Json("https://files.test/base.json", game);
+ handler.Json(GameInstaller.VersionManifestUrl, new JsonObject { ["versions"] = new JsonArray(new JsonObject { ["id"] = "base", ["type"] = "release", ["url"] = "https://files.test/base.json", ["sha1"] = FixtureHttp.Hash(Encoding.UTF8.GetBytes(game.ToJsonString())) }) });
+ return handler;
+ }
+}
diff --git a/PCL.CrossPlatform.Test/RuntimeAndUpdateTests.cs b/PCL.CrossPlatform.Test/RuntimeAndUpdateTests.cs
new file mode 100644
index 000000000..9a204ba76
--- /dev/null
+++ b/PCL.CrossPlatform.Test/RuntimeAndUpdateTests.cs
@@ -0,0 +1,162 @@
+using System.Diagnostics;
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.Avalonia;
+using PCL.Core.Minecraft.Profile.Authentication;
+using PCL.Core.Minecraft.Profile.Models;
+using PCL.Core.Utils.Secret;
+using PCL.CrossPlatform;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+[DoNotParallelize]
+public class RuntimeAndUpdateTests
+{
+ [TestMethod]
+ public async Task JavaInstallerVerifiesDigestAndPreservesExecutableMode()
+ {
+ using var temp = new TemporaryDirectory(); var handler = new FixtureHttp(); using var client = new HttpClient(handler);
+ var name = OperatingSystem.IsWindows() ? "jdk/bin/java.exe" : "jdk/bin/java";
+ var archive = OperatingSystem.IsWindows() ? Zip(name, "java") : Tar((name, "java"));
+ handler.Files["https://files.test/jdk"] = archive;
+ handler.Json("https://api.adoptium.net/*", new JsonArray(new JsonObject
+ {
+ ["binary"] = new JsonObject { ["package"] = new JsonObject { ["link"] = "https://files.test/jdk", ["size"] = archive.Length, ["checksum"] = Convert.ToHexString(SHA256.HashData(archive)) } }
+ }));
+ var path = await new JavaRuntimeInstaller(client).InstallAsync(21, temp.Path, null, default);
+ Assert.AreEqual("java", File.ReadAllText(path));
+ if (!OperatingSystem.IsWindows()) Assert.IsTrue((File.GetUnixFileMode(path) & UnixFileMode.UserExecute) != 0);
+ var second = await new JavaRuntimeInstaller(client).InstallAsync(21, temp.Path, null, default); Assert.AreEqual(path, second);
+ Assert.AreEqual(1, handler.Requests.Count(url => url == "https://files.test/jdk"));
+ }
+
+ [TestMethod]
+ public async Task JavaArchiveMaterializesInternalFileLinksAndRejectsEscapingLinks()
+ {
+ if (OperatingSystem.IsWindows()) { Assert.Inconclusive("Java tar archives are used on Unix."); return; }
+ foreach (var outside in new[] { false, true })
+ {
+ using var temp = new TemporaryDirectory(); var handler = new FixtureHttp(); using var client = new HttpClient(handler);
+ using var stream = new MemoryStream();
+ using (var gzip = new GZipStream(stream, CompressionMode.Compress, true))
+ using (var tar = new TarWriter(gzip, leaveOpen: true))
+ {
+ using var java = new MemoryStream(Encoding.UTF8.GetBytes("java"));
+ tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "jdk/bin/java") { DataStream = java, Mode = UnixFileMode.UserRead | UnixFileMode.UserExecute });
+ tar.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "jdk/lib/java-alias") { LinkName = outside ? "../../../outside" : "../bin/java" });
+ }
+ var archive = stream.ToArray(); handler.Files["https://files.test/jdk"] = archive;
+ handler.Json("https://api.adoptium.net/*", new JsonArray(new JsonObject { ["binary"] = new JsonObject { ["package"] = new JsonObject
+ { ["link"] = "https://files.test/jdk", ["checksum"] = Convert.ToHexString(SHA256.HashData(archive)) } } }));
+ var installer = new JavaRuntimeInstaller(client);
+ if (outside)
+ {
+ await Assert.ThrowsExactlyAsync(() => installer.InstallAsync(21, temp.Path, null, default));
+ Assert.AreEqual(0, Directory.GetFileSystemEntries(temp.Path).Length);
+ }
+ else
+ {
+ var path = await installer.InstallAsync(21, temp.Path, null, default);
+ var alias = Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(path))!, "lib", "java-alias");
+ Assert.AreEqual("java", File.ReadAllText(alias)); Assert.IsNull(new FileInfo(alias).LinkTarget);
+ }
+ }
+ }
+
+ [TestMethod]
+ public async Task BadJavaDigestDoesNotPublishRuntime()
+ {
+ using var temp = new TemporaryDirectory(); var handler = new FixtureHttp(); using var client = new HttpClient(handler);
+ handler.Files["https://files.test/jdk"] = [1, 2, 3];
+ handler.Json("https://api.adoptium.net/*", """[{"binary":{"package":{"link":"https://files.test/jdk","size":3,"checksum":"0000000000000000000000000000000000000000000000000000000000000000"}}}]""");
+ await Assert.ThrowsExactlyAsync(() => new JavaRuntimeInstaller(client).InstallAsync(21, temp.Path, null, default));
+ Assert.AreEqual(0, Directory.GetFileSystemEntries(temp.Path).Length);
+ }
+
+ [TestMethod]
+ public async Task UpdateStagingSeparatesHelperAndBackupFromPayload()
+ {
+ using var temp = new TemporaryDirectory(); var executable = OperatingSystem.IsWindows() ? "PCL-CE.exe" : "PCL-CE";
+ var archive = Path.Combine(temp.Path, "update.tar.gz"); File.WriteAllBytes(archive, Tar((executable, "program"), ("PCL-CE.dll", "assembly")));
+ var plan = await DesktopUpdater.PrepareAsync(archive, Path.Combine(temp.Path, "install with spaces"), temp.Path, default);
+ Assert.AreNotEqual(plan.SourceDirectory, plan.WorkingDirectory);
+ var info = DesktopUpdater.CreateApplyProcess(plan, 12345);
+ Assert.Contains(plan.SourceDirectory, info.ArgumentList.ToArray()); Assert.Contains(plan.InstallDirectory, info.ArgumentList.ToArray());
+ Assert.IsFalse(info.UseShellExecute); Assert.AreEqual("", info.Arguments);
+ Assert.IsTrue(File.Exists(Path.Combine(plan.SourceDirectory, executable)));
+ Assert.IsFalse(Directory.GetFiles(plan.SourceDirectory).Any(path => path.EndsWith(".ps1") || path.EndsWith(".sh")));
+ }
+
+ [TestMethod]
+ public async Task UpdateArchiveRejectsTraversalAndMissingProgram()
+ {
+ using var temp = new TemporaryDirectory(); var archive = Path.Combine(temp.Path, "update.tar.gz");
+ File.WriteAllBytes(archive, Tar(("../outside", "bad")));
+ await Assert.ThrowsExactlyAsync(() => DesktopUpdater.PrepareAsync(archive, "/unused", temp.Path, default));
+ Assert.IsFalse(File.Exists(Path.Combine(temp.Path, "outside")));
+ File.WriteAllBytes(archive, Tar(("unrelated", "file")));
+ await Assert.ThrowsExactlyAsync(() => DesktopUpdater.PrepareAsync(archive, "/unused", temp.Path, default));
+ Assert.AreEqual(0, Directory.GetDirectories(temp.Path, "update-*").Length);
+ }
+
+ [TestMethod]
+ public async Task UnixUpdateHelperBacksUpProgramAndRestartsOnlyPreparedExecutable()
+ {
+ if (OperatingSystem.IsWindows()) { Assert.Inconclusive("Unix update helper integration test."); return; }
+ using var temp = new TemporaryDirectory(); var install = Path.Combine(temp.Path, "install with spaces"); Directory.CreateDirectory(install);
+ File.WriteAllText(Path.Combine(install, "old-file"), "old data");
+ var archive = Path.Combine(temp.Path, "update.tar.gz");
+ File.WriteAllBytes(archive, Tar(("PCL-CE", "#!/bin/sh\nprintf updated > \"$(dirname \"$0\")/restarted\"\n"), ("PCL-CE.dll", "assembly")));
+ var plan = await DesktopUpdater.PrepareAsync(archive, install, temp.Path, default);
+ using var process = Process.Start(DesktopUpdater.CreateApplyProcess(plan, 999999999))!;
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await process.WaitForExitAsync(timeout.Token);
+ Assert.AreEqual(0, process.ExitCode);
+ for (var attempt = 0; attempt < 30 && !File.Exists(Path.Combine(install, "restarted")); attempt++) await Task.Delay(25, timeout.Token);
+ Assert.AreEqual("updated", File.ReadAllText(Path.Combine(install, "restarted")));
+ Assert.AreEqual("old data", File.ReadAllText(Path.Combine(plan.WorkingDirectory, "previous", "old-file")));
+ Assert.IsFalse(Directory.Exists(Path.Combine(install, "previous")), "Backup must never be copied into the payload.");
+ }
+
+ [TestMethod]
+ public void MultipleAccountsMigrateLegacyStorageAndRefreshWithoutDuplicates()
+ {
+ using var temp = new TemporaryDirectory(); EncryptHelper.OverrideKeyFile(Path.Combine(temp.Path, "key.bin")); EncryptHelper.ResetKeyCache();
+ try
+ {
+ var old = new McProfile { ProfileId = "old", ProfileType = ProfileType.Microsoft, UserName = "Old", Uuid = "uuid-old", AccessToken = "old-token" };
+ var settings = new DesktopSettings { EncryptedAccount = EncryptHelper.SecretEncrypt(System.Text.Json.JsonSerializer.Serialize(old)) };
+ Assert.AreEqual("Old", settings.SelectedAccount()!.UserName);
+ settings.StoreAccount(new AuthenticationResult { ProfileType = ProfileType.Authlib, UserName = "New", Uuid = "uuid-new", AccessToken = "secret-token", Server = "https://auth.test/authserver" });
+ Assert.AreEqual(2, settings.Accounts().Count); Assert.AreEqual("New", settings.SelectedAccount()!.UserName);
+ settings.StoreAccount(new AuthenticationResult { ProfileType = ProfileType.Authlib, UserName = "New", Uuid = "uuid-new", AccessToken = "refreshed", Server = "https://auth.test/authserver" });
+ Assert.AreEqual(2, settings.Accounts().Count); Assert.AreEqual("refreshed", settings.SelectedAccount()!.AccessToken);
+ Assert.IsFalse(settings.EncryptedAccounts!.Contains("refreshed", StringComparison.Ordinal));
+ settings.RemoveSelectedAccount(); Assert.AreEqual("Old", settings.SelectedAccount()!.UserName);
+ }
+ finally { EncryptHelper.OverrideKeyFile(null); EncryptHelper.ResetKeyCache(); }
+ }
+
+ private static byte[] Zip(string name, string content)
+ {
+ using var stream = new MemoryStream(); using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true))
+ using (var writer = new StreamWriter(zip.CreateEntry(name).Open())) writer.Write(content);
+ return stream.ToArray();
+ }
+ private static byte[] Tar(params (string Name, string Content)[] files)
+ {
+ using var stream = new MemoryStream();
+ using (var gzip = new GZipStream(stream, CompressionMode.Compress, true))
+ using (var tar = new TarWriter(gzip, leaveOpen: true))
+ foreach (var (name, content) in files)
+ {
+ using var data = new MemoryStream(Encoding.UTF8.GetBytes(content));
+ tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = data, Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute });
+ }
+ return stream.ToArray();
+ }
+}
diff --git a/PCL.CrossPlatform.Test/SecretTests.cs b/PCL.CrossPlatform.Test/SecretTests.cs
new file mode 100644
index 000000000..d3a6e8963
--- /dev/null
+++ b/PCL.CrossPlatform.Test/SecretTests.cs
@@ -0,0 +1,34 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PCL.Core.Utils.Secret;
+
+namespace PCL.CrossPlatform.Test;
+
+[TestClass]
+[DoNotParallelize]
+public class SecretTests
+{
+ [TestMethod]
+ public void SecretsSurviveRestartAndUnixKeysUseOwnerOnlyPermissions()
+ {
+ using var temp = new TemporaryDirectory();
+ var path = Path.Combine(temp.Path, "UserKey.unix.bin");
+ EncryptHelper.OverrideKeyFile(path); EncryptHelper.ResetKeyCache();
+ try
+ {
+ var encrypted = EncryptHelper.SecretEncrypt("test refresh token");
+ Assert.IsTrue(File.Exists(path), "The key must be persisted on every desktop platform.");
+ if (!OperatingSystem.IsWindows())
+ Assert.AreEqual(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path));
+ EncryptHelper.ResetKeyCache();
+ Assert.AreEqual("test refresh token", EncryptHelper.SecretDecrypt(encrypted));
+ if (OperatingSystem.IsWindows()) return;
+ var original = File.ReadAllBytes(path);
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.OtherRead);
+ EncryptHelper.ResetKeyCache();
+ Assert.ThrowsExactly(() => EncryptHelper.EncryptionKey);
+ CollectionAssert.AreEqual(original, File.ReadAllBytes(path));
+ Assert.AreEqual(1, Directory.GetFiles(temp.Path).Length, "Insecure access must not silently rotate the key.");
+ }
+ finally { EncryptHelper.OverrideKeyFile(null); EncryptHelper.ResetKeyCache(); }
+ }
+}
diff --git a/PCL.CrossPlatform.slnx b/PCL.CrossPlatform.slnx
new file mode 100644
index 000000000..cb7351a56
--- /dev/null
+++ b/PCL.CrossPlatform.slnx
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PCL.Launcher.Core/CrossPlatform/DesktopUpdater.cs b/PCL.Launcher.Core/CrossPlatform/DesktopUpdater.cs
new file mode 100644
index 000000000..71081313b
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/DesktopUpdater.cs
@@ -0,0 +1,123 @@
+using System.Diagnostics;
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+
+namespace PCL.CrossPlatform;
+
+public sealed record DesktopRelease(string Version, string Notes, string AssetName, string DownloadUrl, long Size, string? Sha256);
+public sealed record PreparedUpdate(string SourceDirectory, string InstallDirectory, string ExecutableRelativePath, string WorkingDirectory);
+
+/// Downloads and validates a platform package, then uses a small platform helper after the launcher exits.
+public sealed class DesktopUpdater(HttpClient client)
+{
+ public static string CurrentRid => (OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsMacOS() ? "osx" : "linux") + "-" + (RuntimePlatform.Current.Architecture == "aarch64" ? "arm64" : "x64");
+ public async Task LatestAsync(bool prerelease, CancellationToken token)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/bit-torch/PCL-CE/releases" + (prerelease ? "" : "/latest"));
+ request.Headers.UserAgent.ParseAdd("PCL-CE/1.0");
+ using var response = await client.SendAsync(request, token); response.EnsureSuccessStatusCode();
+ var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!;
+ var release = prerelease ? json.AsArray().FirstOrDefault(item => item?["draft"]?.GetValue() != true) : json;
+ if (release is null) throw new InvalidOperationException("没有可用发行版。");
+ var asset = release["assets"]!.AsArray().FirstOrDefault(item => item?["name"]?.ToString().StartsWith("PCL-CE-" + CurrentRid, StringComparison.Ordinal) == true && item["name"]!.ToString().EndsWith(".tar.gz"))
+ ?? throw new InvalidOperationException("最新发行版没有当前平台的跨平台桌面包。");
+ var digest = asset["digest"]?.ToString();
+ return new(release["tag_name"]!.ToString(), release["body"]?.ToString() ?? "", asset["name"]!.ToString(),
+ asset["browser_download_url"]!.ToString(), asset["size"]!.GetValue(), digest?.StartsWith("sha256:") == true ? digest[7..] : null);
+ }
+
+ public async Task DownloadAsync(DesktopRelease release, string destination, CancellationToken token)
+ {
+ await new GameInstaller(client).DownloadAsync(release.DownloadUrl, destination, null, release.Size, token);
+ if (release.Sha256 is not null)
+ {
+ await using var input = File.OpenRead(destination);
+ if (!Convert.ToHexString(await SHA256.HashDataAsync(input, token)).Equals(release.Sha256, StringComparison.OrdinalIgnoreCase))
+ { File.Delete(destination); throw new InvalidDataException("更新包 SHA-256 校验失败。"); }
+ }
+ }
+
+ public static async Task PrepareAsync(string archive, string installDirectory, string stagingParent, CancellationToken token)
+ {
+ var stage = Path.Combine(stagingParent, "update-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(stage);
+ var payload = Path.Combine(stage, "contents"); Directory.CreateDirectory(payload);
+ try
+ {
+ await using var file = File.OpenRead(archive); await using var gzip = new GZipStream(file, CompressionMode.Decompress); using var reader = new TarReader(gzip);
+ while (await reader.GetNextEntryAsync(false, token) is { } entry)
+ {
+ var name = entry.Name.TrimEnd('/'); if (name.StartsWith("./")) name = name[2..]; if (name is "" or ".") continue;
+ var path = GameManifest.SafePath(payload, name); InstanceFiles.EnsureNoLinks(payload, path);
+ if (entry.EntryType == TarEntryType.Directory) { Directory.CreateDirectory(path); continue; }
+ if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile)) throw new InvalidDataException("更新包包含不支持的链接或特殊文件。");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await using (var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
+ if (entry.DataStream is not null) await entry.DataStream.CopyToAsync(output, token);
+ if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, entry.Mode & (UnixFileMode)0x1FF);
+ }
+ var executable = OperatingSystem.IsWindows() ? "PCL-CE.exe" : "PCL-CE";
+ var source = payload; var destination = Path.GetFullPath(installDirectory); var relative = executable;
+ if (OperatingSystem.IsMacOS())
+ {
+ var bundle = Directory.GetDirectories(payload, "*.app").SingleOrDefault();
+ if (bundle is not null)
+ {
+ var currentBundle = new DirectoryInfo(destination).Parent?.Parent;
+ if (currentBundle?.Name.EndsWith(".app", StringComparison.Ordinal) == true)
+ { source = bundle; destination = currentBundle.FullName; relative = "Contents/MacOS/" + executable; }
+ else source = Path.Combine(bundle, "Contents", "MacOS");
+ }
+ }
+ if (!File.Exists(GameManifest.SafePath(source, relative)) || !File.Exists(Path.Combine(source, Path.GetDirectoryName(relative) ?? "", "PCL-CE.dll")))
+ throw new InvalidDataException("更新包缺少启动器程序。");
+ return new(source, destination, relative, stage);
+ }
+ catch { Directory.Delete(stage, true); throw; }
+ }
+
+ public static ProcessStartInfo CreateApplyProcess(PreparedUpdate update, int parentPid)
+ {
+ if (parentPid <= 0) throw new ArgumentOutOfRangeException(nameof(parentPid));
+ var windows = OperatingSystem.IsWindows();
+ var script = Path.Combine(update.WorkingDirectory, windows ? "apply-update.ps1" : "apply-update.sh");
+ File.WriteAllText(script, windows ? WindowsScript : UnixScript);
+ var info = new ProcessStartInfo(windows ? "powershell.exe" : "/bin/sh") { UseShellExecute = false, CreateNoWindow = true };
+ if (windows) foreach (var arg in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File" }) info.ArgumentList.Add(arg);
+ info.ArgumentList.Add(script); info.ArgumentList.Add(parentPid.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ info.ArgumentList.Add(update.SourceDirectory); info.ArgumentList.Add(update.InstallDirectory);
+ info.ArgumentList.Add(Path.Combine(update.WorkingDirectory, "previous")); info.ArgumentList.Add(update.ExecutableRelativePath);
+ info.ArgumentList.Add(Path.Combine(update.WorkingDirectory, "update.log"));
+ return info;
+ }
+
+ private const string UnixScript = """
+ set -eu
+ parent="$1"; stage="$2"; target="$3"; backup="$4"; executable="$5"; log="$6"
+ exec >>"$log" 2>&1
+ count=0
+ while kill -0 "$parent" 2>/dev/null; do
+ count=$((count + 1)); [ "$count" -lt 120 ] || exit 1
+ sleep 1
+ done
+ mkdir -p "$backup"
+ cp -a "$target/." "$backup/"
+ if ! cp -a "$stage/." "$target/"; then cp -a "$backup/." "$target/"; exit 1; fi
+ "$target/$executable" >/dev/null 2>&1 &
+ """;
+ private const string WindowsScript = """
+ param([int]$parentPid, [string]$stage, [string]$target, [string]$backup, [string]$executable, [string]$log)
+ $ErrorActionPreference = 'Stop'
+ try {
+ for ($i = 0; $i -lt 120 -and (Get-Process -Id $parentPid -ErrorAction SilentlyContinue); $i++) { Start-Sleep -Seconds 1 }
+ if (Get-Process -Id $parentPid -ErrorAction SilentlyContinue) { throw 'Launcher did not exit.' }
+ New-Item -ItemType Directory -Path $backup -Force | Out-Null
+ Get-ChildItem -LiteralPath $target -Force | Copy-Item -Destination $backup -Recurse -Force
+ try { Get-ChildItem -LiteralPath $stage -Force | Copy-Item -Destination $target -Recurse -Force }
+ catch { Get-ChildItem -LiteralPath $backup -Force | Copy-Item -Destination $target -Recurse -Force; throw }
+ Start-Process -FilePath (Join-Path $target $executable)
+ } catch { $_ | Out-File -LiteralPath $log; exit 1 }
+ """;
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/DownloadRoutingHandler.cs b/PCL.Launcher.Core/CrossPlatform/DownloadRoutingHandler.cs
new file mode 100644
index 000000000..796cb01ff
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/DownloadRoutingHandler.cs
@@ -0,0 +1,29 @@
+using System.Net.Http;
+
+namespace PCL.CrossPlatform;
+
+/// Applies an explicitly configured BMCLAPI-compatible mirror to public game downloads only.
+public sealed class DownloadRoutingHandler(Func mirror, HttpMessageHandler inner) : DelegatingHandler(inner)
+{
+ public static Uri Map(Uri source, string? mirror)
+ {
+ if (string.IsNullOrWhiteSpace(mirror)) return source;
+ if (!Uri.TryCreate(mirror.TrimEnd('/') + "/", UriKind.Absolute, out var root) || root.Scheme != "https")
+ throw new ArgumentException("下载镜像必须为 HTTPS 地址。");
+ var prefix = source.Host switch
+ {
+ "piston-meta.mojang.com" or "launchermeta.mojang.com" or "piston-data.mojang.com" or "launcher.mojang.com" => "",
+ "resources.download.minecraft.net" => "/assets",
+ "libraries.minecraft.net" => "/maven",
+ "meta.fabricmc.net" => "/fabric-meta",
+ "meta.legacyfabric.net" => "/legacyfabric-meta",
+ _ => null
+ };
+ return prefix is null ? source : new Uri(root.AbsoluteUri.TrimEnd('/') + prefix + source.PathAndQuery);
+ }
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (request.Method == HttpMethod.Get && request.RequestUri is { } uri) request.RequestUri = Map(uri, mirror());
+ return base.SendAsync(request, cancellationToken);
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/GameInstaller.cs b/PCL.Launcher.Core/CrossPlatform/GameInstaller.cs
new file mode 100644
index 000000000..7cf15173f
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/GameInstaller.cs
@@ -0,0 +1,123 @@
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+
+namespace PCL.CrossPlatform;
+
+public sealed record InstallProgress(string Message, int Completed, int Total);
+public sealed record AvailableVersion(string Id, string Type, string Url, string Sha1)
+{
+ public override string ToString() => $"{Id} · {Type}";
+}
+
+/// Installs Mojang client files with cancellation, hash validation and atomic replacement.
+public sealed class GameInstaller(HttpClient client, RuntimePlatform? platform = null)
+{
+ private readonly RuntimePlatform _platform = platform ?? RuntimePlatform.Current;
+ public const string VersionManifestUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
+
+ public async Task> GetVersionsAsync(CancellationToken token = default)
+ {
+ var json = JsonNode.Parse(await client.GetStringAsync(VersionManifestUrl, token));
+ return json!["versions"]!.AsArray().OfType().Select(v => new AvailableVersion(
+ v["id"]!.ToString(), v["type"]!.ToString(), v["url"]!.ToString(), v["sha1"]!.ToString())).ToArray();
+ }
+
+ public async Task InstallAsync(string root, AvailableVersion version, IProgress? progress = null,
+ CancellationToken token = default)
+ {
+ await DownloadAsync(version.Url, GameManifest.VersionPath(root, version.Id), version.Sha1, null, token);
+ await RepairAsync(root, version.Id, progress, token);
+ }
+
+ public async Task RepairAsync(string root, string id, IProgress? progress = null,
+ CancellationToken token = default)
+ {
+ var manifest = GameManifest.Load(root, id);
+ var files = new List<(string Url, string Path, string? Hash, long? Size)>();
+ void Add(JsonNode? download, string path)
+ {
+ if (download?["url"] is not { } url) return;
+ files.Add((url.ToString(), path, download["sha1"]?.ToString(), download["size"]?.GetValue()));
+ }
+ Add(manifest["downloads"]?["client"], GameManifest.VersionPath(root, manifest["jar"]!.ToString(), ".jar"));
+ foreach (var library in GameManifest.Libraries(manifest, _platform))
+ {
+ if (library["downloads"]?["artifact"] is { } artifact)
+ Add(artifact, GameManifest.SafePath(root, "libraries/" + artifact["path"]));
+ else if (library["downloads"] is null && library["name"] is { } name)
+ {
+ var path = GameManifest.MavenPath(name.ToString());
+ files.Add(((library["url"]?.ToString() ?? "https://libraries.minecraft.net/").TrimEnd('/') + "/" + path,
+ GameManifest.SafePath(root, "libraries/" + path), null, null));
+ }
+ if (_platform.NativeClassifier(library) is { } classifier)
+ {
+ var native = library["downloads"]?["classifiers"]?[classifier]
+ ?? throw new PlatformNotSupportedException($"Missing {classifier} for {library["name"]}");
+ Add(native, GameManifest.SafePath(root, "libraries/" + native["path"]));
+ }
+ }
+ if (manifest["assetIndex"] is { } index)
+ {
+ GameManifest.ValidateId(index["id"]!.ToString());
+ var indexPath = GameManifest.SafePath(root, $"assets/indexes/{index["id"]}.json");
+ await DownloadAsync(index["url"]!.ToString(), indexPath, index["sha1"]?.ToString(), index["size"]?.GetValue(), token);
+ var assets = JsonNode.Parse(await File.ReadAllTextAsync(indexPath, token))!["objects"]!.AsObject();
+ foreach (var (_, asset) in assets)
+ {
+ var hash = asset!["hash"]!.ToString();
+ if (hash.Length != 40 || !hash.All(Uri.IsHexDigit)) throw new InvalidDataException("Invalid asset hash.");
+ var path = $"{hash[..2]}/{hash}";
+ files.Add(($"https://resources.download.minecraft.net/{path}",
+ GameManifest.SafePath(root, "assets/objects/" + path), hash, asset["size"]?.GetValue()));
+ }
+ }
+ if (manifest["logging"]?["client"]?["file"] is { } logConfig)
+ {
+ var configId = logConfig["id"]!.ToString();
+ GameManifest.ValidateId(configId);
+ Add(logConfig, GameManifest.SafePath(root, "assets/log_configs/" + configId));
+ }
+ var completed = 0;
+ progress?.Report(new("正在检查与下载游戏文件", 0, files.Count));
+ await Parallel.ForEachAsync(files.DistinctBy(file => file.Path),
+ new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = token }, async (file, ct) =>
+ {
+ await DownloadAsync(file.Url, file.Path, file.Hash, file.Size, ct);
+ progress?.Report(new("正在检查与下载游戏文件", Interlocked.Increment(ref completed), files.Count));
+ });
+ }
+
+ public async Task DownloadAsync(string url, string destination, string? sha1, long? size, CancellationToken token)
+ {
+ if (await IsValidAsync(destination, sha1, size, token)) return;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != "https")
+ throw new InvalidDataException("Game downloads require HTTPS.");
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".part";
+ try
+ {
+ using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token);
+ response.EnsureSuccessStatusCode();
+ await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
+ await response.Content.CopyToAsync(output, token);
+ if (!await IsValidAsync(temporary, sha1, size, token))
+ throw new InvalidDataException($"Downloaded file failed validation: {Path.GetFileName(destination)}");
+ File.Move(temporary, destination, true);
+ }
+ finally
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ }
+
+ private static async Task IsValidAsync(string path, string? sha1, long? size, CancellationToken token)
+ {
+ if (!File.Exists(path) || (size.HasValue && new FileInfo(path).Length != size)) return false;
+ if (sha1 is null) return new FileInfo(path).Length > 0;
+ await using var stream = File.OpenRead(path);
+ var hash = Convert.ToHexString(await SHA1.HashDataAsync(stream, token));
+ return hash.Equals(sha1, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/GameManifest.cs b/PCL.Launcher.Core/CrossPlatform/GameManifest.cs
new file mode 100644
index 000000000..38e8a9341
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/GameManifest.cs
@@ -0,0 +1,94 @@
+using System.Text.Json.Nodes;
+
+namespace PCL.CrossPlatform;
+
+/// Version resolution for the portable host; independent of WPF and global instance state.
+public static class GameManifest
+{
+ public static string SafePath(string root, string relative)
+ {
+ // Mojang paths use '/', regardless of the host. Reject Windows syntax on Unix too.
+ if (string.IsNullOrWhiteSpace(relative) || relative.Contains('\\') || relative.Contains(':') ||
+ Path.IsPathRooted(relative) || relative.Split('/').Any(part => part is ".." or "."))
+ throw new InvalidDataException($"Unsafe relative game path: {relative}");
+ var fullRoot = Path.GetFullPath(root);
+ if (!Path.EndsInDirectorySeparator(fullRoot)) fullRoot += Path.DirectorySeparatorChar;
+ var result = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
+ var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ if (!result.StartsWith(fullRoot, comparison)) throw new InvalidDataException("Path escapes the game directory.");
+ return result;
+ }
+
+ public static string VersionPath(string root, string id, string extension = ".json")
+ {
+ ValidateId(id);
+ return SafePath(root, $"versions/{id}/{id}{extension}");
+ }
+
+ public static void ValidateId(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id) || id.IndexOfAny(['/', '\\', ':']) >= 0 || id is "." or "..")
+ throw new InvalidDataException("Invalid Minecraft version id.");
+ }
+
+ public static JsonObject Load(string root, string id) => Load(root, id, new HashSet(StringComparer.Ordinal));
+
+ private static JsonObject Load(string root, string id, HashSet visited)
+ {
+ if (visited.Count > 32 || !visited.Add(id)) throw new InvalidDataException("Cyclic or excessive version inheritance.");
+ var child = JsonNode.Parse(File.ReadAllText(VersionPath(root, id)))?.AsObject()
+ ?? throw new InvalidDataException("Empty version manifest.");
+ if (child["inheritsFrom"] is not { } inherits)
+ {
+ child["jar"] ??= id;
+ return child;
+ }
+ var parent = Load(root, inherits.ToString(), visited);
+ var merged = parent.DeepClone().AsObject();
+ foreach (var (key, value) in child)
+ {
+ if (key is "libraries" or "arguments" or "inheritsFrom") continue;
+ merged[key] = value?.DeepClone();
+ }
+ var libraries = new List();
+ foreach (var library in (parent["libraries"]?.AsArray() ?? []).Concat(child["libraries"]?.AsArray() ?? []))
+ {
+ if (library is null) continue;
+ var name = library["name"]?.ToString().Split(':');
+ if (name is { Length: >= 2 })
+ libraries.RemoveAll(old => LibraryKey(old) == LibraryKey(library));
+ libraries.Add(library.DeepClone());
+ }
+ merged["libraries"] = new JsonArray(libraries.ToArray());
+ var arguments = new JsonObject();
+ foreach (var kind in new[] { "jvm", "game" })
+ {
+ var inherited = parent["arguments"]?[kind]?.AsArray();
+ if (kind == "game" && inherited is null && parent["minecraftArguments"] is { } legacy)
+ inherited = new JsonArray(GameProcess.SplitLegacyArguments(legacy.ToString()).Select(value => (JsonNode?)JsonValue.Create(value)).ToArray());
+ arguments[kind] = new JsonArray((inherited ?? [])
+ .Concat(child["arguments"]?[kind]?.AsArray() ?? []).Select(arg => arg?.DeepClone()).ToArray());
+ }
+ if (parent["arguments"] is not null || child["arguments"] is not null) merged["arguments"] = arguments;
+ return merged;
+ }
+
+ private static string LibraryKey(JsonNode library)
+ {
+ var parts = library["name"]!.ToString().Split(':');
+ return string.Join(':', parts.Take(2)) + (parts.Length > 3 ? ":" + parts[3] : "");
+ }
+
+ public static IEnumerable Libraries(JsonObject manifest, RuntimePlatform platform) =>
+ (manifest["libraries"]?.AsArray() ?? []).OfType()
+ .Where(library => platform.Allows(library["rules"] as JsonArray));
+
+ public static string MavenPath(string coordinate)
+ {
+ var extensionParts = coordinate.Split('@', 2);
+ var parts = extensionParts[0].Split(':');
+ if (parts.Length is < 3 or > 4) throw new InvalidDataException($"Invalid Maven coordinate: {coordinate}");
+ var extension = extensionParts.Length == 2 ? extensionParts[1] : "jar";
+ return $"{parts[0].Replace('.', '/')}/{parts[1]}/{parts[2]}/{parts[1]}-{parts[2]}{(parts.Length == 4 ? "-" + parts[3] : "")}.{extension}";
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/GameProcess.cs b/PCL.Launcher.Core/CrossPlatform/GameProcess.cs
new file mode 100644
index 000000000..964b9652e
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/GameProcess.cs
@@ -0,0 +1,222 @@
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace PCL.CrossPlatform;
+
+public sealed record LaunchAccount(string Name, string Uuid, string AccessToken, string UserType = "msa")
+{
+ public static LaunchAccount Offline(string name)
+ {
+ if (!Regex.IsMatch(name, "^[A-Za-z0-9_]{1,16}$"))
+ throw new ArgumentException("离线用户名须为 1–16 位英文字母、数字或下划线。", nameof(name));
+ var bytes = MD5.HashData(Encoding.UTF8.GetBytes("OfflinePlayer:" + name));
+ bytes[6] = (byte)((bytes[6] & 0x0f) | 0x30);
+ bytes[8] = (byte)((bytes[8] & 0x3f) | 0x80);
+ return new(name, Convert.ToHexString(bytes).ToLowerInvariant(), "0", "legacy");
+ }
+}
+
+/// Builds native process arguments without shell quoting or Windows-only launch wrappers.
+public sealed class GameProcess(RuntimePlatform? platform = null)
+{
+ private readonly RuntimePlatform _platform = platform ?? RuntimePlatform.Current;
+
+ public ProcessStartInfo CreateStartInfo(string root, string id, string java, LaunchAccount account,
+ int memoryMb, string nativesDirectory, LaunchOptions? options = null)
+ {
+ options ??= new LaunchOptions();
+ var gameDirectory = Path.GetFullPath(options.GameDirectory ?? root);
+ Directory.CreateDirectory(gameDirectory);
+ var features = new Dictionary { ["has_custom_resolution"] = options.Width.HasValue && options.Height.HasValue };
+ var quickPlay = !string.IsNullOrWhiteSpace(options.ServerAddress) && manifestQuickPlay(root, id);
+ features["has_quick_playsupport"] = quickPlay; features["is_quick_play_multiplayer"] = quickPlay;
+ if (memoryMb < 256) throw new ArgumentOutOfRangeException(nameof(memoryMb));
+ var manifest = GameManifest.Load(root, id);
+ var classpath = new List();
+ foreach (var library in GameManifest.Libraries(manifest, _platform))
+ {
+ if (library["downloads"]?["artifact"]?["path"] is { } artifact)
+ classpath.Add(GameManifest.SafePath(root, "libraries/" + artifact));
+ else if (library["downloads"] is null && library["name"] is { } name)
+ classpath.Add(GameManifest.SafePath(root, "libraries/" + GameManifest.MavenPath(name.ToString())));
+ }
+ classpath.Add(GameManifest.VersionPath(root, manifest["jar"]!.ToString(), ".jar"));
+ foreach (var file in classpath)
+ if (!File.Exists(file)) throw new FileNotFoundException("游戏依赖缺失,请先修复文件。", file);
+ var assetId = manifest["assetIndex"]?["id"]?.ToString() ?? manifest["assets"]?.ToString() ?? "legacy";
+ GameManifest.ValidateId(assetId);
+ var replacements = new Dictionary
+ {
+ ["auth_player_name"] = account.Name, ["auth_uuid"] = account.Uuid,
+ ["auth_access_token"] = account.AccessToken, ["auth_session"] = "token:" + account.AccessToken + ":" + account.Uuid,
+ ["user_type"] = account.UserType, ["user_properties"] = "{}", ["profile_properties"] = "{}",
+ ["version_name"] = id, ["version_type"] = manifest["type"]?.ToString() ?? "release",
+ ["game_directory"] = gameDirectory, ["assets_root"] = Path.GetFullPath(Path.Combine(root, "assets")),
+ ["assets_index_name"] = assetId, ["game_assets"] = Path.GetFullPath(Path.Combine(root, "assets", "virtual", assetId)),
+ ["natives_directory"] = nativesDirectory, ["library_directory"] = Path.GetFullPath(Path.Combine(root, "libraries")),
+ ["classpath"] = string.Join(_platform.ClasspathSeparator, classpath),
+ ["classpath_separator"] = _platform.ClasspathSeparator.ToString(),
+ ["launcher_name"] = "PCL-CE", ["launcher_version"] = "cross-platform",
+ ["clientid"] = "", ["auth_xuid"] = "",
+ ["resolution_width"] = (options.Width ?? 854).ToString(), ["resolution_height"] = (options.Height ?? 480).ToString(),
+ ["quickPlayMultiplayer"] = options.ServerAddress ?? "", ["quickPlaySingleplayer"] = "", ["quickPlayRealms"] = "",
+ ["quickPlayPath"] = Path.Combine(gameDirectory, "logs", "quickplay.json")
+ };
+ string Expand(string arg) => Regex.Replace(arg, @"\$\{([^}]+)\}", match =>
+ replacements.TryGetValue(match.Groups[1].Value, out var value) ? value
+ : throw new InvalidDataException($"Unsupported game argument: {match.Value}"));
+ var psi = new ProcessStartInfo(java)
+ {
+ UseShellExecute = false, WorkingDirectory = gameDirectory,
+ RedirectStandardOutput = options.CaptureLogs, RedirectStandardError = options.CaptureLogs, CreateNoWindow = true
+ };
+ psi.ArgumentList.Add($"-Xmx{memoryMb}M");
+ var jvm = ExpandArguments(manifest["arguments"]?["jvm"] as JsonArray, features).ToList();
+ if (!jvm.Any(arg => arg.StartsWith("-Djava.library.path=", StringComparison.Ordinal)))
+ jvm.Add("-Djava.library.path=${natives_directory}");
+ if (!jvm.Any(arg => arg is "-cp" or "-classpath" or "--class-path"))
+ jvm.AddRange(["-cp", "${classpath}"]);
+ if (_platform.Os == "osx" && !jvm.Contains("-XstartOnFirstThread")) jvm.Add("-XstartOnFirstThread");
+ foreach (var arg in jvm.Concat(options.JvmArguments)) psi.ArgumentList.Add(Expand(arg));
+ if (manifest["logging"]?["client"] is { } logging)
+ {
+ var configId = logging["file"]?["id"]?.ToString();
+ if (configId is not null && logging["argument"] is { } logArgument)
+ {
+ GameManifest.ValidateId(configId);
+ psi.ArgumentList.Add(logArgument.ToString().Replace("${path}",
+ GameManifest.SafePath(root, "assets/log_configs/" + configId)));
+ }
+ }
+ psi.ArgumentList.Add(manifest["mainClass"]?.ToString() ?? throw new InvalidDataException("Missing mainClass."));
+ var game = manifest["arguments"]?["game"] as JsonArray;
+ foreach (var arg in game is not null && game.Count != 0
+ ? ExpandArguments(game, features) : SplitLegacyArguments(manifest["minecraftArguments"]?.ToString() ?? ""))
+ psi.ArgumentList.Add(Expand(arg));
+ if (options.Width.HasValue && options.Height.HasValue && !psi.ArgumentList.Contains("--width"))
+ { psi.ArgumentList.Add("--width"); psi.ArgumentList.Add(options.Width.Value.ToString()); psi.ArgumentList.Add("--height"); psi.ArgumentList.Add(options.Height.Value.ToString()); }
+ foreach (var arg in options.GameArguments) psi.ArgumentList.Add(Expand(arg));
+ if (!quickPlay && !string.IsNullOrWhiteSpace(options.ServerAddress))
+ {
+ var server = new Uri("minecraft://" + options.ServerAddress);
+ psi.ArgumentList.Add("--server"); psi.ArgumentList.Add(server.Host);
+ psi.ArgumentList.Add("--port"); psi.ArgumentList.Add((server.Port < 0 ? 25565 : server.Port).ToString());
+ }
+ foreach (var (key, value) in options.Environment) psi.Environment[key] = value;
+ return psi;
+ }
+
+ private static bool manifestQuickPlay(string root, string id) =>
+ GameManifest.Load(root, id)["arguments"]?.ToJsonString().Contains("${quickPlayMultiplayer}", StringComparison.Ordinal) == true;
+
+ public Process Start(string root, string id, string java, LaunchAccount account, int memoryMb, Action? log = null, LaunchOptions? options = null)
+ {
+ var manifest = GameManifest.Load(root, id);
+ var nativeDir = Path.Combine(Path.GetTempPath(), "PCLCE", "natives", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(nativeDir);
+ try
+ {
+ ExtractNatives(root, manifest, nativeDir);
+ MaterializeLegacyAssets(root, manifest, options?.GameDirectory ?? root);
+ var process = new Process { StartInfo = CreateStartInfo(root, id, java, account, memoryMb, nativeDir, options), EnableRaisingEvents = true };
+ void LogLine(object sender, DataReceivedEventArgs e)
+ {
+ if (e.Data is { } line)
+ log?.Invoke(account.AccessToken.Length > 1 ? line.Replace(account.AccessToken, "[redacted]") : line);
+ }
+ process.OutputDataReceived += LogLine;
+ process.ErrorDataReceived += LogLine;
+ process.Exited += (_, _) =>
+ {
+ try { Directory.Delete(nativeDir, true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
+ };
+ try
+ {
+ if (!process.Start()) throw new InvalidOperationException("Java process could not be started.");
+ if (options?.CaptureLogs != false)
+ {
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ }
+ return process;
+ }
+ catch { process.Dispose(); throw; }
+ }
+ catch
+ {
+ Directory.Delete(nativeDir, true);
+ throw;
+ }
+ }
+
+ private IEnumerable ExpandArguments(JsonArray? arguments, IReadOnlyDictionary? features = null)
+ {
+ if (arguments is null) yield break;
+ foreach (var argument in arguments)
+ {
+ if (argument is JsonValue value) yield return value.ToString();
+ else if (argument is JsonObject item && _platform.Allows(item["rules"] as JsonArray, features))
+ {
+ if (item["value"] is JsonArray values)
+ {
+ foreach (var entry in values) yield return entry!.ToString();
+ }
+ else if (item["value"] is { } scalar) yield return scalar.ToString();
+ }
+ }
+ }
+
+ public static IEnumerable SplitLegacyArguments(string arguments)
+ {
+ var matches = Regex.Matches(arguments, "\"([^\"]*)\"|'([^']*)'|([^\\s]+)");
+ foreach (Match match in matches)
+ yield return match.Groups[1].Success ? match.Groups[1].Value
+ : match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Value;
+ }
+
+ private void ExtractNatives(string root, JsonObject manifest, string destination)
+ {
+ foreach (var library in GameManifest.Libraries(manifest, _platform))
+ {
+ if (_platform.NativeClassifier(library) is not { } classifier) continue;
+ var relative = library["downloads"]?["classifiers"]?[classifier]?["path"]?.ToString()
+ ?? throw new InvalidDataException($"Missing native classifier: {classifier}");
+ var jar = GameManifest.SafePath(root, "libraries/" + relative);
+ using var archive = ZipFile.OpenRead(jar);
+ var excludes = library["extract"]?["exclude"]?.AsArray().Select(item => item!.ToString()).ToArray() ?? ["META-INF/"];
+ foreach (var entry in archive.Entries)
+ {
+ if (entry.FullName.EndsWith('/') || excludes.Any(prefix => entry.FullName.StartsWith(prefix, StringComparison.Ordinal))) continue;
+ var path = GameManifest.SafePath(destination, entry.FullName);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ entry.ExtractToFile(path, true);
+ }
+ }
+ }
+
+ private static void MaterializeLegacyAssets(string root, JsonObject manifest, string gameDirectory)
+ {
+ if (manifest["assetIndex"]?["id"] is not { } id) return;
+ var index = JsonNode.Parse(File.ReadAllText(GameManifest.SafePath(root, $"assets/indexes/{id}.json")))!;
+ var virtualAssets = index["virtual"]?.GetValue() == true;
+ var mapToResources = index["map_to_resources"]?.GetValue() == true;
+ if (!virtualAssets && !mapToResources) return;
+ foreach (var (name, asset) in index["objects"]!.AsObject())
+ {
+ var hash = asset!["hash"]!.ToString();
+ if (hash.Length != 40 || !hash.All(Uri.IsHexDigit)) throw new InvalidDataException("Invalid asset hash.");
+ var source = GameManifest.SafePath(root, $"assets/objects/{hash[..2]}/{hash}");
+ foreach (var folder in new[] { virtualAssets ? $"assets/virtual/{id}" : null, mapToResources ? "resources" : null })
+ {
+ if (folder is null) continue;
+ var target = GameManifest.SafePath(folder == "resources" ? gameDirectory : root, folder + "/" + name);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ File.Copy(source, target, true);
+ }
+ }
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/InstanceFiles.cs b/PCL.Launcher.Core/CrossPlatform/InstanceFiles.cs
new file mode 100644
index 000000000..b3444ef37
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/InstanceFiles.cs
@@ -0,0 +1,136 @@
+using System.IO.Compression;
+using System.Text.Json.Nodes;
+
+namespace PCL.CrossPlatform;
+
+public sealed record InstanceFile(string Path, string Name, long Size, bool Enabled)
+{
+ public override string ToString() => $"{(Enabled ? "✓" : "○")} {Name} · {Size / 1024:N0} KiB";
+}
+
+/// Local instance operations. Replacements and removals retain recoverable copies.
+public static class InstanceFiles
+{
+ public static string DataDirectory(string root, string id, bool isolated)
+ {
+ GameManifest.ValidateId(id);
+ return isolated ? GameManifest.SafePath(root, "instances/" + id) : Path.GetFullPath(root);
+ }
+
+ public static IReadOnlyList List(string directory)
+ {
+ if (!Directory.Exists(directory)) return [];
+ return Directory.EnumerateFiles(directory).Select(path => new FileInfo(path))
+ .Where(file => (file.Attributes & FileAttributes.ReparsePoint) == 0)
+ .OrderBy(file => file.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(file => new InstanceFile(file.FullName, file.Name, file.Length, !file.Name.EndsWith(".disabled", StringComparison.OrdinalIgnoreCase))).ToArray();
+ }
+
+ public static string Toggle(string file)
+ {
+ if (!File.Exists(file)) throw new FileNotFoundException("文件不存在。", file);
+ var target = file.EndsWith(".disabled", StringComparison.OrdinalIgnoreCase) ? file[..^9] : file + ".disabled";
+ File.Move(file, target, false);
+ return target;
+ }
+
+ public static string Archive(string path, string backupRoot)
+ {
+ Directory.CreateDirectory(backupRoot);
+ var destination = Path.Combine(backupRoot, DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Guid.NewGuid().ToString("N")[..8] + "-" + Path.GetFileName(path));
+ if (Directory.Exists(path)) Directory.Move(path, destination);
+ else File.Move(path, destination);
+ return destination;
+ }
+
+ public static async Task ImportFileAsync(string source, string directory, CancellationToken token = default)
+ {
+ Directory.CreateDirectory(directory);
+ var destination = GameManifest.SafePath(directory, Path.GetFileName(source));
+ if (File.Exists(destination)) throw new IOException("同名文件已存在,请先移除旧文件或更改文件名。");
+ var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".part";
+ try
+ {
+ await using (var input = File.OpenRead(source))
+ await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
+ await input.CopyToAsync(output, token);
+ File.Move(temporary, destination, false);
+ }
+ finally { if (File.Exists(temporary)) File.Delete(temporary); }
+ }
+
+ public static async Task ExtractAsync(string archivePath, string destination, string prefix = "", CancellationToken token = default)
+ {
+ using var archive = ZipFile.OpenRead(archivePath);
+ foreach (var entry in archive.Entries)
+ {
+ token.ThrowIfCancellationRequested();
+ // Validate even excluded entries. ZIP Unix symlinks are never materialized.
+ GameManifest.SafePath(destination, entry.FullName.TrimEnd('/'));
+ if (((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000) throw new InvalidDataException("压缩包包含符号链接。");
+ if (!entry.FullName.StartsWith(prefix, StringComparison.Ordinal) || entry.FullName.EndsWith('/')) continue;
+ var relative = entry.FullName[prefix.Length..];
+ if (relative.Length == 0) continue;
+ var target = GameManifest.SafePath(destination, relative);
+ EnsureNoLinks(destination, target);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ await using var input = entry.Open();
+ await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true);
+ await input.CopyToAsync(output, token);
+ }
+ }
+
+ public static async Task ExportAsync(string source, string destination, CancellationToken token = default)
+ {
+ var files = EnumerateSafeFiles(source).ToArray();
+ if (Path.GetFullPath(destination).StartsWith(Path.GetFullPath(source) + Path.DirectorySeparatorChar, PathComparison))
+ throw new InvalidOperationException("导出目标不能位于被导出的目录中。");
+ var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".part";
+ try
+ {
+ using (var archive = ZipFile.Open(temporary, ZipArchiveMode.Create))
+ foreach (var file in files)
+ {
+ token.ThrowIfCancellationRequested();
+ var relative = Path.GetRelativePath(source, file).Replace(Path.DirectorySeparatorChar, '/');
+ // Launcher credentials, keys and runtime locks are never exported with a game instance.
+ if (relative.Split('/').Any(p => p is "PCL" or ".pcl-trash") || relative.EndsWith(".lock") ||
+ Path.GetFileName(relative) is "launcher_accounts.json" or "launcher_profiles.json" or "accounts.json" or "avalonia.settings.json" ||
+ Path.GetFileName(relative).StartsWith("UserKey", StringComparison.OrdinalIgnoreCase)) continue;
+ var entry = archive.CreateEntry(relative, CompressionLevel.Fastest);
+ await using var input = File.OpenRead(file);
+ await using var output = entry.Open();
+ await input.CopyToAsync(output, token);
+ }
+ File.Move(temporary, destination, false);
+ }
+ finally { if (File.Exists(temporary)) File.Delete(temporary); }
+ }
+
+ public static IEnumerable EnumerateSafeFiles(string directory)
+ {
+ if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) throw new InvalidDataException("不支持导出链接目录。");
+ foreach (var item in Directory.EnumerateFileSystemEntries(directory))
+ {
+ var attributes = File.GetAttributes(item);
+ if ((attributes & FileAttributes.ReparsePoint) != 0) throw new InvalidDataException("实例目录包含符号链接,操作已停止。");
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ foreach (var file in EnumerateSafeFiles(item)) yield return file;
+ }
+ else yield return item;
+ }
+ }
+
+ internal static void EnsureNoLinks(string root, string destination)
+ {
+ var current = Path.GetFullPath(root);
+ foreach (var component in Path.GetRelativePath(root, destination).Split(Path.DirectorySeparatorChar))
+ {
+ current = Path.Combine(current, component);
+ if ((File.Exists(current) || Directory.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
+ throw new InvalidDataException("目标路径包含符号链接。");
+ }
+ }
+ private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/JavaRuntimeInstaller.cs b/PCL.Launcher.Core/CrossPlatform/JavaRuntimeInstaller.cs
new file mode 100644
index 000000000..1024c58de
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/JavaRuntimeInstaller.cs
@@ -0,0 +1,86 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+
+namespace PCL.CrossPlatform;
+
+public sealed class JavaRuntimeInstaller(HttpClient client)
+{
+ public async Task InstallAsync(int major, string parent, IProgress? progress, CancellationToken token)
+ {
+ var os = OperatingSystem.IsWindows() ? "windows" : OperatingSystem.IsMacOS() ? "mac" : "linux";
+ var architecture = RuntimePlatform.Current.Architecture == "aarch64" ? "aarch64" : "x64";
+ var json = JsonNode.Parse(await client.GetStringAsync($"https://api.adoptium.net/v3/assets/latest/{major}/hotspot?architecture={architecture}&image_type=jdk&os={os}&vendor=eclipse", token))!.AsArray();
+ var release = json.FirstOrDefault() ?? throw new InvalidOperationException("没有找到适合当前系统的 Java。");
+ var package = release["binary"]!["package"]!;
+ var checksum = package["checksum"]!.ToString();
+ if (checksum.Length != 64 || !checksum.All(Uri.IsHexDigit)) throw new InvalidDataException("Java 安装包校验格式无效。");
+ var target = Path.Combine(parent, "temurin-" + major + "-" + checksum[..12]);
+ if (Directory.Exists(target)) return FindJava(target);
+ Directory.CreateDirectory(parent);
+ var stage = target + ".stage-" + Guid.NewGuid().ToString("N"); Directory.CreateDirectory(stage);
+ var archive = stage + (os == "windows" ? ".zip" : ".tar.gz");
+ try
+ {
+ progress?.Report(new("正在下载 Java", 0, 1));
+ await new GameInstaller(client).DownloadAsync(package["link"]!.ToString(), archive, null, package["size"]?.GetValue(), token);
+ await using (var input = File.OpenRead(archive))
+ {
+ var actual = Convert.ToHexString(await SHA256.HashDataAsync(input, token));
+ if (!actual.Equals(checksum, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Java 安装包校验失败。");
+ }
+ progress?.Report(new("正在解压 Java", 0, 1));
+ if (os == "windows") await InstanceFiles.ExtractAsync(archive, stage, token: token);
+ else
+ {
+ await using var file = File.OpenRead(archive); await using var gzip = new GZipStream(file, CompressionMode.Decompress);
+ using var tar = new TarReader(gzip);
+ var links = new List<(string Path, string Target)>();
+ while (await tar.GetNextEntryAsync(copyData: false, cancellationToken: token) is { } entry)
+ {
+ var relative = entry.Name.TrimEnd('/');
+ if (relative.StartsWith("./", StringComparison.Ordinal)) relative = relative[2..];
+ if (relative.Length == 0 || relative == ".") continue;
+ var path = GameManifest.SafePath(stage, relative); InstanceFiles.EnsureNoLinks(stage, path);
+ if (entry.EntryType == TarEntryType.Directory) { Directory.CreateDirectory(path); continue; }
+ if (entry.EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink)
+ {
+ var link = entry.LinkName.Replace('\\', '/');
+ if (Path.IsPathRooted(link) || link.Contains(':')) throw new InvalidDataException("Java 安装包链接越界。");
+ var basis = entry.EntryType == TarEntryType.HardLink ? stage : Path.GetDirectoryName(path)!;
+ var targetPath = Path.GetFullPath(Path.Combine(basis, link));
+ _ = GameManifest.SafePath(stage, Path.GetRelativePath(stage, targetPath));
+ links.Add((path, targetPath)); continue;
+ }
+ if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile)) throw new InvalidDataException("Java 安装包包含不支持的设备文件。");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await using (var output = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
+ if (entry.DataStream is not null) await entry.DataStream.CopyToAsync(output, token);
+ File.SetUnixFileMode(path, entry.Mode & (UnixFileMode)0x1FF);
+ }
+ // Materialize internal file aliases after extraction; never create an archive-controlled symlink.
+ while (links.Count != 0)
+ {
+ var resolved = false;
+ for (var i = links.Count - 1; i >= 0; i--)
+ {
+ var link = links[i]; if (!File.Exists(link.Target)) continue;
+ InstanceFiles.EnsureNoLinks(stage, link.Target); InstanceFiles.EnsureNoLinks(stage, link.Path);
+ Directory.CreateDirectory(Path.GetDirectoryName(link.Path)!);
+ File.Copy(link.Target, link.Path, false);
+ File.SetUnixFileMode(link.Path, File.GetUnixFileMode(link.Target));
+ links.RemoveAt(i); resolved = true;
+ }
+ if (!resolved) throw new InvalidDataException("Java 安装包包含循环、缺失或目录链接。");
+ }
+ }
+ _ = FindJava(stage); token.ThrowIfCancellationRequested(); Directory.Move(stage, target);
+ return FindJava(target);
+ }
+ finally { if (File.Exists(archive)) File.Delete(archive); if (Directory.Exists(stage)) Directory.Delete(stage, true); }
+ }
+ private static string FindJava(string directory) => Directory.EnumerateFiles(directory, OperatingSystem.IsWindows() ? "java.exe" : "java", SearchOption.AllDirectories)
+ .FirstOrDefault(path => Path.GetFileName(Path.GetDirectoryName(path)) == "bin") ?? throw new InvalidDataException("Java 安装包缺少可执行文件。");
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/LaunchOptions.cs b/PCL.Launcher.Core/CrossPlatform/LaunchOptions.cs
new file mode 100644
index 000000000..eb5fdbda6
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/LaunchOptions.cs
@@ -0,0 +1,13 @@
+namespace PCL.CrossPlatform;
+
+public sealed record LaunchOptions
+{
+ public bool CaptureLogs { get; init; } = true;
+ public string? GameDirectory { get; init; }
+ public IReadOnlyList JvmArguments { get; init; } = [];
+ public IReadOnlyList GameArguments { get; init; } = [];
+ public IReadOnlyDictionary Environment { get; init; } = new Dictionary();
+ public int? Width { get; init; }
+ public int? Height { get; init; }
+ public string? ServerAddress { get; init; }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/LoaderInstaller.cs b/PCL.Launcher.Core/CrossPlatform/LoaderInstaller.cs
new file mode 100644
index 000000000..709a8b683
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/LoaderInstaller.cs
@@ -0,0 +1,237 @@
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Reflection;
+using System.Text.Json.Nodes;
+using System.Xml.Linq;
+using System.Text.RegularExpressions;
+using System.Net;
+
+namespace PCL.CrossPlatform;
+
+public enum LoaderKind { Fabric, LegacyFabric, Forge, NeoForge, Cleanroom, LiteLoader, OptiFine, LabyMod }
+public sealed record LoaderVersion(LoaderKind Kind, string Version, string Minecraft, string Url)
+{
+ public override string ToString() => $"{Kind} {Version}";
+}
+
+public sealed class LoaderInstaller(HttpClient client)
+{
+ public async Task> GetVersionsAsync(LoaderKind kind, string minecraft, CancellationToken token)
+ {
+ GameManifest.ValidateId(minecraft);
+ var game = Uri.EscapeDataString(minecraft);
+ if (kind is LoaderKind.Fabric or LoaderKind.LegacyFabric)
+ {
+ var host = kind == LoaderKind.Fabric ? "https://meta.fabricmc.net" : "https://meta.legacyfabric.net";
+ var json = JsonNode.Parse(await client.GetStringAsync($"{host}/v2/versions/loader/{game}", token))!.AsArray();
+ return json.Select(item => item!["loader"]!["version"]!.ToString()).Distinct()
+ .Select(version => new LoaderVersion(kind, version, minecraft, $"{host}/v2/versions/loader/{game}/{Uri.EscapeDataString(version)}/profile/json")).ToArray();
+ }
+ if (kind is LoaderKind.Forge or LoaderKind.NeoForge)
+ {
+ var maven = kind == LoaderKind.Forge ? "https://maven.minecraftforge.net/net/minecraftforge/forge"
+ : "https://maven.neoforged.net/releases/net/neoforged/neoforge";
+ var metadata = XDocument.Parse(await client.GetStringAsync(maven + "/maven-metadata.xml", token));
+ return metadata.Descendants("version").Select(v => v.Value).Where(version => kind == LoaderKind.Forge
+ ? version.StartsWith(minecraft + "-", StringComparison.Ordinal)
+ : NeoForgeMinecraft(version) == minecraft)
+ .Reverse().Select(version => new LoaderVersion(kind, version, minecraft,
+ $"{maven}/{version}/{(kind == LoaderKind.Forge ? "forge" : "neoforge")}-{version}-installer.jar")).ToArray();
+ }
+ if (kind == LoaderKind.Cleanroom)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/CleanroomMC/Cleanroom/releases");
+ request.Headers.UserAgent.ParseAdd("PCL-CE/1.0");
+ using var response = await client.SendAsync(request, token); response.EnsureSuccessStatusCode();
+ var releases = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsArray();
+ return releases.SelectMany(release => release!["assets"]!.AsArray()
+ .Where(asset => asset!["name"]!.ToString().EndsWith("-installer.jar"))
+ .Select(asset => new LoaderVersion(kind, release["tag_name"]!.ToString(), "1.12.2", asset!["browser_download_url"]!.ToString())))
+ .Where(v => v.Minecraft == minecraft).ToArray();
+ }
+ if (kind == LoaderKind.LiteLoader)
+ {
+ var metadata = JsonNode.Parse(await client.GetStringAsync("https://dl.liteloader.com/versions/versions.json", token))!;
+ var entry = (metadata["versions"]?[minecraft]?["artefacts"] ?? metadata["versions"]?[minecraft]?["snapshots"])?["com.mumfrey:liteloader"]?["latest"];
+ return entry is null ? [] : [new(kind, entry["version"]!.ToString(), minecraft, "https://dl.liteloader.com/versions/versions.json")];
+ }
+ if (kind == LoaderKind.OptiFine)
+ {
+ var html = await client.GetStringAsync("https://optifine.net/downloads", token);
+ return Regex.Matches(html, @"adloadx?\?f=(OptiFine_[^&""'<>]+\.jar)")
+ .Select(match => WebUtility.HtmlDecode(match.Groups[1].Value)).Distinct()
+ .Where(name => name.StartsWith("OptiFine_" + minecraft + "_", StringComparison.Ordinal))
+ .Select(name => new LoaderVersion(kind, name[..^4], minecraft, "https://optifine.net/adloadx?f=" + Uri.EscapeDataString(name))).ToArray();
+ }
+ if (kind == LoaderKind.LabyMod)
+ {
+ var manifest = JsonNode.Parse(await client.GetStringAsync("https://releases.r2.labymod.net/api/v1/manifest/production/latest.json", token))!;
+ var commit = manifest["commitReference"]?.ToString() ?? manifest["commit_reference"]?.ToString()
+ ?? throw new InvalidDataException("LabyMod 未返回版本标识。");
+ var supported = manifest["minecraftVersions"]?.AsArray().FirstOrDefault(v => v?["version"]?.ToString() == minecraft);
+ if (supported is null) return [];
+ var url = supported["customManifestUrl"]?.ToString()
+ ?? $"https://releases.r2.labymod.net/api/v1/download/manifest/labymod4/production/{game}/{commit}.json";
+ return [new(kind, manifest["labyModVersion"]?.ToString() ?? commit, minecraft, url)];
+ }
+ throw new InvalidOperationException("请选择已下载的安装器进行本地安装。");
+ }
+
+ internal static string NeoForgeMinecraft(string version)
+ {
+ var parts = version.Split('.');
+ if (parts.Length < 2) return "";
+ return "1." + parts[0] + (parts[1] == "0" ? "" : "." + parts[1]);
+ }
+
+ public async Task InstallAsync(string root, string java, LoaderVersion version, IProgress? progress, CancellationToken token)
+ {
+ var installer = new GameInstaller(client);
+ if (!File.Exists(GameManifest.VersionPath(root, version.Minecraft)))
+ {
+ var baseVersion = (await installer.GetVersionsAsync(token)).FirstOrDefault(v => v.Id == version.Minecraft)
+ ?? throw new InvalidOperationException("未找到对应的 Minecraft 版本。");
+ await installer.InstallAsync(root, baseVersion, progress, token);
+ }
+ if (version.Kind is LoaderKind.Fabric or LoaderKind.LegacyFabric or LoaderKind.LabyMod)
+ {
+ var json = JsonNode.Parse(await client.GetStringAsync(version.Url, token))!.AsObject();
+ var id = json["id"]!.ToString(); GameManifest.ValidateId(id);
+ var path = GameManifest.VersionPath(root, id);
+ if (File.Exists(path)) throw new IOException("该加载器版本已经安装。");
+ json["inheritsFrom"] ??= version.Minecraft;
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await File.WriteAllTextAsync(path, json.ToJsonString(), token);
+ try { await installer.RepairAsync(root, id, progress, token); }
+ catch { Directory.Delete(Path.GetDirectoryName(path)!, true); throw; }
+ return id;
+ }
+ if (version.Kind == LoaderKind.LiteLoader)
+ {
+ var metadata = JsonNode.Parse(await client.GetStringAsync(version.Url, token))!;
+ var entry = (metadata["versions"]?[version.Minecraft]?["artefacts"] ?? metadata["versions"]?[version.Minecraft]?["snapshots"])!["com.mumfrey:liteloader"]!["latest"]!;
+ var id = "LiteLoader-" + version.Minecraft;
+ var profile = new JsonObject { ["id"] = id, ["inheritsFrom"] = version.Minecraft, ["mainClass"] = "net.minecraft.launchwrapper.Launch",
+ ["libraries"] = entry["libraries"]?.DeepClone() ?? new JsonArray(), ["arguments"] = new JsonObject { ["game"] = new JsonArray("--tweakClass", entry["tweakClass"]!.ToString()) } };
+ profile["libraries"]!.AsArray().Add(new JsonObject { ["name"] = "com.mumfrey:liteloader:" + entry["version"], ["url"] = "https://dl.liteloader.com/versions/" });
+ var path = GameManifest.VersionPath(root, id); if (File.Exists(path)) throw new IOException("该版本已经安装。");
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!); await File.WriteAllTextAsync(path, profile.ToJsonString(), token);
+ try { await installer.RepairAsync(root, id, progress, token); return id; } catch { Directory.Delete(Path.GetDirectoryName(path)!, true); throw; }
+ }
+ var cache = Path.Combine(Path.GetTempPath(), "PCLCE", "installer-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(cache);
+ try
+ {
+ var jar = Path.Combine(cache, "installer.jar");
+ var downloadUrl = version.Url;
+ if (version.Kind == LoaderKind.OptiFine)
+ {
+ var html = await client.GetStringAsync(version.Url, token);
+ var match = Regex.Match(html, @"downloadx?\?f=[^""'<> ]+");
+ if (!match.Success) throw new InvalidDataException("OptiFine 未返回下载链接。");
+ downloadUrl = "https://optifine.net/" + WebUtility.HtmlDecode(match.Value);
+ }
+ await installer.DownloadAsync(downloadUrl, jar, null, null, token);
+ return await InstallLocalAsync(root, java, jar, version.Minecraft, progress, token);
+ }
+ finally { Directory.Delete(cache, true); }
+ }
+
+ public async Task InstallLocalAsync(string root, string java, string jar, string minecraft,
+ IProgress? progress, CancellationToken token)
+ {
+ using var zip = ZipFile.OpenRead(jar);
+ if (zip.GetEntry("install_profile.json") is { } profileEntry)
+ {
+ using var reader = new StreamReader(profileEntry.Open());
+ var profile = JsonNode.Parse(await reader.ReadToEndAsync(token))!.AsObject();
+ JsonObject version;
+ if (zip.GetEntry("version.json") is { } versionEntry)
+ {
+ using var versionReader = new StreamReader(versionEntry.Open());
+ version = JsonNode.Parse(await versionReader.ReadToEndAsync(token))!.AsObject();
+ }
+ else version = profile["versionInfo"]?.DeepClone().AsObject() ?? throw new InvalidDataException("安装器缺少版本描述。");
+ var id = version["id"]!.ToString(); GameManifest.ValidateId(id);
+ var target = GameManifest.VersionPath(root, id);
+ if (File.Exists(target)) throw new IOException("同名版本已存在。");
+ foreach (var entry in zip.Entries.Where(entry => entry.FullName.StartsWith("maven/", StringComparison.Ordinal) && !entry.FullName.EndsWith('/')))
+ {
+ var path = GameManifest.SafePath(root, "libraries/" + entry.FullName[6..]);
+ InstanceFiles.EnsureNoLinks(root, path);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ if (!File.Exists(path)) entry.ExtractToFile(path);
+ }
+ var downloads = new GameInstaller(client);
+ foreach (var library in (profile["libraries"]?.AsArray() ?? []).OfType())
+ {
+ var artifact = library["downloads"]?["artifact"];
+ if (artifact?["url"]?.ToString() is { Length: > 0 } url)
+ await downloads.DownloadAsync(url, GameManifest.SafePath(root, "libraries/" + artifact["path"]), artifact["sha1"]?.ToString(), artifact["size"]?.GetValue(), token);
+ }
+ var work = Path.Combine(Path.GetTempPath(), "PCLCE", "loader-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(work);
+ try
+ {
+ var helper = Path.Combine(work, "forge-helper.jar");
+ await using (var resource = typeof(LoaderInstaller).Assembly.GetManifestResourceStream("PCL.ForgeInstallerHelper")!)
+ await using (var file = File.Create(helper)) await resource.CopyToAsync(file, token);
+ var profiles = Path.Combine(root, "launcher_profiles.json");
+ if (!File.Exists(profiles)) await File.WriteAllTextAsync(profiles, "{\"profiles\":{}}", token);
+ progress?.Report(new("正在运行加载器安装器", 0, 1));
+ await RunJavaAsync(java, ["-cp", string.Join(Path.PathSeparator, helper, Path.GetFullPath(jar)),
+ "com.bangbang93.ForgeInstaller", Path.GetFullPath(root)], root, token);
+ if (!File.Exists(target))
+ {
+ // Legacy Forge installers carry their complete launch profile in versionInfo.
+ if (profile["versionInfo"] is null) throw new InvalidOperationException("加载器未生成版本文件。");
+ version["inheritsFrom"] ??= minecraft;
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ await File.WriteAllTextAsync(target, version.ToJsonString(), token);
+ }
+ await downloads.RepairAsync(root, id, progress, token);
+ return id;
+ }
+ catch { if (Directory.Exists(Path.GetDirectoryName(target))) Directory.Delete(Path.GetDirectoryName(target)!, true); throw; }
+ finally { Directory.Delete(work, true); }
+ }
+ if (zip.GetEntry("optifine/Installer.class") is not null)
+ {
+ var work = Path.Combine(Path.GetTempPath(), "PCLCE", "optifine-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(work);
+ try
+ {
+ var compiler = Path.Combine(Path.GetDirectoryName(java)!, OperatingSystem.IsWindows() ? "javac.exe" : "javac");
+ if (!File.Exists(compiler)) throw new InvalidOperationException("OptiFine 安装需要 JDK,请在 Java 页面下载对应版本的 JDK。");
+ var source = Path.Combine(work, "PclOptiFineBridge.java");
+ await File.WriteAllTextAsync(source, "public class PclOptiFineBridge { public static void main(String[] args) throws Exception { java.lang.reflect.Method m = Class.forName(\"optifine.Installer\").getDeclaredMethod(\"doInstall\", java.io.File.class); m.setAccessible(true); m.invoke(null, new java.io.File(args[0])); } }", token);
+ var previous = Directory.GetDirectories(Path.Combine(root, "versions")).ToHashSet();
+ await RunJavaAsync(compiler, ["-source", "8", "-target", "8", "-d", work, source], root, token);
+ await RunJavaAsync(java, ["-cp", string.Join(Path.PathSeparator, work, Path.GetFullPath(jar)), "PclOptiFineBridge", Path.GetFullPath(root)], root, token);
+ var added = Directory.GetDirectories(Path.Combine(root, "versions")).FirstOrDefault(path => !previous.Contains(path))
+ ?? throw new InvalidOperationException("OptiFine 没有生成新版本,可能已经安装。");
+ var id = Path.GetFileName(added); await new GameInstaller(client).RepairAsync(root, id, progress, token); return id;
+ }
+ finally { Directory.Delete(work, true); }
+ }
+ throw new InvalidDataException("未识别的加载器安装器。");
+ }
+
+ public static async Task RunJavaAsync(string java, IReadOnlyList arguments, string workingDirectory, CancellationToken token)
+ {
+ var info = new ProcessStartInfo(java) { WorkingDirectory = Path.GetFullPath(workingDirectory), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true };
+ foreach (var argument in arguments) info.ArgumentList.Add(argument);
+ using var process = new Process { StartInfo = info };
+ process.Start();
+ var output = process.StandardOutput.ReadToEndAsync(token);
+ var error = process.StandardError.ReadToEndAsync(token);
+ try
+ {
+ await process.WaitForExitAsync(token);
+ var log = await output; var errors = await error;
+ if (process.ExitCode != 0 || log.TrimEnd().EndsWith("false", StringComparison.Ordinal))
+ throw new InvalidOperationException($"安装器返回 {process.ExitCode}:{errors[^Math.Min(errors.Length, 2000)..]} {log[^Math.Min(log.Length, 2000)..]}");
+ }
+ catch (OperationCanceledException) { if (!process.HasExited) process.Kill(true); await process.WaitForExitAsync(); throw; }
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/ModpackInstaller.cs b/PCL.Launcher.Core/CrossPlatform/ModpackInstaller.cs
new file mode 100644
index 000000000..fdbdd8f72
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/ModpackInstaller.cs
@@ -0,0 +1,188 @@
+using System.IO.Compression;
+using System.Net.Http;
+using System.Text.Json.Nodes;
+
+namespace PCL.CrossPlatform;
+
+public sealed record ModpackInstallResult(string Root, string VersionId);
+
+/// Transactional pack import. Nothing is published until all required downloads and loaders succeed.
+public sealed class ModpackInstaller(HttpClient client, string? curseForgeKey = null)
+{
+ public async Task InstallAsync(string archive, string destination, string java, bool optionalFiles,
+ IProgress? progress, CancellationToken token)
+ {
+ if (File.Exists(destination) || Directory.Exists(destination)) throw new IOException("请选择不存在的新实例目录。");
+ var stage = destination + ".install-" + Guid.NewGuid().ToString("N");
+ Directory.CreateDirectory(stage);
+ try
+ {
+ using var zip = ZipFile.OpenRead(archive);
+ JsonObject? manifest = null;
+ string? minecraft = null;
+ LoaderVersion? loader = null;
+ var resources = new ResourceCatalog(client, curseForgeKey);
+ if (zip.GetEntry("modrinth.index.json") is { } index)
+ {
+ manifest = await ReadJson(index, token);
+ if (manifest["formatVersion"]?.GetValue() != 1 || manifest["game"]?.ToString() != "minecraft") throw new InvalidDataException("不支持此整合包格式版本。");
+ minecraft = manifest["dependencies"]?["minecraft"]?.ToString() ?? throw new InvalidDataException("整合包未指定 Minecraft 版本。");
+ foreach (var file in manifest["files"]!.AsArray().OfType())
+ {
+ var environment = file["env"]?["client"]?.ToString();
+ if (environment == "unsupported" || (environment == "optional" && !optionalFiles)) continue;
+ var path = GameManifest.SafePath(stage, file["path"]!.ToString());
+ var urls = file["downloads"]!.AsArray().Select(item => item!.ToString()).ToArray();
+ Exception? failure = null;
+ foreach (var url in urls)
+ {
+ try { await new GameInstaller(client).DownloadAsync(url, path, file["hashes"]?["sha1"]?.ToString(), file["fileSize"]?.GetValue(), token); failure = null; break; }
+ catch (Exception ex) when (ex is HttpRequestException or IOException) { failure = ex; }
+ }
+ if (urls.Length == 0 || failure is not null) throw new IOException("整合包文件下载失败:" + file["path"], failure);
+ }
+ foreach (var (key, value) in manifest["dependencies"]!.AsObject())
+ {
+ var kind = key switch { "fabric-loader" => LoaderKind.Fabric, "forge" => LoaderKind.Forge, "neoforge" => LoaderKind.NeoForge, _ => (LoaderKind?)null };
+ if (kind is not null) loader = await ResolveLoader(kind.Value, minecraft, value!.ToString(), token);
+ }
+ await ExtractOverrides(archive, stage, "overrides/", token);
+ await ExtractOverrides(archive, stage, "client-overrides/", token);
+ }
+ else if (zip.GetEntry("manifest.json") is { } curseManifest && !await HasMcbbsAddons(curseManifest, token))
+ {
+ manifest = await ReadJson(curseManifest, token);
+ minecraft = manifest["minecraft"]?["version"]?.ToString() ?? throw new InvalidDataException("整合包未指定 Minecraft 版本。");
+ foreach (var file in manifest["files"]!.AsArray().OfType())
+ {
+ if (file["required"]?.GetValue() == false && !optionalFiles) continue;
+ var json = (await resources.GetJsonAsync($"https://api.curseforge.com/v1/mods/{file["projectID"]}/files/{file["fileID"]}", true, token))["data"]!.AsObject();
+ await resources.InstallAsync(ResourceProvider.CurseForge, new(json["id"]!.ToString(), json["displayName"]!.ToString(), json), Path.Combine(stage, "mods"), minecraft, "", token);
+ }
+ var spec = manifest["minecraft"]?["modLoaders"]?.AsArray().OfType().OrderByDescending(item => item["primary"]?.GetValue() == true).FirstOrDefault()?["id"]?.ToString();
+ if (spec is not null)
+ {
+ var parts = spec.Split('-', 2);
+ var kind = parts[0] switch { "forge" => LoaderKind.Forge, "fabric" => LoaderKind.Fabric, "neoforge" => LoaderKind.NeoForge, _ => throw new InvalidDataException("未知加载器:" + spec) };
+ loader = await ResolveLoader(kind, minecraft, parts[1], token);
+ }
+ var overrides = manifest["overrides"]?.ToString() ?? "overrides";
+ GameManifest.ValidateId(overrides);
+ await ExtractOverrides(archive, stage, overrides + "/", token);
+ }
+ else if (zip.Entries.FirstOrDefault(e => e.FullName.EndsWith("mmc-pack.json", StringComparison.Ordinal)) is { } mmc)
+ {
+ manifest = await ReadJson(mmc, token);
+ var prefix = mmc.FullName[..^"mmc-pack.json".Length];
+ var components = manifest["components"]!.AsArray().OfType().ToArray();
+ minecraft = components.First(c => c["uid"]?.ToString() == "net.minecraft")["version"]!.ToString();
+ foreach (var component in components)
+ {
+ var kind = component["uid"]?.ToString() switch
+ {
+ "net.fabricmc.fabric-loader" => LoaderKind.Fabric, "net.minecraftforge" => LoaderKind.Forge,
+ "net.neoforged" => LoaderKind.NeoForge, "com.mumfrey.liteloader" => LoaderKind.LiteLoader,
+ _ => (LoaderKind?)null
+ };
+ if (kind is not null) loader = await ResolveLoader(kind.Value, minecraft, component["version"]!.ToString(), token);
+ else if (component["uid"]?.ToString() is not ("net.minecraft" or "org.lwjgl" or "org.lwjgl3") &&
+ !zip.Entries.Any(e => e.FullName == prefix + "patches/" + component["uid"] + ".json"))
+ throw new InvalidDataException("无法安装 MultiMC 组件:" + component["uid"]);
+ }
+ var gamePrefix = zip.Entries.Any(e => e.FullName.StartsWith(prefix + ".minecraft/", StringComparison.Ordinal)) ? prefix + ".minecraft/" : prefix + "minecraft/";
+ await ExtractOverrides(archive, stage, gamePrefix, token);
+ var patchList = new JsonArray();
+ foreach (var patch in zip.Entries.Where(e => e.FullName.StartsWith(prefix + "patches/", StringComparison.Ordinal) && e.FullName.EndsWith(".json")))
+ patchList.Add(await ReadJson(patch, token));
+ manifest["portablePatches"] = patchList;
+ }
+ else if (zip.Entries.FirstOrDefault(e => e.FullName.EndsWith("mcbbs.packmeta", StringComparison.Ordinal) || e.FullName == "manifest.json") is { } mcbbs)
+ {
+ manifest = await ReadJson(mcbbs, token);
+ var addons = manifest["addons"]!.AsArray().OfType()
+ .ToDictionary(a => a["id"]!.ToString(), a => a["version"]!.ToString());
+ minecraft = addons.GetValueOrDefault("game") ?? throw new InvalidDataException("MCBBS 整合包未指定游戏版本。");
+ foreach (var (key, value) in addons)
+ {
+ var kind = key switch { "forge" => LoaderKind.Forge, "fabric" => LoaderKind.Fabric, "neoforge" => LoaderKind.NeoForge, "optifine" => LoaderKind.OptiFine, _ => (LoaderKind?)null };
+ if (kind is not null) loader = await ResolveLoader(kind.Value, minecraft, value, token);
+ else if (key != "game") throw new InvalidDataException("无法安装 MCBBS 加载器:" + key);
+ }
+ var prefix = mcbbs.FullName[..^Path.GetFileName(mcbbs.FullName).Length];
+ await ExtractOverrides(archive, stage, prefix + "overrides/", token);
+ }
+ else if (zip.Entries.FirstOrDefault(e => e.FullName.EndsWith("modpack.json", StringComparison.Ordinal)) is { } hmcl)
+ {
+ manifest = await ReadJson(hmcl, token);
+ minecraft = manifest["gameVersion"]!.ToString();
+ var prefix = hmcl.FullName[..^"modpack.json".Length];
+ await ExtractOverrides(archive, stage, prefix + "minecraft/", token);
+ }
+ else
+ {
+ // Plain archives carry a complete .minecraft directory or local version JSON.
+ var version = zip.Entries.FirstOrDefault(entry => entry.FullName.Contains("versions/", StringComparison.Ordinal) && entry.FullName.EndsWith(".json", StringComparison.Ordinal));
+ if (version is null) throw new InvalidDataException("未识别整合包结构,缺少版本文件或支持的清单。");
+ var prefix = version.FullName[..version.FullName.IndexOf("versions/", StringComparison.Ordinal)];
+ await InstanceFiles.ExtractAsync(archive, stage, prefix, token);
+ var ids = Directory.GetDirectories(Path.Combine(stage, "versions")).Select(Path.GetFileName).OfType().ToArray();
+ var id = ids.FirstOrDefault(value => File.Exists(GameManifest.VersionPath(stage, value))) ?? throw new InvalidDataException("整合包中没有可用版本。");
+ await new GameInstaller(client).RepairAsync(stage, id, progress, token);
+ Directory.Move(stage, destination); return new(destination, id);
+ }
+ var baseVersion = (await new GameInstaller(client).GetVersionsAsync(token)).First(v => v.Id == minecraft);
+ await new GameInstaller(client).InstallAsync(stage, baseVersion, progress, token);
+ var versionId = loader is null ? minecraft! : await new LoaderInstaller(client).InstallAsync(stage, java, loader, progress, token);
+ if (manifest?["portablePatches"] is JsonArray patches && patches.Count > 0)
+ {
+ var combined = new JsonObject { ["id"] = "pack-custom", ["inheritsFrom"] = versionId, ["libraries"] = new JsonArray(),
+ ["arguments"] = new JsonObject { ["jvm"] = new JsonArray(), ["game"] = new JsonArray() } };
+ foreach (var patch in patches.OfType().OrderBy(p => p["order"]?.GetValue() ?? 0))
+ {
+ foreach (var key in new[] { "mainClass", "assetIndex", "javaVersion", "minecraftArguments" })
+ if (patch[key] is { } value) combined[key] = value.DeepClone();
+ foreach (var key in new[] { "libraries", "+libraries" })
+ foreach (var library in patch[key]?.AsArray() ?? []) combined["libraries"]!.AsArray().Add(library?.DeepClone());
+ foreach (var argument in patch["+jvmArgs"]?.AsArray() ?? []) combined["arguments"]!["jvm"]!.AsArray().Add(argument?.DeepClone());
+ foreach (var tweaker in patch["+tweakers"]?.AsArray() ?? []) { combined["arguments"]!["game"]!.AsArray().Add("--tweakClass"); combined["arguments"]!["game"]!.AsArray().Add(tweaker?.DeepClone()); }
+ }
+ var path = GameManifest.VersionPath(stage, "pack-custom"); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await File.WriteAllTextAsync(path, combined.ToJsonString(), token); versionId = "pack-custom";
+ await new GameInstaller(client).RepairAsync(stage, versionId, progress, token);
+ }
+ token.ThrowIfCancellationRequested(); Directory.Move(stage, destination);
+ return new(destination, versionId);
+ }
+ finally { if (Directory.Exists(stage)) Directory.Delete(stage, true); }
+ }
+
+ private static async Task HasMcbbsAddons(ZipArchiveEntry entry, CancellationToken token) => (await ReadJson(entry, token))["addons"] is not null;
+
+ private async Task ResolveLoader(LoaderKind kind, string minecraft, string version, CancellationToken token)
+ {
+ var versions = await new LoaderInstaller(client).GetVersionsAsync(kind, minecraft, token);
+ return versions.FirstOrDefault(item => item.Version == version || item.Version == minecraft + "-" + version)
+ ?? throw new InvalidOperationException($"未找到整合包指定的 {kind} {version}。");
+ }
+ private static async Task ReadJson(ZipArchiveEntry entry, CancellationToken token)
+ {
+ using var reader = new StreamReader(entry.Open());
+ return JsonNode.Parse(await reader.ReadToEndAsync(token))!.AsObject();
+ }
+ private static async Task ExtractOverrides(string archive, string destination, string prefix, CancellationToken token)
+ {
+ // Overrides may intentionally replace downloaded files; stage them independently before merging.
+ var temporary = destination + ".overrides-" + Guid.NewGuid().ToString("N");
+ Directory.CreateDirectory(temporary);
+ try
+ {
+ await InstanceFiles.ExtractAsync(archive, temporary, prefix, token);
+ foreach (var source in InstanceFiles.EnumerateSafeFiles(temporary))
+ {
+ var target = GameManifest.SafePath(destination, Path.GetRelativePath(temporary, source).Replace(Path.DirectorySeparatorChar, '/'));
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!); File.Move(source, target, true);
+ }
+ }
+ finally { Directory.Delete(temporary, true); }
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/PortableDiagnostics.cs b/PCL.Launcher.Core/CrossPlatform/PortableDiagnostics.cs
new file mode 100644
index 000000000..d2d780db6
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/PortableDiagnostics.cs
@@ -0,0 +1,20 @@
+namespace PCL.CrossPlatform;
+
+public sealed record DiagnosticFinding(string Cause, string Confidence, IReadOnlyList Evidence);
+
+/// Public portable facade over the launcher's existing crash detector.
+public static class PortableDiagnostics
+{
+ public static IReadOnlyList Analyze(string gameLog, string? crashReport = null, string? hsErr = null)
+ {
+ var logs = new CrashLogSet
+ {
+ Game = string.IsNullOrWhiteSpace(gameLog) ? null : new CrashLogText { Kind = CrashLogKind.Game, Text = gameLog },
+ CrashReport = string.IsNullOrWhiteSpace(crashReport) ? null : new CrashLogText { Kind = CrashLogKind.CrashReport, Text = crashReport },
+ HsErr = string.IsNullOrWhiteSpace(hsErr) ? null : new CrashLogText { Kind = CrashLogKind.HsErr, Text = hsErr },
+ All = string.Join('\n', gameLog, crashReport, hsErr)
+ };
+ return new CrashDetector().Analyze(logs, null).Findings
+ .Select(finding => new DiagnosticFinding(finding.Cause.ToString(), finding.Confidence.ToString(), finding.Details)).ToArray();
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/PortableLobby.cs b/PCL.Launcher.Core/CrossPlatform/PortableLobby.cs
new file mode 100644
index 000000000..9d632f4f4
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/PortableLobby.cs
@@ -0,0 +1,80 @@
+using System.Net.Http;
+using PCL.Core.Link.Scaffolding;
+using PCL.Core.Link.Scaffolding.Client.Models;
+using PCL.Core.Link.Scaffolding.Client.Requests;
+using PCL.Core.Link.Scaffolding.EasyTier;
+
+namespace PCL.CrossPlatform;
+
+/// Reuses the launcher's lobby protocol with platform-native EasyTier executables.
+public sealed class PortableLobby : IAsyncDisposable
+{
+ private ScaffoldingServerEntity? _server;
+ private ScaffoldingClientEntity? _client;
+ private volatile string[] _players = [];
+ public IReadOnlyList Players => _players;
+ public bool IsRunning => _server is not null || _client is not null;
+ public string? RoomCode => _server?.EasyTier.Lobby.FullCode;
+
+ public async Task HostAsync(int port, string playerName, CancellationToken token)
+ {
+ await DisposeAsync(); token.ThrowIfCancellationRequested();
+ var server = await Task.Run(() => ScaffoldingFactory.CreateServer(port, playerName), token);
+ server.Server.PlayerProfilePing += profiles => _players = profiles.Select(p => p.Name + " · " + p.Vendor).ToArray();
+ server.Server.ServerStarted += profiles => _players = profiles.Select(p => p.Name + " · " + p.Vendor).ToArray();
+ Exception? startFailure = null;
+ server.Server.ServerException += error => startFailure = error ?? new IOException("大厅监听失败。");
+ try
+ {
+ token.ThrowIfCancellationRequested(); server.Server.Start();
+ if (startFailure is not null) throw new IOException("无法启动大厅监听。", startFailure);
+ _server = server; return server.EasyTier.Lobby.FullCode;
+ }
+ catch { await server.Server.DisposeAsync(); await server.EasyTier.StopAsync(); throw; }
+ }
+ public async Task JoinAsync(string code, string playerName, CancellationToken token)
+ {
+ await DisposeAsync();
+ var client = await ScaffoldingFactory.CreateClientAsync(playerName, code, LobbyType.PCLCE, token);
+ try
+ {
+ client.Client.Heartbeat += (profiles, _) => _players = profiles.Select(p => p.Name + " · " + p.Vendor).ToArray();
+ await client.Client.ConnectAsync(token);
+ var port = await client.Client.SendRequestAsync(new GetServerPortRequest(), token);
+ var localPort = await client.EasyTier.AddPortForwardAsync(client.HostInfo.Ip, port);
+ token.ThrowIfCancellationRequested(); _client = client;
+ return "127.0.0.1:" + localPort;
+ }
+ catch { await client.Client.DisposeAsync(); await client.EasyTier.StopAsync(); throw; }
+ }
+ public async ValueTask DisposeAsync()
+ {
+ _players = [];
+ if (_server is { } server) { _server = null; await server.Server.DisposeAsync(); await server.EasyTier.StopAsync(); }
+ if (_client is { } client) { _client = null; await client.Client.DisposeAsync(); await client.EasyTier.StopAsync(); }
+ }
+
+ public static async Task InstallDependencyAsync(HttpClient client, CancellationToken token)
+ {
+ var folder = EasyTierMetadata.EasyTierFilePath;
+ if (Directory.Exists(folder)) throw new IOException("EasyTier 已存在;可在设置中选择其他可执行文件目录。");
+ var parent = Path.GetDirectoryName(folder)!; Directory.CreateDirectory(parent);
+ var archive = Path.Combine(parent, Guid.NewGuid().ToString("N") + ".zip");
+ var stage = folder + ".stage-" + Guid.NewGuid().ToString("N"); Directory.CreateDirectory(stage);
+ try
+ {
+ var name = $"easytier-{EasyTierMetadata.PlatformName}-{EasyTierMetadata.ArchitectureName}-v{EasyTierMetadata.CurrentEasyTierVer}.zip";
+ await new GameInstaller(client).DownloadAsync($"https://github.com/EasyTier/EasyTier/releases/download/v{EasyTierMetadata.CurrentEasyTierVer}/{name}", archive, null, null, token);
+ await InstanceFiles.ExtractAsync(archive, stage, token: token);
+ var core = Directory.EnumerateFiles(stage, EasyTierMetadata.ExecutableName("easytier-core"), SearchOption.AllDirectories).Single();
+ var source = Path.GetDirectoryName(core)!;
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(core, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ File.SetUnixFileMode(Path.Combine(source, "easytier-cli"), UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+ Directory.Move(source, folder);
+ }
+ finally { if (File.Exists(archive)) File.Delete(archive); if (Directory.Exists(stage)) Directory.Delete(stage, true); }
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/ResourceCatalog.cs b/PCL.Launcher.Core/CrossPlatform/ResourceCatalog.cs
new file mode 100644
index 000000000..6c91b5293
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/ResourceCatalog.cs
@@ -0,0 +1,153 @@
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+
+namespace PCL.CrossPlatform;
+
+public enum ResourceProvider { Modrinth, CurseForge }
+public enum ResourceKind { Mod, Modpack, ResourcePack, Shader }
+public sealed record ResourceProject(ResourceProvider Provider, string Id, string Title, string Description, string WebUrl)
+{
+ public override string ToString() => $"{Title}\n{Description}";
+}
+public sealed record ResourceRelease(string Id, string Name, JsonObject Data)
+{
+ public override string ToString() => Name;
+}
+
+/// Resource search, dependency installation and update lookup for either provider.
+public sealed class ResourceCatalog(HttpClient client, string? curseForgeKey = null)
+{
+ private const string Modrinth = "https://api.modrinth.com/v2/";
+ private const string CurseForge = "https://api.curseforge.com/v1/";
+ private static string Escape(string value) => Uri.EscapeDataString(value);
+
+ public async Task> SearchAsync(ResourceProvider provider, ResourceKind kind, string query,
+ string gameVersion, string loader, CancellationToken token)
+ {
+ if (provider == ResourceProvider.Modrinth)
+ {
+ var type = kind switch { ResourceKind.Mod => "mod", ResourceKind.Modpack => "modpack", ResourceKind.Shader => "shader", _ => "resourcepack" };
+ var facets = new List { new[] { "project_type:" + type } };
+ if (gameVersion.Length != 0) facets.Add(["versions:" + gameVersion]);
+ if (loader.Length != 0 && kind == ResourceKind.Mod) facets.Add(["categories:" + loader]);
+ var result = await GetJsonAsync(Modrinth + "search?limit=30&query=" + Escape(query) + "&facets=" + Escape(System.Text.Json.JsonSerializer.Serialize(facets)), false, token);
+ return result["hits"]!.AsArray().Select(item => new ResourceProject(provider, item!["project_id"]!.ToString(), item["title"]!.ToString(),
+ item["description"]!.ToString(), "https://modrinth.com/" + type + "/" + item["slug"])).ToArray();
+ }
+ var classId = kind switch { ResourceKind.Mod => 6, ResourceKind.Modpack => 4471, ResourceKind.Shader => 6552, _ => 12 };
+ var json = await GetJsonAsync(CurseForge + $"mods/search?gameId=432&classId={classId}&pageSize=30&searchFilter={Escape(query)}&gameVersion={Escape(gameVersion)}", true, token);
+ return json["data"]!.AsArray().Select(item => new ResourceProject(provider, item!["id"]!.ToString(), item["name"]!.ToString(),
+ item["summary"]!.ToString(), item["links"]?["websiteUrl"]?.ToString() ?? "https://www.curseforge.com/minecraft")).ToArray();
+ }
+
+ public async Task> VersionsAsync(ResourceProject project, string gameVersion, string loader, CancellationToken token)
+ {
+ JsonArray versions;
+ if (project.Provider == ResourceProvider.Modrinth)
+ {
+ var url = Modrinth + "project/" + Escape(project.Id) + "/version";
+ var parameters = new List();
+ if (gameVersion.Length != 0) parameters.Add("game_versions=" + Escape(System.Text.Json.JsonSerializer.Serialize(new[] { gameVersion })));
+ if (loader.Length != 0) parameters.Add("loaders=" + Escape(System.Text.Json.JsonSerializer.Serialize(new[] { loader })));
+ versions = (await GetJsonAsync(url + (parameters.Count == 0 ? "" : "?" + string.Join('&', parameters)), false, token)).AsArray();
+ return versions.Select(item => new ResourceRelease(item!["id"]!.ToString(), item["name"]!.ToString(), item.AsObject())).ToArray();
+ }
+ versions = (await GetJsonAsync(CurseForge + $"mods/{Escape(project.Id)}/files?gameVersion={Escape(gameVersion)}&pageSize=50", true, token))["data"]!.AsArray();
+ return versions.Where(item => loader.Length == 0 || item?["gameVersions"]?.AsArray().Any(value => value?.ToString().Equals(loader, StringComparison.OrdinalIgnoreCase) == true) == true)
+ .Select(item => new ResourceRelease(item!["id"]!.ToString(), item["displayName"]!.ToString(), item.AsObject())).ToArray();
+ }
+
+ public async Task> InstallAsync(ResourceProvider provider, ResourceRelease release, string destination,
+ string gameVersion, string loader, CancellationToken token)
+ {
+ var installed = new List();
+ var visited = new HashSet(StringComparer.Ordinal);
+ await InstallRelease(release);
+ return installed;
+
+ async Task InstallRelease(ResourceRelease current)
+ {
+ if (!visited.Add(current.Id)) return;
+ if (visited.Count > 256) throw new InvalidDataException("资源依赖数量过多。");
+ foreach (var dependency in (current.Data["dependencies"]?.AsArray() ?? []).OfType())
+ {
+ if (provider == ResourceProvider.Modrinth)
+ {
+ if (dependency["dependency_type"]?.ToString() != "required") continue;
+ if (dependency["version_id"] is { } versionId)
+ {
+ var value = (await GetJsonAsync(Modrinth + "version/" + Escape(versionId.ToString()), false, token)).AsObject();
+ await InstallRelease(new(value["id"]!.ToString(), value["name"]!.ToString(), value));
+ }
+ else if (dependency["project_id"] is { } projectId)
+ {
+ var versions = await VersionsAsync(new(provider, projectId.ToString(), "", "", ""), gameVersion, loader, token);
+ await InstallRelease(versions.FirstOrDefault() ?? throw new InvalidOperationException("缺少兼容的必需依赖:" + projectId));
+ }
+ }
+ else if (dependency["relationType"]?.GetValue() == 3)
+ {
+ var versions = await VersionsAsync(new(provider, dependency["modId"]!.ToString(), "", "", ""), gameVersion, loader, token);
+ await InstallRelease(versions.FirstOrDefault() ?? throw new InvalidOperationException("缺少兼容依赖。"));
+ }
+ }
+ var files = provider == ResourceProvider.Modrinth ? current.Data["files"]!.AsArray().OfType()
+ .OrderByDescending(file => file["primary"]?.GetValue() == true).Take(1) : new[] { current.Data };
+ foreach (var file in files)
+ {
+ var name = file[provider == ResourceProvider.Modrinth ? "filename" : "fileName"]!.ToString();
+ var target = GameManifest.SafePath(destination, name);
+ var url = file[provider == ResourceProvider.Modrinth ? "url" : "downloadUrl"]?.ToString();
+ if (string.IsNullOrEmpty(url)) throw new InvalidOperationException("作者未允许通过第三方下载此文件,请在项目网页下载后导入。");
+ var hash = provider == ResourceProvider.Modrinth ? file["hashes"]?["sha1"]?.ToString()
+ : file["hashes"]?.AsArray().FirstOrDefault(h => h?["algo"]?.GetValue() == 1)?["value"]?.ToString();
+ var size = file[provider == ResourceProvider.Modrinth ? "size" : "fileLength"]?.GetValue();
+ if (!File.Exists(target)) await new GameInstaller(client).DownloadAsync(url, target, hash, size, token);
+ else
+ {
+ // Verify the replacement before touching an existing resource, retaining a recoverable copy.
+ var staged = target + ".update-" + Guid.NewGuid().ToString("N");
+ string? backup = null;
+ try
+ {
+ await new GameInstaller(client).DownloadAsync(url, staged, hash, size, token);
+ token.ThrowIfCancellationRequested();
+ backup = InstanceFiles.Archive(target, Path.Combine(destination, ".pcl-trash"));
+ try { File.Move(staged, target); }
+ catch { if (!File.Exists(target)) File.Move(backup, target); throw; }
+ }
+ finally { if (File.Exists(staged)) File.Delete(staged); }
+ }
+ installed.Add(target);
+ }
+ }
+ }
+
+ public async Task FindUpdateAsync(string file, string gameVersion, string loader, CancellationToken token)
+ {
+ await using var input = File.OpenRead(file);
+ var hash = Convert.ToHexString(await SHA1.HashDataAsync(input, token)).ToLowerInvariant();
+ using var response = await client.PostAsJsonAsync(Modrinth + "version_file/" + hash + "/update?algorithm=sha1",
+ new { loaders = loader.Length == 0 ? Array.Empty() : new[] { loader }, game_versions = gameVersion.Length == 0 ? Array.Empty() : new[] { gameVersion } }, token);
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
+ response.EnsureSuccessStatusCode();
+ var json = JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject();
+ if (json["files"]!.AsArray().Any(f => string.Equals(f?["hashes"]?["sha1"]?.ToString(), hash, StringComparison.OrdinalIgnoreCase))) return null;
+ return new(json["id"]!.ToString(), json["name"]!.ToString(), json);
+ }
+
+ public async Task GetJsonAsync(string url, bool curseForge, CancellationToken token)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.UserAgent.ParseAdd("PCL-CE/1.0");
+ if (curseForge)
+ {
+ if (string.IsNullOrWhiteSpace(curseForgeKey)) throw new InvalidOperationException("请先在下载设置中填写 CurseForge API 密钥。");
+ request.Headers.Add("x-api-key", curseForgeKey);
+ }
+ using var response = await client.SendAsync(request, token); response.EnsureSuccessStatusCode();
+ return JsonNode.Parse(await response.Content.ReadAsStringAsync(token)) ?? throw new InvalidDataException("资源服务器返回空数据。");
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/RuntimePlatform.cs b/PCL.Launcher.Core/CrossPlatform/RuntimePlatform.cs
new file mode 100644
index 000000000..e4aa03650
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/RuntimePlatform.cs
@@ -0,0 +1,56 @@
+using System.Runtime.InteropServices;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace PCL.CrossPlatform;
+
+/// Mojang platform rules, shared by installation and launch.
+public sealed record RuntimePlatform(string Os, string Architecture, string Version, char ClasspathSeparator)
+{
+ public static RuntimePlatform Current { get; } = new(
+ OperatingSystem.IsWindows() ? "windows" : OperatingSystem.IsMacOS() ? "osx" : "linux",
+ RuntimeInformation.OSArchitecture switch
+ {
+ System.Runtime.InteropServices.Architecture.X64 => "amd64",
+ System.Runtime.InteropServices.Architecture.Arm64 => "aarch64",
+ System.Runtime.InteropServices.Architecture.X86 => "x86",
+ _ => throw new PlatformNotSupportedException("Only x86, x64 and ARM64 desktops are supported.")
+ }, Environment.OSVersion.Version.ToString(), Path.PathSeparator);
+
+ public bool Allows(JsonArray? rules, IReadOnlyDictionary? features = null)
+ {
+ if (rules is null) return true;
+ var allowed = false;
+ foreach (var rule in rules.OfType())
+ {
+ if (rule["os"] is JsonObject os)
+ {
+ if (os["name"] is { } name && name.ToString() != Os) continue;
+ if (os["arch"] is { } arch && !Matches(Architecture, arch.ToString())) continue;
+ if (os["version"] is { } version && !Matches(Version, version.ToString())) continue;
+ }
+ if (rule["features"] is JsonObject required && required.Any(pair =>
+ (features?.GetValueOrDefault(pair.Key) ?? false) != pair.Value!.GetValue())) continue;
+ allowed = rule["action"]?.ToString() == "allow";
+ }
+ return allowed;
+ }
+
+ private static bool Matches(string value, string pattern) =>
+ Regex.IsMatch(value, pattern, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
+
+ public string? NativeClassifier(JsonObject library)
+ {
+ var template = library["natives"]?[Os]?.ToString();
+ if (template is null) return null;
+ var classifier = template.Replace("${arch}", Architecture == "x86" ? "32" : "64");
+ if (Architecture == "aarch64" && library["downloads"]?["classifiers"] is JsonObject classifiers)
+ {
+ foreach (var candidate in new[] { classifier + "-arm64", classifier + "-aarch64" })
+ if (classifiers.ContainsKey(candidate)) return candidate;
+ if (!classifier.Contains("arm64") && !classifier.Contains("aarch64"))
+ throw new PlatformNotSupportedException($"{library["name"]} does not provide {Os} ARM64 natives.");
+ }
+ return classifier;
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/ServerStore.cs b/PCL.Launcher.Core/CrossPlatform/ServerStore.cs
new file mode 100644
index 000000000..a73b637a0
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/ServerStore.cs
@@ -0,0 +1,41 @@
+using fNbt;
+
+namespace PCL.CrossPlatform;
+
+public sealed record SavedServer(string Name, string Address)
+{
+ public override string ToString() => $"{Name} · {Address}";
+}
+
+public static class ServerStore
+{
+ public static IReadOnlyList Read(string file)
+ {
+ if (!File.Exists(file)) return [];
+ var nbt = new NbtFile(file);
+ return (nbt.RootTag.Get("servers") ?? new NbtList("servers", NbtTagType.Compound))
+ .Cast().Select(item => new SavedServer(item.Get("name")?.Value ?? "", item.Get("ip")?.Value ?? "")).ToArray();
+ }
+ public static void Write(string file, IReadOnlyList servers)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(file)!);
+ var nbt = File.Exists(file) ? new NbtFile(file) : new NbtFile(new NbtCompound(""));
+ var existing = nbt.RootTag.Get("servers")?.Cast().ToList() ?? [];
+ var list = new NbtList("servers", NbtTagType.Compound);
+ foreach (var server in servers)
+ {
+ var entry = existing.FirstOrDefault(item => item.Get("ip")?.Value == server.Address)?.Clone() as NbtCompound ?? new NbtCompound();
+ entry["name"] = new NbtString("name", server.Name); entry["ip"] = new NbtString("ip", server.Address);
+ list.Add(entry);
+ }
+ nbt.RootTag["servers"] = list;
+ var temporary = file + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ nbt.SaveToFile(temporary, NbtCompression.None);
+ if (File.Exists(file)) File.Copy(file, file + ".bak", true);
+ File.Move(temporary, file, true);
+ }
+ finally { if (File.Exists(temporary)) File.Delete(temporary); }
+ }
+}
diff --git a/PCL.Launcher.Core/CrossPlatform/SingleInstanceHost.cs b/PCL.Launcher.Core/CrossPlatform/SingleInstanceHost.cs
new file mode 100644
index 000000000..93fad2108
--- /dev/null
+++ b/PCL.Launcher.Core/CrossPlatform/SingleInstanceHost.cs
@@ -0,0 +1,57 @@
+using System.IO.Pipes;
+
+namespace PCL.CrossPlatform;
+
+/// Per-user launcher activation over a current-user-only named pipe.
+public sealed class SingleInstanceHost : IDisposable
+{
+ private readonly Mutex _mutex;
+ private readonly string _name;
+ private readonly CancellationTokenSource _stop = new();
+ private Task? _listener;
+ public bool OwnsInstance { get; }
+ public event Action? ActivateRequested;
+
+ public SingleInstanceHost(string name)
+ {
+ _name = name; _mutex = new Mutex(false, name);
+ try { OwnsInstance = _mutex.WaitOne(0); }
+ catch (AbandonedMutexException) { OwnsInstance = true; }
+ }
+ public void Listen()
+ {
+ if (!OwnsInstance) throw new InvalidOperationException("Only the primary instance can listen.");
+ _listener ??= Task.Run(async () =>
+ {
+ while (!_stop.IsCancellationRequested)
+ {
+ try
+ {
+ await using var pipe = new NamedPipeServerStream(_name, PipeDirection.In, 1, PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ await pipe.WaitForConnectionAsync(_stop.Token);
+ var buffer = new byte[1];
+ if (await pipe.ReadAsync(buffer, _stop.Token) == 1 && buffer[0] == 1) ActivateRequested?.Invoke();
+ }
+ catch (OperationCanceledException) when (_stop.IsCancellationRequested) { break; }
+ catch (IOException) when (!_stop.IsCancellationRequested) { }
+ }
+ });
+ }
+ public async Task ActivatePrimaryAsync(CancellationToken token = default)
+ {
+ await using var pipe = new NamedPipeClientStream(".", _name, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
+ await pipe.ConnectAsync(3000, token); await pipe.WriteAsync(new byte[] { 1 }, token); await pipe.FlushAsync(token);
+ }
+ public void Dispose()
+ {
+ _stop.Cancel();
+ if (_listener is not null)
+ {
+ try { _listener.GetAwaiter().GetResult(); }
+ catch (OperationCanceledException) { }
+ }
+ if (OwnsInstance) _mutex.ReleaseMutex();
+ _mutex.Dispose(); _stop.Dispose();
+ }
+}
diff --git a/PCL.Launcher.Core/PCL.Launcher.Core.csproj b/PCL.Launcher.Core/PCL.Launcher.Core.csproj
index 68749d9dd..f5114954a 100644
--- a/PCL.Launcher.Core/PCL.Launcher.Core.csproj
+++ b/PCL.Launcher.Core/PCL.Launcher.Core.csproj
@@ -7,8 +7,7 @@
PCL Community
PCL.Launcher.Core
PCL
- net10.0-windows
- true
+ net10.0
15.0
enable
enable
@@ -21,6 +20,9 @@
+
+
+
diff --git a/Plain Craft Launcher 2.slnx b/Plain Craft Launcher 2.slnx
index 19c39b473..1e103d29d 100644
--- a/Plain Craft Launcher 2.slnx
+++ b/Plain Craft Launcher 2.slnx
@@ -8,6 +8,10 @@
+
+
+
+
diff --git a/README-EN.md b/README-EN.md
index 6f821b0a5..5c433e691 100644
--- a/README-EN.md
+++ b/README-EN.md
@@ -34,6 +34,10 @@ Everyone is welcome to try it out!
**Developing group:** [599620549](https://qm.qq.com/q/PNQA4Md8wc) | **User group 1:** [1028074835](https://qm.qq.com/q/qrMGJo8BQQ) | **User group 2:** [1070029541](https://qm.qq.com/q/lBywbDMWhG)
+## Cross-platform desktop preview
+
+`PCL.Avalonia` is a native Windows/Linux/macOS preview with game/loader installation, resource and modpack management, worlds, servers, accounts, multiplayer tools, updates and media settings. It is not yet a full replacement for the WPF feature set. See [build instructions and the feature matrix](docs/cross-platform.md). Build with .NET 11 SDK using `PCL.CrossPlatform.slnx`, or run `dotnet run --project PCL.Avalonia/PCL.Avalonia.csproj`.
+
## 💻 Supported Platforms
| Operating System | Support Status | Requirements |
@@ -41,7 +45,8 @@ Everyone is welcome to try it out!
| Windows 10 1809 (17763) or later | ✅ Fully supported | [.NET 10 Desktop Runtime](http://get.dot.net/10) |
| Windows 8 to Windows 10 1809 (17763) | ⚠️ Expected to run; community support offered at discretion | [.NET 10 Desktop Runtime](http://get.dot.net/10) |
| Windows 7 or earlier | ❌ Not supported | N/A |
-| macOS / Linux / Other OS | ⚠️ Cross-platform development only (cross-compilation) | [.NET 11 SDK](http://get.dot.net/11) |
+| macOS / Linux | 🧪 Avalonia desktop preview | .NET 10 Runtime, or self-contained package |
+| Other OS | ❌ No desktop target | N/A |
**✅ Fully supported**: We will provide as much support as possible, but you must ensure you are using the latest version of the launcher.
@@ -49,7 +54,7 @@ Everyone is welcome to try it out!
**❌ Not supported**: PCL CE may not be usable on these platforms and might not even launch at all. Please upgrade your OS to use PCL CE.
-**⚠️ Cross-platform development only (cross-compilation)**: PCL CE’s source code can be built on macOS and Linux, but it cannot run directly. As a developer, you can develop on these platforms and then move the build artifacts to Windows for testing.
+**🧪 Desktop preview**: Use the Avalonia entry point on macOS and Linux. The existing WPF entry point still runs on Windows only. See the feature matrix above for the migration scope.
**Note**:
The community only supports the latest version of the launcher.
diff --git a/README-ZH_TW.md b/README-ZH_TW.md
index 03c9a3eab..6d6c3dc58 100644
--- a/README-ZH_TW.md
+++ b/README-ZH_TW.md
@@ -34,6 +34,10 @@ PCL CE 是基於 PCL 開放原始碼二次開發的社群版本,包括了主
**開發群:** [599620549](https://qm.qq.com/q/PNQA4Md8wc) | **使用者 1 群:** [1028074835](https://qm.qq.com/q/qrMGJo8BQQ) | **使用者 2 群:** [1070029541](https://qm.qq.com/q/lBywbDMWhG)
+## 跨平台桌面預覽
+
+`PCL.Avalonia` 提供 Windows/Linux/macOS 原生桌面預覽,包含原版安裝、Java 偵測、帳戶登入及遊戲啟動。尚未完整取代原 WPF 功能,請參閱[建置說明與功能範圍](docs/cross-platform.md)。使用 .NET 11 SDK 建置 `PCL.CrossPlatform.slnx`,或執行 `dotnet run --project PCL.Avalonia/PCL.Avalonia.csproj`。
+
## 💻 支援平台
| 作業系統 | 支援情況 | 環境要求 |
@@ -41,7 +45,8 @@ PCL CE 是基於 PCL 開放原始碼二次開發的社群版本,包括了主
| Windows 10 1809 (17763) 或更高 | ✅ 完整支援 | [.NET 10 Desktop Runtime](https://get.dot.net/10) |
| Windows 8 - Windows 10 1809 (17763) | ⚠️ 理論能跑,酌情提供社群支援 | [.NET 10 Desktop Runtime](https://get.dot.net/10) |
| Windows 7 或更低版本 | ❌ 不支援 | / |
-| macOS / Linux / 其他作業系統 | ⚠️ 僅跨平台開發支援(交叉編譯)| [.NET 11 SDK](https://get.dot.net/11) |
+| macOS / Linux | 🧪 Avalonia 桌面預覽 | .NET 10 Runtime,或自包含套件 |
+| 其他作業系統 | ❌ 尚無桌面目標 | / |
**✅ 完整支援**:盡可能提供一切相關支援,但必須確保啟動器為最新版本。
@@ -49,7 +54,7 @@ PCL CE 是基於 PCL 開放原始碼二次開發的社群版本,包括了主
**❌ 不支援**:PCL CE 在這些平台的可用性較低,甚至根本打不開。請升級作業系統以使用 PCL CE。
-**⚠️ 僅跨平台開發支援(交叉編譯)**:PCL CE 的原始碼可以在 macOS 與 Linux 平台編譯,但無法直接執行。作為開發者,你可以在這些平台上進行開發,然後將編譯產物轉移到 Windows 系統測試。
+**🧪 桌面預覽**:macOS 和 Linux 可使用 Avalonia 入口,原 WPF 入口仍僅在 Windows 執行。功能遷移範圍請參閱上述說明。
**注**:
社群僅對最新版本的啟動器提供支援。
diff --git a/README.md b/README.md
index 974139de4..f4fcb0a6e 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,8 @@ PCL CE 是基于 PCL 开源代码二次开发的社区版本,包括了主线
| Windows 10 1809 (17763) 或更高 | ✅ 完整支持 | [.NET 10 Desktop Runtime](https://get.dot.net/10) |
| Windows 8 - Windows 10 1809 (17763) | ⚠️ 理论能跑,酌情提供社区支持 | [.NET 10 Desktop Runtime](https://get.dot.net/10) |
| Windows 7 或更低版本 | ❌ 不支持 | / |
-| macOS / Linux / 其他操作系统 | ⚠️ 仅跨平台开发支持(交叉编译) | [.NET 11 SDK](https://get.dot.net/11) |
+| macOS / Linux | 🧪 Avalonia 跨平台预览入口 | .NET 10 Runtime;自包含包无需安装 |
+| 其他操作系统 | ❌ 暂无桌面运行支持 | / |
**✅ 完整支持**:尽可能提供一切相关支持,但必须确保启动器为最新版本。
@@ -42,7 +43,13 @@ PCL CE 是基于 PCL 开源代码二次开发的社区版本,包括了主线
**❌ 不支持**:PCL CE 在这些平台的可用性较低,甚至压根打不开。请升级操作系统以使用 PCL CE。
-**⚠️ 仅跨平台开发支持(交叉编译)**:PCL CE 的源代码可以在 macOS 与 Linux 平台编译,但无法直接运行。作为开发者,你可以在这些平台上进行开发,然后将编译产物转移到 Windows 系统测试。
+**🧪 跨平台预览**:通过 `PCL.Avalonia` 提供安装、资源与整合包、实例管理、多账户、联机和工具页面。兼容范围与外部运行依赖详见 [跨平台构建、使用与功能范围](docs/cross-platform.md)。
+
+```bash
+dotnet run --project PCL.Avalonia/PCL.Avalonia.csproj
+```
+
+开发使用 .NET 11 SDK。跨平台项目可通过 `PCL.CrossPlatform.slnx` 单独构建;Windows WPF 入口继续保留。
**注**:
社区仅对最新版本的启动器提供支持。
diff --git a/docs/cross-platform-features.md b/docs/cross-platform-features.md
new file mode 100644
index 000000000..3c0cb4d97
--- /dev/null
+++ b/docs/cross-platform-features.md
@@ -0,0 +1,48 @@
+# 原版功能迁移对照
+
+本表按原 WPF 页面分组,对应的是可操作的功能入口,而非窗口视觉效果的逐像素复刻。
+跨平台代码在 `PCL.Avalonia/MainWindow.*.cs` 与 `PCL.Launcher.Core/CrossPlatform`;
+底层继续复用原认证、NBT、存档、Mod 元数据、崩溃规则与 Scaffolding 协议。
+
+| 原页面 / 模块 | 新入口 | 实现与验证边界 |
+|---|---|---|
+| PageLaunch、PageSelect | 启动、实例设置 | 原版/继承版本启动、Java、独立数据与参数;快照列表仍在 Minecraft 下载页 |
+| PageDownloadClient | 安装与资源 / Minecraft | Mojang 清单、资源与库、校验修复 |
+| PageDownloadFabric / LegacyFabric | 安装与资源 / 加载器 | Meta 清单与版本配置安装 |
+| PageDownloadForge / NeoForge / Cleanroom | 加载器 | 现有 Forge Java bridge 与安装器;历史版本需继续做真机兼容检查 |
+| PageDownloadOptiFine / LiteLoader / LabyMod | 加载器 | 版本发现与安装;OptiFine 本地桥依赖 JDK 编译工具 |
+| PageDownloadInstall | 加载器 + 实例资源 | 已接入独立加载器/资源流程;复杂加载器组合需按各自兼容条件使用 |
+| PageDownloadCompFavorites | 资源搜索 | Modrinth/CurseForge、版本、依赖与收藏 |
+| PageInstanceCompResource / ModDisabled / JarInJar | 实例管理 / Mod、资源包、光影与投影 | 文件导入、启停、备份、详情、内嵌 Mod、更新 |
+| PageInstanceSetup / Overall / Install | 实例管理 / 设置 | 启动参数、每实例 Java/内存、独立目录、版本副本与备份移除 |
+| PageInstanceExport | 安装与资源 / 整合包 | 安全导出,排除已知凭据与密钥 |
+| Modpack | 整合包 | 各清单格式、overrides、依赖加载器、暂存与回滚 |
+| PageInstanceSaves | 实例管理 / 存档 | 原 SaveManager;修改前完整备份、导入/导出、数据包管理 |
+| PageInstanceServer | 实例管理 / 服务器 | servers.dat 保留未知字段、备份、探测与加入 |
+| PageInstanceScreenshot | 实例管理 / 截图 | 预览、系统打开、移至备份 |
+| PageLoginOffline / Ms / Auth / Profile | 账户 | 多账户、离线、微软、Authlib、Yggdrasil Connect |
+| PageLoginProfileSkin / MySkin | 账户 / 皮肤与披风 | 微软账户的在线外观服务;第三方外观由其服务端提供 |
+| PageSetupJava | 安装与资源 / Java、常规设置 | 检测、手动选择、校验下载与解压 |
+| PageToolsGameLink / PageSetupGameLink | 工具 / 联机 | Scaffolding 大厅、成员列表与 NAT/IPv6 检测;平台原生 EasyTier |
+| PageToolsTest | 服务器状态、崩溃分析 | 服务器探测、EasyTier NAT/IPv6 检测、崩溃诊断 |
+| PageLog / PageSetupLog | 日志、工具 / 崩溃分析 | 输出、筛选、复制、读取、规则分析与报告导出 |
+| PageSpeed | 全局任务状态与取消 | 安装、下载任务提供进度;任务退出等待清理 |
+| PageSetupLaunch / GameManage | 常规与实例设置 | 内存、Java、目录、窗口、参数、环境与副本;启动后保留/隐藏/最小化/退出、异常退出分析 |
+| PageSetupDownloadSource | 设置 / 下载与网络 | 官方/兼容镜像、系统/无/自定义代理、API 密钥 |
+| PageSetupUpdate | 工具 / 更新 | 发布检查、下载、校验、准备、退出替换和重启 |
+| PageSetupUI / Music / VideoBackground | 设置 / 外观与媒体 | 主题色、字体、背景透明度、图片、LibVLC 音视频;系统播放器依赖不内置 |
+| PageSetupLauncherLanguage | 设置 / 语言 | 原七语言字典;新增文本仍有回退 |
+| PageHomepageNewsView | 主页 / Minecraft 新闻 | 原数据模型、可信新闻链接校验 |
+| PageLaunchRight 自定义主页 | 主页 / 自定义主页 | 受限声明式 XAML,含 Grid 行列/跨格、WrapPanel 与滚动容器;不执行代码或 WPF 标记扩展 |
+| PageHelpDetail / SetupAbout / Feedback | 工具 / 帮助与关于 | 文档、来源、许可证与反馈入口 |
+
+## 自动化验证
+
+测试不连接真实账号或远端游戏服务器。下载/加载器/整合包使用内存 HTTP fixtures;
+文件操作使用独立临时目录;页面通过 Avalonia/Skia 无界面运行。
+同时保留 Windows 原测试与 WPF 构建,三系统 CI 检查发布。
+
+不会把“存在页面按钮”等同于完成外部服务验证。尚未实测的历史加载器、媒体库、
+真实皮肤账户操作与异地联机,应在具体环境验收后再标注为真机验证通过。
+
+启动器通过每用户互斥体与当前用户专用管道实现单实例唤醒。IPC 测试在禁止 Unix socket 的容器中明确跳过,由三系统 CI 补充验证。
diff --git a/docs/cross-platform.md b/docs/cross-platform.md
new file mode 100644
index 000000000..9544131c7
--- /dev/null
+++ b/docs/cross-platform.md
@@ -0,0 +1,112 @@
+# 跨平台桌面端(Avalonia 预览)
+
+仓库现在提供两个桌面入口:`PCL.Avalonia` 面向 Windows、Linux 和 macOS;
+`Plain Craft Launcher 2` 保留原有 WPF 桌面端。共享的 `PCL.Core` 与
+`PCL.Launcher.Core` 目标框架改为 `net10.0`,不再引用 WPF。
+WPF 控件、行为及 Windows 桌面入口仍使用 `net10.0-windows`。
+
+**新版入口已接入主要业务页面,框架专属界面与外部服务仍有兼容边界。**
+原启动器中仍有 Windows 专用业务逻辑;Avalonia 入口使用 `PCL.Launcher.Core/CrossPlatform`
+中的独立安装与启动服务,避免调用这些旧流程。现有 Windows 发布流程继续发布 WPF 版本。
+
+## 构建与运行
+
+需要 .NET 11 SDK(仓库现有代码使用 C# 15)和 .NET 10 Runtime。
+自包含发布产物不需要单独安装 .NET。Linux 桌面需要 X11 或 XWayland、fontconfig
+及系统图形依赖。macOS 产物目前没有 Apple 签名或公证。
+
+```bash
+# 仅构建跨平台项目,不加载 WPF
+# 关闭 SDK 和 Avalonia 的构建遥测
+export DOTNET_CLI_TELEMETRY_OPTOUT=1
+export AVALONIA_TELEMETRY_OPTOUT=1
+dotnet build PCL.CrossPlatform.slnx -c Release
+dotnet run --project PCL.Avalonia/PCL.Avalonia.csproj
+
+# 无外网、无显示服务器要求的回归测试
+dotnet test PCL.CrossPlatform.Test/PCL.CrossPlatform.Test.csproj -c CI
+
+# RID 可替换为 win-x64、win-arm64、linux-x64、linux-arm64、osx-x64、osx-arm64
+dotnet publish PCL.Avalonia/PCL.Avalonia.csproj -c Release -r linux-x64 --self-contained true -o artifact/linux-x64
+```
+
+PowerShell 中设置环境变量使用 `$env:DOTNET_CLI_TELEMETRY_OPTOUT='1'` 和
+`$env:AVALONIA_TELEMETRY_OPTOUT='1'`。
+
+CI 在 Windows、Linux 和 macOS 分别运行测试,为六个 RID 输出自包含压缩包。
+macOS 包含 `.app` 目录。交叉发布 ARM64 不代表已经在 ARM64 真机运行过游戏。
+
+## 使用
+
+1. 在“设置”选择 Minecraft 目录、Java 可执行文件及最大内存。默认读取系统惯用的 Minecraft 目录。
+2. 在“下载”获取 Mojang 版本列表并安装原版,或在“启动”刷新已有版本。
+3. 使用离线用户名,或在“账户”使用获准访问 Minecraft API 的 Microsoft OAuth 客户端 ID 登录。
+ 登录复用核心现有的 Microsoft/Xbox/Minecraft 认证模块,不包含任何内置私人客户端凭据。
+4. 选择版本后启动;已有文件缺失或损坏时可使用“检查并修复文件”。游戏日志显示在“日志”。
+ 可设置启动后保留、隐藏、最小化或退出启动器。退出模式不重定向游戏输出;大厅运行时保留启动器以维持联机。其余模式在游戏运行期间阻止手动关闭,退出游戏后恢复窗口。
+
+## 当前覆盖范围
+
+| 功能 | Avalonia 入口 |
+|---|---|
+| 原版安装、文件修复、版本选择 | 已接入 |
+| Fabric / Legacy Fabric | Meta API 版本列表与配置安装 |
+| Forge / NeoForge / Cleanroom | 在线安装器、原有 Java 安装桥与依赖预下载 |
+| OptiFine | 官方页面版本发现、本地安装桥;安装要求 JDK |
+| LiteLoader / LabyMod | 对应版本清单与依赖安装 |
+| Modrinth / CurseForge 资源 | 搜索、版本筛选、必需依赖、收藏;CurseForge 需要 API 密钥并遵守作者下载限制 |
+| 本地 Mod / 资源包 / 光影 / 投影 | 导入、启停、备份、元数据与内嵌 Mod、Modrinth 更新检查 |
+| 整合包 | mrpack、CurseForge、MCBBS、HMCL、MultiMC、普通版本目录压缩包;独立暂存后发布 |
+| 版本隔离与启动设置 | 独立数据目录、每实例 Java/内存覆盖、窗口尺寸、附加 JVM/游戏参数、环境变量、服务器快速加入 |
+| 存档 | 复用原解析器与编辑器,导入/导出、设置修改前备份、所选存档数据包管理 |
+| 服务器 / 截图 | 读写原 servers.dat、状态检查、快速加入;截图预览与备份 |
+| 账户 | 多账户、旧账户存储迁移、微软设备码、第三方密码 / Yggdrasil Connect、令牌刷新 |
+| 皮肤 / 披风 | 微软账户在线读取、上传、重置、披风切换 |
+| Java | 常见目录与 PATH 检测,Adoptium JDK 下载、SHA-256 校验与安全解压 |
+| 联机 | 复用 Scaffolding 大厅协议,EasyTier 按系统下载或选择已有目录,创建 / 加入 / 退出 |
+| 崩溃分析 | 复用原规则,手动日志与最新日志分析、报告导出 |
+| 更新与发布 | 六 RID 发布包、下载校验、退出后替换与重启、保留旧程序;发布包作为 draft release 附件 |
+| 首页 | Minecraft 新闻;可载入受限的声明式自定义 XAML |
+| 外观与媒体 | 明暗主题、主题色、字体、背景透明度与图片、音乐播放与循环/音量、背景视频;需要系统 LibVLC |
+| 本地化 | 读取原有七种语言的字符串字典;新界面未匹配的文本仍回退到中文 |
+
+这些入口已实现,但不能将构建或内存 HTTP fixtures 视为所有历史加载器、真实账户、
+异地联机或每种显卡/媒体后端已经通过真机验证。原 WPF 入口继续保留,迁移对照见
+[功能清单](cross-platform-features.md)。
+
+### 自定义主页兼容范围
+
+主页由受限 XML 控件解析器呈现,不执行 CLR 类型、代码、绑定或任意命令。
+现阶段支持常见文本、卡片、堆叠布局、Grid 行列与跨格、WrapPanel、滚动容器及按钮链接等声明元素;复杂 WPF 专属控件、
+标记扩展与可执行事件不能直接照搬。HTTP(S) 按钮仅在用户点击时打开浏览器。
+
+### 媒体运行依赖
+
+LibVLCSharp 仅在请求播放时加载 native LibVLC,因此缺少播放器不影响启动、下载或测试。
+请安装与系统架构匹配的 VLC / libvlc;也可在外观页指定库目录。Linux 需要 libvlc 和相关
+音视频插件,macOS 可使用 VLC 应用内的 lib 目录,Windows 可选择 VLC 安装目录。
+系统 LibVLC 未打包进入自包含发布包,绑定的 LGPL 声明随应用分发。
+
+## 数据与安全
+
+Avalonia 设置位于 `Paths.SharedData/avalonia.settings.json`,与 WPF 设置分开;
+游戏目录可选择现有目录,文件布局继续遵循 Mojang 约定。
+Linux/macOS 应用数据写入用户配置目录,避免向只读 `.app` 或安装目录写入。
+
+账户数据通过现有 `EncryptHelper` 加密后保存。Windows 保留原 DPAPI v1/v2 兼容及迁移行为。
+Unix 使用独立的 `UserKey.unix.bin`(密钥文件格式 v3),以创建时 `0600` 权限保存 32 字节随机密钥。
+**Unix 密钥文件依赖文件系统访问权限,不具备系统钥匙串的硬件或用户会话保护**;
+备份设置与密钥时应将它们都视为敏感资料。读取时发现密钥对其他用户可读写会报错,并保留原文件。
+Windows 密钥文件不与 Unix 密钥混用。
+
+下载先写入唯一临时文件,验证元数据中的大小与 SHA-1 后原子替换;失败和取消会清理临时文件。
+下载 URL 要求 HTTPS;版本、库与 ZIP 路径拒绝绝对路径及 `..` 跳转。
+启动使用 `ProcessStartInfo.ArgumentList`,不通过 shell 拼接 Java 参数。
+每次游戏启动使用独立本地库目录,游戏退出后清理;日志不会输出启动命令或账户访问令牌。
+
+## 验证边界
+
+测试包含平台规则、架构本地库、版本继承、离线 UUID、参数边界、下载校验与取消、
+安装修复、ZIP 路径越界、Unix 密钥持久化及权限、Unix 子进程启动和 Avalonia 无界面窗口加载。
+下载测试使用内存 HTTP 响应,不下载游戏。无界面窗口测试不能代替桌面真机操作、
+真实 Microsoft 登录或 Minecraft OpenGL 游戏运行测试。