From b97515245124e8d7a53d20107e7477743839a66f Mon Sep 17 00:00:00 2001 From: LinmingXHL Date: Sun, 15 Nov 2026 16:36:02 +0800 Subject: [PATCH 1/6] feat(desktop): add Avalonia cross-platform launcher preview --- .github/workflows/cross-platform.yml | 77 ++++ PCL.Avalonia/App.axaml | 10 + PCL.Avalonia/App.axaml.cs | 23 ++ PCL.Avalonia/AvaloniaUiDispatcher.cs | 27 ++ PCL.Avalonia/DesktopSettings.cs | 56 +++ PCL.Avalonia/Info.plist | 13 + PCL.Avalonia/MainWindow.cs | 347 ++++++++++++++++++ PCL.Avalonia/PCL.Avalonia.csproj | 23 ++ PCL.Avalonia/Program.cs | 12 + PCL.Core.Test/ArchitectureTest.cs | 1 - PCL.Core.Wpf/PCL.Core.Wpf.csproj | 3 + PCL.Core/App/Paths.cs | 4 +- PCL.Core/AssemblyInfo.cs | 3 +- PCL.Core/IO/Net/Http/HttpProxyManager.cs | 15 +- PCL.Core/IO/Net/NetworkService.cs | 9 +- PCL.Core/Logging/LogService.cs | 13 +- PCL.Core/Minecraft/Java/JavaInstallation.cs | 3 +- PCL.Core/Minecraft/Java/JavaManager.cs | 3 +- PCL.Core/Minecraft/Java/JavaService.cs | 4 +- .../Java/Parser/ReleaseFileJavaParser.cs | 55 +++ .../Java/Scanner/DefaultPathsScanner.cs | 2 +- .../Java/Scanner/MicrosoftStoreJavaScanner.cs | 1 + .../Java/Scanner/PathEnvironmentScanner.cs | 4 +- .../Java/Scanner/RegistryJavaScanner.cs | 1 + .../Minecraft/Java/Scanner/UnixJavaScanner.cs | 39 ++ PCL.Core/PCL.Core.csproj | 4 +- PCL.Core/UI/Theme/SystemThemeHelper.cs | 1 + PCL.Core/Utils/OS/KernelInterop.cs | 19 +- PCL.Core/Utils/OS/PortableMemory.cs | 33 ++ PCL.Core/Utils/OS/ProcessInterop.cs | 9 +- PCL.Core/Utils/OS/SystemInfo.cs | 2 +- PCL.Core/Utils/OS/SystemTheme.cs | 1 + PCL.Core/Utils/Secret/EncryptHelper.cs | 25 +- PCL.Core/Utils/Secret/Identify.cs | 3 + PCL.CrossPlatform.Test/DesktopTests.cs | 56 +++ PCL.CrossPlatform.Test/GameTests.cs | 127 +++++++ .../InstallerIntegrationTests.cs | 134 +++++++ .../PCL.CrossPlatform.Test.csproj | 19 + PCL.CrossPlatform.Test/PlatformTests.cs | 95 +++++ PCL.CrossPlatform.Test/SecretTests.cs | 32 ++ PCL.CrossPlatform.slnx | 13 + .../CrossPlatform/GameInstaller.cs | 123 +++++++ .../CrossPlatform/GameManifest.cs | 89 +++++ .../CrossPlatform/GameProcess.cs | 195 ++++++++++ .../CrossPlatform/RuntimePlatform.cs | 56 +++ PCL.Launcher.Core/PCL.Launcher.Core.csproj | 3 +- Plain Craft Launcher 2.slnx | 4 + README-EN.md | 9 +- README-ZH_TW.md | 9 +- README.md | 11 +- docs/cross-platform.md | 88 +++++ 51 files changed, 1862 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/cross-platform.yml create mode 100644 PCL.Avalonia/App.axaml create mode 100644 PCL.Avalonia/App.axaml.cs create mode 100644 PCL.Avalonia/AvaloniaUiDispatcher.cs create mode 100644 PCL.Avalonia/DesktopSettings.cs create mode 100644 PCL.Avalonia/Info.plist create mode 100644 PCL.Avalonia/MainWindow.cs create mode 100644 PCL.Avalonia/PCL.Avalonia.csproj create mode 100644 PCL.Avalonia/Program.cs create mode 100644 PCL.Core/Minecraft/Java/Parser/ReleaseFileJavaParser.cs create mode 100644 PCL.Core/Minecraft/Java/Scanner/UnixJavaScanner.cs create mode 100644 PCL.Core/Utils/OS/PortableMemory.cs create mode 100644 PCL.CrossPlatform.Test/DesktopTests.cs create mode 100644 PCL.CrossPlatform.Test/GameTests.cs create mode 100644 PCL.CrossPlatform.Test/InstallerIntegrationTests.cs create mode 100644 PCL.CrossPlatform.Test/PCL.CrossPlatform.Test.csproj create mode 100644 PCL.CrossPlatform.Test/PlatformTests.cs create mode 100644 PCL.CrossPlatform.Test/SecretTests.cs create mode 100644 PCL.CrossPlatform.slnx create mode 100644 PCL.Launcher.Core/CrossPlatform/GameInstaller.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/GameManifest.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/GameProcess.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/RuntimePlatform.cs create mode 100644 docs/cross-platform.md diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml new file mode 100644 index 000000000..7d44c39b1 --- /dev/null +++ b/.github/workflows/cross-platform.yml @@ -0,0 +1,77 @@ +name: Cross-platform desktop + +on: + 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.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + AVALONIA_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + +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 Release -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/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/DesktopSettings.cs b/PCL.Avalonia/DesktopSettings.cs new file mode 100644 index 000000000..316bf429c --- /dev/null +++ b/PCL.Avalonia/DesktopSettings.cs @@ -0,0 +1,56 @@ +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 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 void StoreAccount(AuthenticationResult result) + { + var profile = new McProfile + { + 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 + }; + EncryptedAccount = EncryptHelper.SecretEncrypt(JsonSerializer.Serialize(profile)); + } + + 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"); + } +} 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/MainWindow.cs b/PCL.Avalonia/MainWindow.cs new file mode 100644 index 000000000..e0c7c3b04 --- /dev/null +++ b/PCL.Avalonia/MainWindow.cs @@ -0,0 +1,347 @@ +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 class MainWindow : Window +{ + private readonly DesktopSettings _settings; + private readonly bool _canSave; + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) }; + 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 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; } + _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.LoadAccount(); } + 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 = LaunchPage() }); + tabs.Items.Add(new TabItem { Header = "下载", Content = DownloadPage() }); + tabs.Items.Add(new TabItem { Header = "账户", Content = AccountPage() }); + tabs.Items.Add(new TabItem { Header = "设置", Content = SettingsPage() }); + tabs.Items.Add(new TabItem { Header = "日志", Content = new ScrollViewer { Content = _log, Margin = new Thickness(6, 16) } }); + 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") }; + layout.Children.Add(header); Grid.SetRow(tabs, 1); layout.Children.Add(tabs); + Grid.SetRow(footer, 2); layout.Children.Add(footer); Content = layout; + Closing += (_, e) => + { + if (_game is { HasExited: false }) + { + e.Cancel = true; Report("游戏正在运行,请先退出游戏或点击“停止游戏”。"); return; + } + _operation?.Cancel(); + }; + Closed += (_, _) => { _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); + }; + } + + 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 = "支持原版安装与标准版本 JSON 的启动。已安装的 Fabric / Forge 版本可尝试启动;整合包安装、联机与旧版自定义主页尚在迁移。", + 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("退出账户", _ => + { + _profile = null; _settings.EncryptedAccount = null; 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); + 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.LoadAccount(); SaveSettings(); RefreshAccount(); + Report("登录成功。"); + } + + private async Task Launch(CancellationToken token) + { + if (_game is { HasExited: false }) throw new InvalidOperationException("游戏已经在运行。"); + var id = SelectedVersion; SaveSettings(); + var javaPath = _java.Text?.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.IsExpired) + { + var refreshed = await new MicrosoftProvider(_clientId.Text).RefreshAsync(profile, token); + _settings.StoreAccount(refreshed); _profile = _settings.LoadAccount(); SaveSettings(); RefreshAccount(); + profile = _profile!; + } + account = new(profile.UserName, profile.Uuid, profile.AccessToken); + } + else account = LaunchAccount.Offline(_offlineName.Text ?? ""); + token.ThrowIfCancellationRequested(); + var root = GameRoot; + var memory = (int)(_memory.Value ?? 2048); + _game = await Task.Run(() => new GameProcess().Start(root, id, javaPath, account, + memory, line => Dispatcher.UIThread.Post(() => AppendLog(line))), token); + _stop.IsEnabled = true; Report($"{id} 已启动。"); + _ = ObserveGame(_game); + } + + private async Task ObserveGame(Process process) + { + await process.WaitForExitAsync(); + await Dispatcher.UIThread.InvokeAsync(() => + { + Report($"游戏已退出,退出码:{process.ExitCode}"); _stop.IsEnabled = false; + _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 void AppendLog(string text) + { + var combined = (_log.Text ?? "") + text + Environment.NewLine; + _log.Text = combined.Length > 40000 ? combined[^30000..] : combined; + _log.CaretIndex = _log.Text.Length; + } + + private async Task RunOperation(Func action) + { + if (_operation is not null) return; + using var source = new CancellationTokenSource(); _operation = source; + 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; _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..90591496f --- /dev/null +++ b/PCL.Avalonia/PCL.Avalonia.csproj @@ -0,0 +1,23 @@ + + + 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..30acefdb7 --- /dev/null +++ b/PCL.Avalonia/Program.cs @@ -0,0 +1,12 @@ +using Avalonia; + +namespace PCL.Avalonia; + +internal static class Program +{ + [STAThread] + public static int Main(string[] args) => 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/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/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..81293cece 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 @@ - 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..40ebd7cd3 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,11 @@ 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)) + using (var fs = new FileStream(tmpFile, new FileStreamOptions + { + Mode = FileMode.CreateNew, Access = FileAccess.ReadWrite, Share = FileShare.None, + UnixCreateMode = OperatingSystem.IsWindows() ? null : UnixFileMode.UserRead | UnixFileMode.UserWrite + })) { 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.CrossPlatform.Test/DesktopTests.cs b/PCL.CrossPlatform.Test/DesktopTests.cs new file mode 100644 index 000000000..63578253d --- /dev/null +++ b/PCL.CrossPlatform.Test/DesktopTests.cs @@ -0,0 +1,56 @@ +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 window = new MainWindow(new DesktopSettings { JavaPath = "/test/java" }); + window.Show(); + Dispatcher.UIThread.RunJobs(); + var tabs = window.GetVisualDescendants().OfType().Single(); + Assert.AreEqual(5, tabs.ItemCount); + for (var i = 0; i < tabs.ItemCount; i++) + { + tabs.SelectedIndex = i; + Dispatcher.UIThread.RunJobs(); + Assert.IsNotNull(tabs.SelectedContent); + 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-{i}.png")); + } + } + 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); + } +} 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/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..0b736c75e --- /dev/null +++ b/PCL.CrossPlatform.Test/PlatformTests.cs @@ -0,0 +1,95 @@ +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 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/SecretTests.cs b/PCL.CrossPlatform.Test/SecretTests.cs new file mode 100644 index 000000000..af0ca57d5 --- /dev/null +++ b/PCL.CrossPlatform.Test/SecretTests.cs @@ -0,0 +1,32 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PCL.Core.Utils.Secret; + +namespace PCL.CrossPlatform.Test; + +[TestClass] +[DoNotParallelize] +public class SecretTests +{ + [TestMethod] + public void UnixSecretsSurviveRestartAndUseOwnerOnlyPermissions() + { + if (OperatingSystem.IsWindows()) Assert.Inconclusive("This test exercises Unix-specific behavior."); + 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.AreEqual(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path)); + EncryptHelper.ResetKeyCache(); + Assert.AreEqual("test refresh token", EncryptHelper.SecretDecrypt(encrypted)); + 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/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..be5f9b1d1 --- /dev/null +++ b/PCL.Launcher.Core/CrossPlatform/GameManifest.cs @@ -0,0 +1,89 @@ +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" }) + arguments[kind] = new JsonArray((parent["arguments"]?[kind]?.AsArray() ?? []) + .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..4fe617395 --- /dev/null +++ b/PCL.Launcher.Core/CrossPlatform/GameProcess.cs @@ -0,0 +1,195 @@ +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) + { + 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"] = Path.GetFullPath(root), ["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"] = "" + }; + 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 = Path.GetFullPath(root), + RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true + }; + psi.ArgumentList.Add($"-Xmx{memoryMb}M"); + var jvm = ExpandArguments(manifest["arguments"]?["jvm"] as JsonArray).ToList(); + if (jvm.Count == 0) + jvm.AddRange(["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]); + if (_platform.Os == "osx" && !jvm.Contains("-XstartOnFirstThread")) jvm.Add("-XstartOnFirstThread"); + foreach (var arg in jvm) 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) : SplitLegacyArguments(manifest["minecraftArguments"]?.ToString() ?? "")) + psi.ArgumentList.Add(Expand(arg)); + return psi; + } + + public Process Start(string root, string id, string java, LaunchAccount account, int memoryMb, Action? log = 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); + var process = new Process { StartInfo = CreateStartInfo(root, id, java, account, memoryMb, nativeDir), 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."); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + return process; + } + catch { process.Dispose(); throw; } + } + catch + { + Directory.Delete(nativeDir, true); + throw; + } + } + + private IEnumerable ExpandArguments(JsonArray? arguments) + { + 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)) + { + 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) + { + 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(root, folder + "/" + name); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(source, target, true); + } + } + } +} 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/PCL.Launcher.Core.csproj b/PCL.Launcher.Core/PCL.Launcher.Core.csproj index 68749d9dd..dea116234 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 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..5bf867c30 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 vanilla installation, Java discovery, account login and game launch. 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..ed06b1596 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` 提供原版安装、版本选择、Java 检测和游戏启动。原 WPF 全部功能尚未迁移,详见 [跨平台构建、使用与功能范围](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.md b/docs/cross-platform.md new file mode 100644 index 000000000..e8e3b6ab0 --- /dev/null +++ b/docs/cross-platform.md @@ -0,0 +1,88 @@ +# 跨平台桌面端(Avalonia 预览) + +仓库现在提供两个桌面入口:`PCL.Avalonia` 面向 Windows、Linux 和 macOS; +`Plain Craft Launcher 2` 保留原有 WPF 桌面端。共享的 `PCL.Core` 与 +`PCL.Launcher.Core` 目标框架改为 `net10.0`,不再引用 WPF。 +WPF 控件、行为及 Windows 桌面入口仍使用 `net10.0-windows`。 + +**这是可运行的跨平台初版,不是原有 WPF 全部功能的等价替换。** +原启动器中仍有 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 入口 | +|---|---| +| 原生桌面窗口、主题、文件选择、状态与取消 | 已实现 | +| 原版版本列表、客户端下载、库与资源校验修复 | 已实现,使用 Mojang 官方地址 | +| 安全解包本地库、版本继承、平台规则 | 已实现 | +| 带空格路径、平台 classpath、macOS 首线程参数 | 已实现 | +| Windows PE Java 检测 | 复用现有解析器 | +| Linux/macOS Java 检测 | 解析 JDK `release`,支持符号链接、PATH、JAVA_HOME、SDKMAN、常见安装目录 | +| 离线账户、微软设备码登录、令牌刷新 | 已接入;微软登录需要可用客户端 ID | +| 已安装的标准 Fabric/Forge 版本 JSON | 支持通用继承和参数;尚未完成各代加载器的实机兼容验证 | +| Forge/Fabric 安装器、整合包导入、Mod 管理 | 尚未移植至新界面 | +| 第三方认证界面、联机、服务器工具 | 尚未移植至新界面 | +| 自定义主页、动画、背景音乐、原窗口视觉样式 | 尚未移植至新界面 | +| 自动下载 Java、自动更新启动器 | 尚未移植至新入口 | +| 旧式只有 Windows 本地库的版本在 Unix/ARM64 运行 | 不承诺兼容;缺少 ARM64 本地库时明确拒绝使用 x64 库 | + +## 数据与安全 + +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 游戏运行测试。 From 4185c0e89b5f8d96485c4fda3d68f074a71ece50 Mon Sep 17 00:00:00 2001 From: LinmingXHL Date: Sun, 15 Nov 2026 16:41:11 +0800 Subject: [PATCH 2/6] fix(storage): apply Unix key permissions only on Unix --- PCL.Core/Utils/Secret/EncryptHelper.cs | 11 +++++++---- PCL.CrossPlatform.Test/SecretTests.cs | 8 +++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/PCL.Core/Utils/Secret/EncryptHelper.cs b/PCL.Core/Utils/Secret/EncryptHelper.cs index 40ebd7cd3..354bef6b6 100644 --- a/PCL.Core/Utils/Secret/EncryptHelper.cs +++ b/PCL.Core/Utils/Secret/EncryptHelper.cs @@ -256,11 +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, new FileStreamOptions + var options = new FileStreamOptions { - Mode = FileMode.CreateNew, Access = FileAccess.ReadWrite, Share = FileShare.None, - UnixCreateMode = OperatingSystem.IsWindows() ? null : UnixFileMode.UserRead | UnixFileMode.UserWrite - })) + 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.CrossPlatform.Test/SecretTests.cs b/PCL.CrossPlatform.Test/SecretTests.cs index af0ca57d5..d3a6e8963 100644 --- a/PCL.CrossPlatform.Test/SecretTests.cs +++ b/PCL.CrossPlatform.Test/SecretTests.cs @@ -8,18 +8,20 @@ namespace PCL.CrossPlatform.Test; public class SecretTests { [TestMethod] - public void UnixSecretsSurviveRestartAndUseOwnerOnlyPermissions() + public void SecretsSurviveRestartAndUnixKeysUseOwnerOnlyPermissions() { - if (OperatingSystem.IsWindows()) Assert.Inconclusive("This test exercises Unix-specific behavior."); 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.AreEqual(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path)); + 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(); From 7edb3b20ae28bbf7f772a745e10d05566ae5fe28 Mon Sep 17 00:00:00 2001 From: LinmingXHL Date: Sun, 15 Nov 2026 19:39:22 +0800 Subject: [PATCH 3/6] feat(desktop): migrate instance, resource, account and launcher tools --- .github/workflows/cross-platform.yml | 11 +- .github/workflows/release-publish.yml | 45 ++ PCL.Avalonia/CustomHomeRenderer.cs | 47 ++ PCL.Avalonia/DesktopLocalization.cs | 36 ++ PCL.Avalonia/DesktopMediaPlayer.cs | 36 ++ PCL.Avalonia/DesktopSettings.cs | 52 +- PCL.Avalonia/Licenses/LGPL-2.1.txt | 501 ++++++++++++++++++ PCL.Avalonia/Licenses/THIRD-PARTY-NOTICES.md | 17 + PCL.Avalonia/MainWindow.Accounts.cs | 164 ++++++ PCL.Avalonia/MainWindow.Installation.cs | 116 ++++ PCL.Avalonia/MainWindow.Instances.cs | 193 +++++++ PCL.Avalonia/MainWindow.Preferences.cs | 140 +++++ PCL.Avalonia/MainWindow.Tools.cs | 113 ++++ PCL.Avalonia/MainWindow.cs | 58 +- PCL.Avalonia/PCL.Avalonia.csproj | 3 + .../Scaffolding/EasyTier/EasyTierEntity.cs | 24 +- .../Scaffolding/EasyTier/EasyTierMetadata.cs | 12 +- PCL.Core/ViewModel/Homepage/NewsViewModel.cs | 2 +- PCL.CrossPlatform.Test/DesktopTests.cs | 12 +- PCL.CrossPlatform.Test/MigrationTests.cs | 133 +++++ .../ResourceAndPackTests.cs | 106 ++++ .../RuntimeAndUpdateTests.cs | 129 +++++ .../CrossPlatform/DesktopUpdater.cs | 123 +++++ .../CrossPlatform/DownloadRoutingHandler.cs | 29 + .../CrossPlatform/GameManifest.cs | 7 +- .../CrossPlatform/GameProcess.cs | 48 +- .../CrossPlatform/InstanceFiles.cs | 136 +++++ .../CrossPlatform/JavaRuntimeInstaller.cs | 60 +++ .../CrossPlatform/LaunchOptions.cs | 12 + .../CrossPlatform/LoaderInstaller.cs | 233 ++++++++ .../CrossPlatform/ModpackInstaller.cs | 188 +++++++ .../CrossPlatform/PortableDiagnostics.cs | 20 + .../CrossPlatform/PortableLobby.cs | 67 +++ .../CrossPlatform/ResourceCatalog.cs | 138 +++++ .../CrossPlatform/ServerStore.cs | 41 ++ PCL.Launcher.Core/PCL.Launcher.Core.csproj | 3 + README.md | 2 +- docs/cross-platform-features.md | 46 ++ docs/cross-platform.md | 52 +- 39 files changed, 3085 insertions(+), 70 deletions(-) create mode 100644 PCL.Avalonia/CustomHomeRenderer.cs create mode 100644 PCL.Avalonia/DesktopLocalization.cs create mode 100644 PCL.Avalonia/DesktopMediaPlayer.cs create mode 100644 PCL.Avalonia/Licenses/LGPL-2.1.txt create mode 100644 PCL.Avalonia/Licenses/THIRD-PARTY-NOTICES.md create mode 100644 PCL.Avalonia/MainWindow.Accounts.cs create mode 100644 PCL.Avalonia/MainWindow.Installation.cs create mode 100644 PCL.Avalonia/MainWindow.Instances.cs create mode 100644 PCL.Avalonia/MainWindow.Preferences.cs create mode 100644 PCL.Avalonia/MainWindow.Tools.cs create mode 100644 PCL.CrossPlatform.Test/MigrationTests.cs create mode 100644 PCL.CrossPlatform.Test/ResourceAndPackTests.cs create mode 100644 PCL.CrossPlatform.Test/RuntimeAndUpdateTests.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/DesktopUpdater.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/DownloadRoutingHandler.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/InstanceFiles.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/JavaRuntimeInstaller.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/LaunchOptions.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/LoaderInstaller.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/ModpackInstaller.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/PortableDiagnostics.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/PortableLobby.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/ResourceCatalog.cs create mode 100644 PCL.Launcher.Core/CrossPlatform/ServerStore.cs create mode 100644 docs/cross-platform-features.md diff --git a/.github/workflows/cross-platform.yml b/.github/workflows/cross-platform.yml index 7d44c39b1..6c5fa797f 100644 --- a/.github/workflows/cross-platform.yml +++ b/.github/workflows/cross-platform.yml @@ -1,6 +1,12 @@ name: Cross-platform desktop on: + workflow_call: + inputs: + configuration: + type: string + default: Release + required: false push: branches: [main, dev] paths: &paths @@ -20,13 +26,14 @@ permissions: contents: read concurrency: - group: desktop-${{ github.ref }} + 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: @@ -55,7 +62,7 @@ jobs: shell: bash run: | for rid in ${{ matrix.rids }}; do - dotnet publish PCL.Avalonia/PCL.Avalonia.csproj -c Release -r "$rid" --self-contained true -o "artifact/$rid" + 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" 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/CustomHomeRenderer.cs b/PCL.Avalonia/CustomHomeRenderer.cs new file mode 100644 index 000000000..4f2592c0c --- /dev/null +++ b/PCL.Avalonia/CustomHomeRenderer.cs @@ -0,0 +1,47 @@ +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": case "WrapPanel": case "Grid": + 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 "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": case "ScrollViewer": + var content = element.Elements().FirstOrDefault(); control = new Border { Padding = new Thickness(14), Child = content is null ? null : Build(content, 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..73eb43af6 --- /dev/null +++ b/PCL.Avalonia/DesktopLocalization.cs @@ -0,0 +1,36 @@ +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(); foreach (var (key, value) in chinese) 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 index 316bf429c..9785cb5c8 100644 --- a/PCL.Avalonia/DesktopSettings.cs +++ b/PCL.Avalonia/DesktopSettings.cs @@ -15,6 +15,24 @@ public sealed class DesktopSettings 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 bool DarkTheme { get; set; } public static string FilePath => Path.Combine(Paths.SharedData, "avalonia.settings.json"); @@ -33,16 +51,32 @@ public void Save() 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 = Guid.NewGuid().ToString("N"), ProfileType = result.ProfileType, + 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 + Provider = result.Provider, Server = result.Server, LoginName = result.LoginName, + DiscoveryAddress = result.DiscoveryAddress, IdToken = result.IdToken }; - EncryptedAccount = EncryptHelper.SecretEncrypt(JsonSerializer.Serialize(profile)); + 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() @@ -54,3 +88,15 @@ private static string DefaultGameDirectory() : Path.Combine(user, ".minecraft"); } } + +public sealed class InstancePreferences +{ + 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); 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..68083da81 --- /dev/null +++ b/PCL.Avalonia/MainWindow.Accounts.cs @@ -0,0 +1,164 @@ +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(); } + 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 }, 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 }, 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("认证补丁缺少校验信息。"); + 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 { 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..90e8015cd --- /dev/null +++ b/PCL.Avalonia/MainWindow.Instances.cs @@ -0,0 +1,193 @@ +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 = "启动后加入的服务器地址(可留空)" }; + panel.Children.Add(ActionButton("载入当前版本设置", _ => + { + var options = InstanceOptions; 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(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.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) + { + 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(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(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..2889e8e0a --- /dev/null +++ b/PCL.Avalonia/MainWindow.Preferences.cs @@ -0,0 +1,140 @@ +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 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 ApplyBackground() + { + _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..a2f784943 --- /dev/null +++ b/PCL.Avalonia/MainWindow.Tools.cs @@ -0,0 +1,113 @@ +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); + 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("已退出大厅。"); }))); + 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; + 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 index e0c7c3b04..3d118b2d0 100644 --- a/PCL.Avalonia/MainWindow.cs +++ b/PCL.Avalonia/MainWindow.cs @@ -16,11 +16,11 @@ namespace PCL.Avalonia; -public sealed class MainWindow : Window +public sealed partial class MainWindow : Window { private readonly DesktopSettings _settings; private readonly bool _canSave; - private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) }; + 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 }; @@ -38,6 +38,8 @@ public sealed class MainWindow : Window 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) @@ -47,13 +49,18 @@ public MainWindow(DesktopSettings? settings = null) 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.LoadAccount(); } + try { _profile = _settings.SelectedAccount(); } catch (Exception ex) { startupError = "已保存的账户无法解密,原数据已保留:" + ex.Message; } RefreshAccount(); @@ -61,10 +68,13 @@ public MainWindow(DesktopSettings? settings = null) 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 = DownloadPage() }); - tabs.Items.Add(new TabItem { Header = "账户", Content = AccountPage() }); - tabs.Items.Add(new TabItem { Header = "设置", Content = SettingsPage() }); + 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 = new ScrollViewer { Content = _log, Margin = new Thickness(6, 16) } }); var footer = new StackPanel { Margin = new Thickness(26, 12, 26, 18), Spacing = 10 }; footer.Children.Add(_progress); @@ -77,17 +87,25 @@ public MainWindow(DesktopSettings? settings = null) 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; - Closing += (_, e) => + ApplyBackground(); TranslateVisuals(this); + Closing += async (_, e) => { + if (_allowClose) return; if (_game is { HasExited: false }) { e.Cancel = true; Report("游戏正在运行,请先退出游戏或点击“停止游戏”。"); return; } - _operation?.Cancel(); + 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 += (_, _) => { _http.Dispose(); _game?.Dispose(); }; + Closed += async (_, _) => { await _lobby.DisposeAsync(); _mediaPlayer.Dispose(); _backgroundBitmap?.Dispose(); _http.Dispose(); _game?.Dispose(); }; Opened += async (_, _) => { RefreshInstalled(); @@ -107,7 +125,7 @@ private Control LaunchPage() panel.Children.Add(Row(ActionButton("启动游戏", Launch), ActionButton("检查并修复文件", Repair))); panel.Children.Add(new TextBlock { - Text = "支持原版安装与标准版本 JSON 的启动。已安装的 Fabric / Forge 版本可尝试启动;整合包安装、联机与旧版自定义主页尚在迁移。", + Text = "可在“安装与资源”安装加载器和整合包,在“实例管理”管理 Mod、存档与服务器。", TextWrapping = TextWrapping.Wrap, Opacity = .65, Margin = new Thickness(0, 18, 0, 0) }); return new ScrollViewer { Content = panel }; @@ -143,7 +161,7 @@ private Control AccountPage() panel.Children.Add(new TextBlock { Text = "使用已获 Minecraft API 权限的应用客户端 ID。授权时会显示设备码,并在浏览器中完成登录。", TextWrapping = TextWrapping.Wrap, Opacity = .65 }); panel.Children.Add(Row(ActionButton("微软账户登录", Login), ActionButton("退出账户", _ => { - _profile = null; _settings.EncryptedAccount = null; SaveSettings(); RefreshAccount(); return Task.CompletedTask; + _settings.RemoveSelectedAccount(); _profile = _settings.SelectedAccount(); SaveSettings(); RefreshAccount(); return Task.CompletedTask; }))); return new ScrollViewer { Content = panel }; } @@ -198,7 +216,7 @@ private async Task Login(CancellationToken token) throw new TimeoutException("设备授权已超时,请重新登录。"); } }, token); - _settings.StoreAccount(result); _profile = _settings.LoadAccount(); SaveSettings(); RefreshAccount(); + _settings.StoreAccount(result); _profile = _settings.SelectedAccount(); SaveSettings(); RefreshAccount(); Report("登录成功。"); } @@ -218,20 +236,23 @@ private async Task Launch(CancellationToken token) if (_useAccount.IsChecked == true) { var profile = _profile ?? throw new InvalidOperationException("请先登录账户。"); - if (profile.IsExpired) + if (profile.IsExpired || profile.ProfileType == PCL.Core.Minecraft.Profile.Models.ProfileType.Authlib) { - var refreshed = await new MicrosoftProvider(_clientId.Text).RefreshAsync(profile, token); - _settings.StoreAccount(refreshed); _profile = _settings.LoadAccount(); SaveSettings(); RefreshAccount(); + 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); + account = new(profile.UserName, profile.Uuid, profile.AccessToken, profile.ProfileType == PCL.Core.Minecraft.Profile.Models.ProfileType.Microsoft ? "msa" : "mojang"); } else account = LaunchAccount.Offline(_offlineName.Text ?? ""); token.ThrowIfCancellationRequested(); var root = GameRoot; + var launchOptions = await BuildLaunchOptions(token); var memory = (int)(_memory.Value ?? 2048); _game = await Task.Run(() => new GameProcess().Start(root, id, javaPath, account, - memory, line => Dispatcher.UIThread.Post(() => AppendLog(line))), token); + memory, line => Dispatcher.UIThread.Post(() => AppendLog(line)), launchOptions), token); _stop.IsEnabled = true; Report($"{id} 已启动。"); _ = ObserveGame(_game); } @@ -317,6 +338,7 @@ 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); } @@ -324,7 +346,7 @@ private async Task RunOperation(Func action) catch (Exception ex) { Report("操作失败:" + ex.Message); } finally { - _operation = null; _cancel.IsEnabled = false; + _operation = null; _operationFinished = null; completion.TrySetResult(); _cancel.IsEnabled = false; foreach (var control in _operationControls) control.IsEnabled = true; } } diff --git a/PCL.Avalonia/PCL.Avalonia.csproj b/PCL.Avalonia/PCL.Avalonia.csproj index 90591496f..825eccfd6 100644 --- a/PCL.Avalonia/PCL.Avalonia.csproj +++ b/PCL.Avalonia/PCL.Avalonia.csproj @@ -12,11 +12,14 @@ Debug;CI;Release;Beta + + + 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/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 index 63578253d..a57237860 100644 --- a/PCL.CrossPlatform.Test/DesktopTests.cs +++ b/PCL.CrossPlatform.Test/DesktopTests.cs @@ -24,13 +24,18 @@ public void AvaloniaWindowLoadsAllPagesWithoutWpf() var window = new MainWindow(new DesktopSettings { JavaPath = "/test/java" }); window.Show(); Dispatcher.UIThread.RunJobs(); - var tabs = window.GetVisualDescendants().OfType().Single(); - Assert.AreEqual(5, tabs.ItemCount); + var tabs = window.GetVisualDescendants().OfType().First(); + Assert.AreEqual(8, tabs.ItemCount); + var visited = 0; for (var i = 0; i < tabs.ItemCount; i++) { tabs.SelectedIndex = i; Dispatcher.UIThread.RunJobs(); Assert.IsNotNull(tabs.SelectedContent); + if (tabs.SelectedContent is TabControl nested) + { + foreach (var item in nested.Items.OfType()) { nested.SelectedItem = item; Dispatcher.UIThread.RunJobs(); Assert.IsNotNull(nested.SelectedContent); visited++; } + } if (Environment.GetEnvironmentVariable("PCL_TEST_SCREENSHOTS") is { } folder) { Directory.CreateDirectory(folder); @@ -39,6 +44,7 @@ public void AvaloniaWindowLoadsAllPagesWithoutWpf() frameImage.Save(Path.Combine(folder, $"desktop-{i}.png")); } } + Assert.IsTrue(visited >= 20, "Feature pages must be connected to navigation."); var dispatcher = new AvaloniaUiDispatcher(); Assert.IsTrue(dispatcher.CheckAccess()); var frame = dispatcher.CreateModalFrame(); @@ -51,6 +57,6 @@ public void AvaloniaWindowLoadsAllPagesWithoutWpf() 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); + if (failure is not null) throw new AssertFailedException("Avalonia window failed to initialize: " + failure, failure); } } diff --git a/PCL.CrossPlatform.Test/MigrationTests.cs b/PCL.CrossPlatform.Test/MigrationTests.cs new file mode 100644 index 000000000..1cee520d6 --- /dev/null +++ b/PCL.CrossPlatform.Test/MigrationTests.cs @@ -0,0 +1,133 @@ +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 + { GameDirectory = isolated, Environment = new Dictionary { ["PCL_TEST"] = "yes" }, JvmArguments = ["-Dcustom=some value"] }); + 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("