소스 검색

Add cross-platform desktop and mobile preview

Angel 10 시간 전
부모
커밋
d56ee41c2c
54개의 변경된 파일과 2584개의 추가작업 그리고 10개의 파일을 삭제
  1. 95 0
      .github/workflows/cross-platform.yml
  2. 454 0
      CrossPlatform/.gitignore
  3. 9 0
      CrossPlatform/Directory.Build.props
  4. 21 0
      CrossPlatform/Directory.Packages.props
  5. 21 0
      CrossPlatform/PCL.Communist.Android/Application.cs
  6. BIN
      CrossPlatform/PCL.Communist.Android/Icon.png
  7. 16 0
      CrossPlatform/PCL.Communist.Android/MainActivity.cs
  8. 28 0
      CrossPlatform/PCL.Communist.Android/PCL.Communist.Android.csproj
  9. 5 0
      CrossPlatform/PCL.Communist.Android/Properties/AndroidManifest.xml
  10. 13 0
      CrossPlatform/PCL.Communist.Android/Resources/drawable/splash_screen.xml
  11. 4 0
      CrossPlatform/PCL.Communist.Android/Resources/values-night/colors.xml
  12. 21 0
      CrossPlatform/PCL.Communist.Android/Resources/values-v31/styles.xml
  13. 4 0
      CrossPlatform/PCL.Communist.Android/Resources/values/colors.xml
  14. 12 0
      CrossPlatform/PCL.Communist.Android/Resources/values/styles.xml
  15. 91 0
      CrossPlatform/PCL.Communist.Core/DownloadClient.cs
  16. 123 0
      CrossPlatform/PCL.Communist.Core/GameLauncher.cs
  17. 137 0
      CrossPlatform/PCL.Communist.Core/JavaRuntimeService.cs
  18. 85 0
      CrossPlatform/PCL.Communist.Core/MicrosoftLogin.cs
  19. 136 0
      CrossPlatform/PCL.Communist.Core/MinecraftInstaller.cs
  20. 49 0
      CrossPlatform/PCL.Communist.Core/MinecraftRules.cs
  21. 6 0
      CrossPlatform/PCL.Communist.Core/PCL.Communist.Core.csproj
  22. 65 0
      CrossPlatform/PCL.Communist.Core/PlatformInfo.cs
  23. 21 0
      CrossPlatform/PCL.Communist.Desktop/PCL.Communist.Desktop.csproj
  24. 21 0
      CrossPlatform/PCL.Communist.Desktop/Program.cs
  25. 18 0
      CrossPlatform/PCL.Communist.Desktop/app.manifest
  26. 11 0
      CrossPlatform/PCL.Communist.Tests/PCL.Communist.Tests.csproj
  27. 254 0
      CrossPlatform/PCL.Communist.Tests/Program.cs
  28. 23 0
      CrossPlatform/PCL.Communist.iOS/AppDelegate.cs
  29. 5 0
      CrossPlatform/PCL.Communist.iOS/Entitlements.plist
  30. 39 0
      CrossPlatform/PCL.Communist.iOS/Info.plist
  31. 14 0
      CrossPlatform/PCL.Communist.iOS/Main.cs
  32. 21 0
      CrossPlatform/PCL.Communist.iOS/PCL.Communist.iOS.csproj
  33. 1 0
      CrossPlatform/PCL.Communist.iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
  34. BIN
      CrossPlatform/PCL.Communist.iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png
  35. 1 0
      CrossPlatform/PCL.Communist.iOS/Resources/Assets.xcassets/Contents.json
  36. 43 0
      CrossPlatform/PCL.Communist.iOS/Resources/LaunchScreen.xib
  37. 6 0
      CrossPlatform/PCL.Communist.slnx
  38. 10 0
      CrossPlatform/PCL.Communist/App.axaml
  39. 41 0
      CrossPlatform/PCL.Communist/App.axaml.cs
  40. BIN
      CrossPlatform/PCL.Communist/Assets/Icon.png
  41. BIN
      CrossPlatform/PCL.Communist/Assets/Launcher.icns
  42. 21 0
      CrossPlatform/PCL.Communist/PCL.Communist.csproj
  43. 233 0
      CrossPlatform/PCL.Communist/ViewModels/MainViewModel.cs
  44. 7 0
      CrossPlatform/PCL.Communist/ViewModels/ViewModelBase.cs
  45. 108 0
      CrossPlatform/PCL.Communist/Views/MainView.axaml
  46. 28 0
      CrossPlatform/PCL.Communist/Views/MainView.axaml.cs
  47. 13 0
      CrossPlatform/PCL.Communist/Views/MainWindow.axaml
  48. 11 0
      CrossPlatform/PCL.Communist/Views/MainWindow.axaml.cs
  49. 6 0
      CrossPlatform/global.json
  50. 7 6
      README.md
  51. 76 0
      docs/cross-platform.md
  52. 55 0
      tools/Build-CrossPlatform.ps1
  53. 6 4
      tools/Test-IsolatedProcess.ps1
  54. 89 0
      tools/package_cross_platform.py

+ 95 - 0
.github/workflows/cross-platform.yml

@@ -0,0 +1,95 @@
+name: Cross-platform preview
+
+on:
+  push:
+    branches: [main]
+    paths: ['CrossPlatform/**', 'tools/Build-CrossPlatform.ps1', 'tools/package_cross_platform.py', 'tools/Test-IsolatedProcess.ps1', '.github/workflows/cross-platform.yml']
+  pull_request:
+    paths: ['CrossPlatform/**', 'tools/Build-CrossPlatform.ps1', 'tools/package_cross_platform.py', 'tools/Test-IsolatedProcess.ps1', '.github/workflows/cross-platform.yml']
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  desktop:
+    name: Desktop ${{ matrix.runtime }}
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - {runner: windows-latest, runtime: win-x64}
+          - {runner: ubuntu-latest, runtime: linux-x64}
+          - {runner: macos-latest, runtime: osx-x64}
+          - {runner: macos-latest, runtime: osx-arm64}
+    runs-on: ${{ matrix.runner }}
+    timeout-minutes: 25
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
+        with:
+          dotnet-version: 10.0.x
+      - name: Run core and responsive UI checks
+        shell: pwsh
+        run: ./tools/Build-CrossPlatform.ps1 -Target test
+      - name: Publish self-contained application
+        shell: pwsh
+        run: ./tools/Build-CrossPlatform.ps1 -Target desktop -Runtime ${{ matrix.runtime }}
+      - name: Check native Windows startup on an inactive desktop
+        if: matrix.runtime == 'win-x64'
+        shell: powershell
+        run: ./tools/Test-IsolatedProcess.ps1 -Executable ./artifacts/cross-platform/win-x64/PCL.Communist.Desktop.exe -OutputDirectory ./artifacts/cross-platform/windows-startup -TimeoutSeconds 10 -ExpectMainWindow -ExpectedWindowTitle 'PCL Communist · 跨平台预览' -ExpectedWindowClassPrefix Avalonia
+      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
+        with:
+          name: preview-${{ matrix.runtime }}
+          path: |
+            artifacts/cross-platform/packages/*.tar.gz*
+            artifacts/cross-platform/packages/*.zip*
+            artifacts/cross-platform/qa/*.png
+            artifacts/cross-platform/windows-startup/*
+          if-no-files-found: error
+
+  android:
+    name: Android arm64 preview APK
+    runs-on: ubuntu-latest
+    timeout-minutes: 35
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
+        with:
+          dotnet-version: 10.0.x
+      - run: dotnet workload install android --skip-manifest-update
+      - name: Install matching Android SDK and JDK
+        run: dotnet build CrossPlatform/PCL.Communist.Android -t:InstallAndroidDependencies -f net10.0-android -p:AndroidSdkDirectory="$RUNNER_TEMP/android-sdk" -p:JavaSdkDirectory="$RUNNER_TEMP/jdk" -p:AcceptAndroidSDKLicenses=True
+      - name: Build debug-signed preview APK
+        shell: pwsh
+        run: ./tools/Build-CrossPlatform.ps1 -Target android -AndroidSdkDirectory "$env:RUNNER_TEMP/android-sdk" -JavaSdkDirectory "$env:RUNNER_TEMP/jdk"
+      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
+        with:
+          name: preview-android-arm64
+          path: artifacts/cross-platform/android-arm64/*-Signed.apk
+          if-no-files-found: error
+
+  ios:
+    name: iPhone and iPad simulator preview
+    runs-on: macos-latest
+    timeout-minutes: 40
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+      - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
+        with:
+          dotnet-version: 10.0.x
+      - run: dotnet workload install ios --skip-manifest-update
+      - name: Build simulator application without signing credentials
+        shell: pwsh
+        run: ./tools/Build-CrossPlatform.ps1 -Target ios-simulator
+      - name: Archive simulator application
+        run: |
+          app=$(find artifacts/cross-platform -maxdepth 2 -type d -name '*.app' -print -quit)
+          test -n "$app"
+          tar -czf artifacts/cross-platform/PCL-Communist-ios-simulator.tar.gz -C "$(dirname "$app")" "$(basename "$app")"
+      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
+        with:
+          name: preview-ios-simulator
+          path: artifacts/cross-platform/PCL-Communist-ios-simulator.tar.gz
+          if-no-files-found: error

+ 454 - 0
CrossPlatform/.gitignore

@@ -0,0 +1,454 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# Mac bundle stuff
+*.dmg
+*.app
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# JetBrains Rider
+.idea/
+*.sln.iml
+
+##
+## Visual Studio Code
+##
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json

+ 9 - 0
CrossPlatform/Directory.Build.props

@@ -0,0 +1,9 @@
+<Project>
+  <PropertyGroup>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <LangVersion>14.0</LangVersion>
+    <Deterministic>true</Deterministic>
+    <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
+  </PropertyGroup>
+</Project>

+ 21 - 0
CrossPlatform/Directory.Packages.props

@@ -0,0 +1,21 @@
+<Project>
+    <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
+    <PropertyGroup>
+        <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
+    </PropertyGroup>
+    <ItemGroup>
+        <!-- Avalonia packages -->
+        <!-- Important: keep version in sync! -->
+        <PackageVersion Include="Avalonia" Version="12.1.4" />
+        <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.4" />
+        <PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.4" />
+        <PackageVersion Include="Avalonia.Desktop" Version="12.1.4" />
+        <PackageVersion Include="Avalonia.iOS" Version="12.1.4" />
+        <PackageVersion Include="Avalonia.Headless" Version="12.1.4" />
+        <PackageVersion Include="Avalonia.Android" Version="12.1.4" />
+
+        <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
+
+        <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" />
+    </ItemGroup>
+</Project>

+ 21 - 0
CrossPlatform/PCL.Communist.Android/Application.cs

@@ -0,0 +1,21 @@
+using Android.App;
+using Android.Runtime;
+using Avalonia;
+using Avalonia.Android;
+
+namespace PCL.Communist.Android
+{
+    [Application]
+    public class Application : AvaloniaAndroidApplication<App>
+    {
+        protected Application(nint javaReference, JniHandleOwnership transfer) : base(javaReference, transfer)
+        {
+        }
+
+        protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
+        {
+            return base.CustomizeAppBuilder(builder)
+            .WithInterFont();
+        }
+    }
+}

BIN
CrossPlatform/PCL.Communist.Android/Icon.png


+ 16 - 0
CrossPlatform/PCL.Communist.Android/MainActivity.cs

@@ -0,0 +1,16 @@
+using Android.App;
+using Android.Content.PM;
+using Avalonia;
+using Avalonia.Android;
+
+namespace PCL.Communist.Android;
+
+[Activity(
+    Label = "PCL Communist",
+    Theme = "@style/MyTheme.NoActionBar",
+    Icon = "@drawable/icon",
+    MainLauncher = true,
+    ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
+public class MainActivity : AvaloniaMainActivity
+{
+}

+ 28 - 0
CrossPlatform/PCL.Communist.Android/PCL.Communist.Android.csproj

@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net10.0-android</TargetFramework>
+    <SupportedOSPlatformVersion>23</SupportedOSPlatformVersion>
+    <Nullable>enable</Nullable>
+    <ApplicationId>org.pclcommunist.launcher</ApplicationId>
+    <ApplicationVersion>1</ApplicationVersion>
+    <ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
+    <AndroidPackageFormat>apk</AndroidPackageFormat>
+    <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <AndroidResource Include="Icon.png">
+      <Link>Resources\drawable\Icon.png</Link>
+    </AndroidResource>
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Avalonia.Android" />
+    <PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\PCL.Communist\PCL.Communist.csproj" />
+  </ItemGroup>
+</Project>

+ 5 - 0
CrossPlatform/PCL.Communist.Android/Properties/AndroidManifest.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
+	<uses-permission android:name="android.permission.INTERNET" />
+	<application android:label="PCL Communist" android:icon="@drawable/icon" android:usesCleartextTraffic="false" android:allowBackup="false" />
+</manifest>

+ 13 - 0
CrossPlatform/PCL.Communist.Android/Resources/drawable/splash_screen.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+
+  <item>
+    <color android:color="@color/splash_background"/>
+  </item>
+
+  <item android:drawable="@drawable/icon"
+        android:width="120dp"
+        android:height="120dp"
+        android:gravity="center" />
+
+</layer-list>

+ 4 - 0
CrossPlatform/PCL.Communist.Android/Resources/values-night/colors.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <color name="splash_background">#1B141B</color>
+</resources>

+ 21 - 0
CrossPlatform/PCL.Communist.Android/Resources/values-v31/styles.xml

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<resources>
+
+  <style name="MyTheme">
+  </style>
+
+  <style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.NoActionBar">
+    <item name="android:windowActionBar">false</item>
+    <item name="android:windowBackground">@null</item>
+    <item name="android:windowNoTitle">true</item>
+    <item name="android:windowSplashScreenBackground">@color/splash_background</item>
+    <item name="android:windowSplashScreenAnimatedIcon">@drawable/icon</item>
+    <item name="android:windowSplashScreenAnimationDuration">1000</item>
+    <item name="postSplashScreenTheme">@style/MyTheme.Main</item>
+
+  </style>
+  <style name="MyTheme.Main"
+         parent ="MyTheme.NoActionBar">
+    <item name="android:windowIsTranslucent">true</item>
+  </style>
+</resources>

+ 4 - 0
CrossPlatform/PCL.Communist.Android/Resources/values/colors.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <color name="splash_background">#1B141B</color>
+</resources>

+ 12 - 0
CrossPlatform/PCL.Communist.Android/Resources/values/styles.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<resources>
+
+  <style name="MyTheme">
+  </style>
+
+  <style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
+    <item name="android:windowActionBar">false</item>
+    <item name="android:windowBackground">@drawable/splash_screen</item>
+    <item name="android:windowNoTitle">true</item>
+  </style>
+</resources>

+ 91 - 0
CrossPlatform/PCL.Communist.Core/DownloadClient.cs

@@ -0,0 +1,91 @@
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+
+namespace PCL.Communist.Core;
+
+public sealed record DownloadFile(string Url, string Path, string? Sha1 = null, long? Size = null);
+public sealed record InstallProgress(string Message, int Completed, int Total)
+{
+    public double Percent => Total == 0 ? 0 : Completed * 100d / Total;
+}
+
+public sealed class DownloadClient(HttpClient http, bool preferMirror = true)
+{
+    public async Task<JsonObject> GetJsonAsync(string url, CancellationToken cancellationToken = default)
+    {
+        Exception? failure = null;
+        foreach (var source in Sources(url))
+        {
+            try
+            {
+                using var response = await http.GetAsync(source, cancellationToken);
+                response.EnsureSuccessStatusCode();
+                using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+                return await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) as JsonObject
+                    ?? throw new InvalidDataException("下载的元数据不是 JSON 对象。");
+            }
+            catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidDataException or System.Text.Json.JsonException)
+            { failure = ex; }
+            catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+            { failure = ex; }
+        }
+        throw new IOException("下载元数据失败,请检查网络后重试。", failure);
+    }
+
+    public async Task FetchAsync(DownloadFile file, CancellationToken cancellationToken = default)
+    {
+        if (await IsValidAsync(file, cancellationToken)) return;
+        Directory.CreateDirectory(System.IO.Path.GetDirectoryName(file.Path)!);
+        Exception? failure = null;
+        foreach (var source in Sources(file.Url))
+        {
+            var temporary = file.Path + "." + Guid.NewGuid().ToString("N") + ".part";
+            try
+            {
+                using var response = await http.GetAsync(source, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
+                response.EnsureSuccessStatusCode();
+                await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
+                    await response.Content.CopyToAsync(output, cancellationToken);
+                if (!await IsValidAsync(file with { Path = temporary }, cancellationToken))
+                    throw new InvalidDataException("下载文件的长度或 SHA1 不匹配。");
+                File.Move(temporary, file.Path, true);
+                return;
+            }
+            catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidDataException)
+            { failure = ex; }
+            catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+            { failure = ex; }
+            finally { if (File.Exists(temporary)) File.Delete(temporary); }
+        }
+        throw new IOException($"下载失败:{System.IO.Path.GetFileName(file.Path)}", failure);
+    }
+
+    public static async Task<bool> IsValidAsync(DownloadFile file, CancellationToken cancellationToken = default)
+    {
+        if (!File.Exists(file.Path)) return false;
+        if (file.Size is long size && new FileInfo(file.Path).Length != size) return false;
+        if (string.IsNullOrEmpty(file.Sha1)) return true;
+        await using var stream = File.OpenRead(file.Path);
+        var actual = Convert.ToHexString(await SHA1.HashDataAsync(stream, cancellationToken));
+        return actual.Equals(file.Sha1, StringComparison.OrdinalIgnoreCase);
+    }
+
+    public IEnumerable<Uri> Sources(string url)
+    {
+        var original = new Uri(url);
+        if (original.Scheme != Uri.UriSchemeHttps) throw new InvalidDataException("只允许 HTTPS 下载源。");
+        if (preferMirror)
+        {
+            var mirrorPath = original.Host switch
+            {
+                "piston-meta.mojang.com" or "launchermeta.mojang.com" or "piston-data.mojang.com" => original.PathAndQuery,
+                "libraries.minecraft.net" => "/maven" + original.PathAndQuery,
+                "resources.download.minecraft.net" => "/assets" + original.PathAndQuery,
+                _ => null
+            };
+            if (mirrorPath is not null) yield return new Uri("https://bmclapi2.bangbang93.com" + mirrorPath);
+        }
+        yield return original;
+    }
+}

+ 123 - 0
CrossPlatform/PCL.Communist.Core/GameLauncher.cs

@@ -0,0 +1,123 @@
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace PCL.Communist.Core;
+
+public sealed record AccountSession(string Name, string Uuid, string AccessToken, string UserType, DateTimeOffset ExpiresAt)
+{
+    public static AccountSession Offline(string name)
+    {
+        if (!Regex.IsMatch(name, "^[A-Za-z0-9_]{3,16}$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)))
+            throw new ArgumentException("离线名称应为 3–16 位字母、数字或下划线。");
+        var hash = MD5.HashData(Encoding.UTF8.GetBytes("OfflinePlayer:" + name));
+        hash[6] = (byte)((hash[6] & 0x0f) | 0x30);
+        hash[8] = (byte)((hash[8] & 0x3f) | 0x80);
+        return new(name, Convert.ToHexString(hash).ToLowerInvariant(), "0", "legacy", DateTimeOffset.MaxValue);
+    }
+}
+
+public sealed record LaunchPlan(string Executable, string WorkingDirectory, IReadOnlyList<string> Arguments)
+{
+    public ProcessStartInfo CreateStartInfo()
+    {
+        var info = new ProcessStartInfo(Executable) { WorkingDirectory = WorkingDirectory, UseShellExecute = false };
+        foreach (var argument in Arguments) info.ArgumentList.Add(argument);
+        return info;
+    }
+}
+
+public sealed record RuntimeCapability(bool CanLaunch, string Description);
+
+// A future mobile backend must embed its JVM, native graphics surface and input bridge.
+// It must never fall through to desktop Process.Start or report a successful launch without a running game.
+public interface IGameRuntime
+{
+    RuntimeCapability Capability { get; }
+    Task LaunchAsync(JsonObject metadata, AccountSession account, string? java, int memoryMb,
+        IProgress<InstallProgress>? progress, CancellationToken cancellationToken);
+}
+
+public sealed class GameLauncher(PlatformInfo platform, LauncherPaths paths, JavaRuntimeService javaService) : IGameRuntime
+{
+    public RuntimeCapability Capability => platform.IsMobile
+        ? new(false, "可管理版本、登录和下载游戏资源;本机游戏启动还需接入移动端 Java、图形与触控运行时。")
+        : new(true, "原版游戏启动 · 自动选择对应系统和架构的 Java 与原生库");
+
+    public async Task LaunchAsync(JsonObject metadata, AccountSession account, string? java, int memoryMb,
+        IProgress<InstallProgress>? progress, CancellationToken cancellationToken)
+    {
+        if (!Capability.CanLaunch) throw new PlatformNotSupportedException(Capability.Description);
+        var runtime = await javaService.ResolveAsync(metadata, platform, java, progress, cancellationToken);
+        cancellationToken.ThrowIfCancellationRequested();
+        var plan = BuildPlan(metadata, platform, paths, runtime.Executable, account, memoryMb);
+        using var process = Process.Start(plan.CreateStartInfo()) ?? throw new IOException("未能创建游戏进程。");
+        await Task.Delay(1500, cancellationToken);
+        if (process.HasExited && process.ExitCode != 0)
+            throw new IOException($"游戏进程提前退出(代码 {process.ExitCode}),请检查游戏目录内的日志。");
+    }
+
+    public static LaunchPlan BuildPlan(JsonObject metadata, PlatformInfo platform, LauncherPaths paths,
+        string java, AccountSession account, int memoryMb)
+    {
+        if (platform.IsMobile) throw new PlatformNotSupportedException("移动端不能使用桌面启动命令。");
+        if (account.ExpiresAt <= DateTimeOffset.UtcNow) throw new InvalidOperationException("登录已过期,请重新登录微软账号。");
+        if (metadata["inheritsFrom"] is not null) throw new NotSupportedException("此移植版尚未支持加载器继承版本。");
+        var id = metadata["id"]!.GetValue<string>();
+        var libraries = metadata["libraries"]!.AsArray().Where(l => l is not null && MinecraftRules.Allows(l["rules"], platform)).ToArray();
+        MinecraftRules.ValidateNativeArchitecture(metadata, platform);
+        var classpath = libraries.Where(l => l?["downloads"]?["artifact"] is not null)
+            .Select(l => LauncherPaths.SafeChild(paths.Libraries, l!["downloads"]!["artifact"]!["path"]!.GetValue<string>()))
+            .Append(Path.Combine(paths.VersionDirectory(id), id + ".jar"));
+        var assetId = metadata["assetIndex"]?["id"]?.GetValue<string>() ?? "legacy";
+        var replacements = new Dictionary<string, string>
+        {
+            ["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"] = "{}", ["version_name"] = id,
+            ["version_type"] = "PCL Communist", ["game_directory"] = paths.Root, ["assets_root"] = paths.Assets,
+            ["assets_index_name"] = assetId, ["game_assets"] = LauncherPaths.SafeChild(Path.Combine(paths.Assets, "virtual"), assetId),
+            ["natives_directory"] = Path.Combine(paths.VersionDirectory(id), "natives", platform.Kind + "-" + platform.Architecture),
+            ["launcher_name"] = "PCL Communist", ["launcher_version"] = "0.1.0", ["classpath_separator"] = platform.ClasspathSeparator,
+            ["library_directory"] = paths.Libraries, ["classpath"] = string.Join(platform.ClasspathSeparator, classpath),
+            ["resolution_width"] = "1100", ["resolution_height"] = "700", ["clientid"] = MicrosoftLogin.ClientId, ["auth_xuid"] = ""
+        };
+        string Expand(string arg) => Regex.Replace(arg, @"\$\{([^}]+)\}", match =>
+            replacements.TryGetValue(match.Groups[1].Value, out var value) ? value :
+                throw new InvalidDataException($"未知的启动参数:{match.Value}"), RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
+        var arguments = new List<string> { $"-Xmx{Math.Clamp(memoryMb, 512, 32768)}M" };
+        if (metadata["arguments"]?["jvm"] is JsonArray jvm) arguments.AddRange(ReadArguments(jvm, platform).Select(Expand));
+        else arguments.AddRange(["-Djava.library.path=" + replacements["natives_directory"], "-cp", replacements["classpath"]]);
+        if (platform.Kind == PlatformKind.MacOS && !arguments.Contains("-XstartOnFirstThread")) arguments.Add("-XstartOnFirstThread");
+        if (metadata["logging"]?["client"] is JsonNode logging)
+        {
+            var path = LauncherPaths.SafeChild(Path.Combine(paths.Assets, "log_configs"), logging["file"]!["id"]!.GetValue<string>());
+            arguments.Add(logging["argument"]!.GetValue<string>().Replace("${path}", path));
+        }
+        arguments.Add(metadata["mainClass"]!.GetValue<string>());
+        if (metadata["arguments"]?["game"] is JsonArray game) arguments.AddRange(ReadArguments(game, platform).Select(Expand));
+        else
+        {
+            // Legacy Mojang arguments contain quoted tokens but never require a shell.
+            foreach (Match token in Regex.Matches(metadata["minecraftArguments"]!.GetValue<string>(), "\"([^\"]*)\"|([^\\s]+)", RegexOptions.None, TimeSpan.FromSeconds(1)))
+                arguments.Add(Expand(token.Groups[1].Success ? token.Groups[1].Value : token.Groups[2].Value));
+        }
+        return new(java, paths.Root, arguments);
+    }
+
+    private static IEnumerable<string> ReadArguments(JsonArray arguments, PlatformInfo platform)
+    {
+        foreach (var argument in arguments)
+        {
+            if (argument is JsonValue value) yield return value.GetValue<string>();
+            else if (argument is JsonObject conditional && MinecraftRules.Allows(conditional["rules"], platform))
+            {
+                if (conditional["value"] is JsonArray values)
+                    foreach (var item in values) yield return item!.GetValue<string>();
+                else yield return conditional["value"]!.GetValue<string>();
+            }
+        }
+    }
+}

+ 137 - 0
CrossPlatform/PCL.Communist.Core/JavaRuntimeService.cs

@@ -0,0 +1,137 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace PCL.Communist.Core;
+
+public sealed record JavaRuntime(string Executable, int Major, string Architecture);
+
+public sealed class JavaRuntimeService(DownloadClient downloads, LauncherPaths paths)
+{
+    public const string IndexUrl = "https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json";
+
+    public static string RuntimePlatform(PlatformInfo platform) => (platform.Kind, platform.Architecture) switch
+    {
+        (PlatformKind.Windows, Architecture.X64) => "windows-x64",
+        (PlatformKind.Windows, Architecture.X86) => "windows-x86",
+        (PlatformKind.Windows, Architecture.Arm64) => "windows-arm64",
+        (PlatformKind.MacOS, Architecture.X64) => "mac-os",
+        (PlatformKind.MacOS, Architecture.Arm64) => "mac-os-arm64",
+        (PlatformKind.Linux, Architecture.X64) => "linux",
+        (PlatformKind.Linux, Architecture.X86) => "linux-i386",
+        _ => throw new PlatformNotSupportedException("此平台需要单独提供匹配架构的 Java 运行时。")
+    };
+
+    public async Task<JavaRuntime> ResolveAsync(JsonObject metadata, PlatformInfo platform, string? configuredJava,
+        IProgress<InstallProgress>? progress = null, CancellationToken cancellationToken = default)
+    {
+        if (platform.IsMobile) throw new PlatformNotSupportedException("移动端 Java 需要原生运行时接入。" );
+        var major = metadata["javaVersion"]?["majorVersion"]?.GetValue<int>() ?? 8;
+        if (!string.IsNullOrWhiteSpace(configuredJava))
+        {
+            var configured = await ProbeAsync(configuredJava, cancellationToken);
+            if (configured is null || configured.Major != major || !MatchesArchitecture(configured, platform))
+                throw new InvalidOperationException($"所选 Java 需要为 {platform.MinecraftArch} 架构的 Java {major}。");
+            return configured;
+        }
+        foreach (var candidate in Candidates(platform))
+        {
+            var found = await ProbeAsync(candidate, cancellationToken);
+            if (found?.Major == major && MatchesArchitecture(found, platform)) return found;
+        }
+        progress?.Report(new($"准备 Java {major} · {platform.DisplayName}…", 0, 1));
+        var runtimePlatform = RuntimePlatform(platform);
+        var index = await downloads.GetJsonAsync(IndexUrl, cancellationToken);
+        var component = metadata["javaVersion"]?["component"]?.GetValue<string>() ?? "jre-legacy";
+        var entry = index[runtimePlatform]?[component]?.AsArray().FirstOrDefault()
+            ?? throw new PlatformNotSupportedException($"官方没有 {platform.DisplayName} 的 Java {major}。请安装对应 Java 并在界面指定路径。");
+        var root = LauncherPaths.SafeChild(paths.Runtimes, runtimePlatform + "/" + component);
+        var manifestPath = Path.Combine(root, "runtime-manifest.json");
+        await downloads.FetchAsync(MinecraftInstaller.Artifact(entry["manifest"]!, manifestPath), cancellationToken);
+        var manifest = JsonNode.Parse(await File.ReadAllTextAsync(manifestPath, cancellationToken))!["files"]!.AsObject();
+        var files = manifest.Where(f => f.Value?["type"]?.GetValue<string>() == "file").ToArray();
+        var completed = 0;
+        await Parallel.ForEachAsync(files, new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = cancellationToken },
+            async (file, token) =>
+            {
+                var target = LauncherPaths.SafeChild(root, file.Key);
+                await downloads.FetchAsync(MinecraftInstaller.Artifact(file.Value!["downloads"]!["raw"]!, target), token);
+                if (!OperatingSystem.IsWindows() && file.Value["executable"]?.GetValue<bool>() == true)
+                    File.SetUnixFileMode(target, File.GetUnixFileMode(target) | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
+                progress?.Report(new($"安装 Java {major}…", Interlocked.Increment(ref completed), files.Length));
+            });
+        // Resolve only links inside the verified runtime. Copying these small files also works without Windows symlink privileges.
+        foreach (var file in manifest.Where(f => f.Value?["type"]?.GetValue<string>() == "link"))
+        {
+            var target = LauncherPaths.SafeChild(root, file.Key);
+            var source = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(target)!, file.Value!["target"]!.GetValue<string>()));
+            if (!source.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, StringComparison.Ordinal))
+                throw new InvalidDataException("Java 运行时的链接超出安装目录。");
+            Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+            if (Directory.Exists(source))
+            {
+                if (!Directory.Exists(target)) Directory.CreateSymbolicLink(target, source);
+            }
+            else File.Copy(source, target, true);
+        }
+        var result = await ProbeAsync(Path.Combine(root, "bin", platform.JavaExecutable), cancellationToken)
+            ?? await ProbeAsync(Path.Combine(root, "jre.bundle", "Contents", "Home", "bin", platform.JavaExecutable), cancellationToken);
+        if (result?.Major != major || !MatchesArchitecture(result, platform)) throw new IOException("下载的 Java 无法正常运行,或架构不匹配。" );
+        return result;
+    }
+
+    public static bool MatchesArchitecture(JavaRuntime runtime, PlatformInfo platform) => platform.Architecture switch
+    {
+        Architecture.X64 => runtime.Architecture is "amd64" or "x86_64",
+        Architecture.Arm64 => runtime.Architecture is "aarch64" or "arm64",
+        Architecture.X86 => runtime.Architecture is "x86" or "i386" or "i686",
+        _ => false
+    };
+
+    private IEnumerable<string> Candidates(PlatformInfo platform)
+    {
+        if (Directory.Exists(paths.Runtimes))
+            foreach (var file in Directory.EnumerateFiles(paths.Runtimes, platform.JavaExecutable, SearchOption.AllDirectories)) yield return file;
+        var javaHome = Environment.GetEnvironmentVariable("JAVA_HOME");
+        if (!string.IsNullOrEmpty(javaHome)) yield return Path.Combine(javaHome, "bin", platform.JavaExecutable);
+        yield return platform.JavaExecutable;
+        var parent = platform.Kind == PlatformKind.MacOS ? "/Library/Java/JavaVirtualMachines" : "/usr/lib/jvm";
+        if (!Directory.Exists(parent)) yield break;
+        foreach (var directory in Directory.EnumerateDirectories(parent))
+            yield return Path.Combine(directory, platform.Kind == PlatformKind.MacOS ? "Contents/Home/bin/java" : "bin/java");
+    }
+
+    public static async Task<JavaRuntime?> ProbeAsync(string executable, CancellationToken cancellationToken = default)
+    {
+        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+        timeout.CancelAfter(TimeSpan.FromSeconds(10));
+        Process? process = null;
+        try
+        {
+            var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true };
+            start.ArgumentList.Add("-XshowSettings:properties");
+            start.ArgumentList.Add("-version");
+            process = Process.Start(start)!;
+            var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
+            var stderr = process.StandardError.ReadToEndAsync(timeout.Token);
+            await process.WaitForExitAsync(timeout.Token);
+            var text = await stdout + "\n" + await stderr;
+            if (process.ExitCode != 0) return null;
+            var version = Regex.Match(text, @"java\.version\s*=\s*(?:1\.)?(\d+)", RegexOptions.None, TimeSpan.FromSeconds(1));
+            var arch = Regex.Match(text, @"os\.arch\s*=\s*(\S+)", RegexOptions.None, TimeSpan.FromSeconds(1));
+            return version.Success && arch.Success ? new(executable, int.Parse(version.Groups[1].Value), arch.Groups[1].Value) : null;
+        }
+        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return null; }
+        catch (System.ComponentModel.Win32Exception) { return null; }
+        catch (IOException) { return null; }
+        finally
+        {
+            if (process is not null)
+            {
+                if (!process.HasExited) process.Kill(true);
+                process.Dispose();
+            }
+        }
+    }
+}

+ 85 - 0
CrossPlatform/PCL.Communist.Core/MicrosoftLogin.cs

@@ -0,0 +1,85 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace PCL.Communist.Core;
+
+public sealed record DeviceCodePrompt(string Code, Uri VerificationUri, string Message);
+
+public sealed class MicrosoftLogin(HttpClient http)
+{
+    public static string ClientId => Environment.GetEnvironmentVariable("PCL_MS_CLIENT_ID") ?? "fe72edc2-3a6f-4280-90e8-e2beb64ce7e1";
+
+    // Tokens live only in this session. A mobile secure-store implementation is required before persisting refresh tokens.
+    public async Task<AccountSession> LoginAsync(Action<DeviceCodePrompt> prompt, CancellationToken cancellationToken = default)
+    {
+        var device = await FormAsync("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode",
+            new() { ["client_id"] = ClientId, ["scope"] = "XboxLive.signin offline_access" }, cancellationToken);
+        prompt(new(device["user_code"]!.GetValue<string>(), new Uri(device["verification_uri"]!.GetValue<string>()), device["message"]?.GetValue<string>() ?? "请在浏览器完成登录。"));
+        var interval = Math.Max(5, device["interval"]?.GetValue<int>() ?? 5);
+        var expires = DateTimeOffset.UtcNow.AddSeconds(device["expires_in"]!.GetValue<int>());
+        JsonObject? oauth = null;
+        while (DateTimeOffset.UtcNow < expires)
+        {
+            await Task.Delay(TimeSpan.FromSeconds(interval), cancellationToken);
+            var result = await FormAsync("https://login.microsoftonline.com/consumers/oauth2/v2.0/token", new()
+            {
+                ["client_id"] = ClientId, ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
+                ["device_code"] = device["device_code"]!.GetValue<string>()
+            }, cancellationToken, allowOAuthError: true);
+            if (result["access_token"] is not null) { oauth = result; break; }
+            switch (result["error"]?.GetValue<string>())
+            {
+                case "authorization_pending": continue;
+                case "slow_down": interval += 5; continue;
+                case "authorization_declined": throw new InvalidOperationException("你取消了微软登录。");
+                case "expired_token": throw new InvalidOperationException("登录代码已过期,请重新登录。");
+                default: throw new InvalidOperationException("微软登录失败:" + result["error"]?.GetValue<string>());
+            }
+        }
+        if (oauth is null) throw new InvalidOperationException("登录代码已过期,请重新登录。");
+        var xbl = await JsonAsync("https://user.auth.xboxlive.com/user/authenticate", new JsonObject
+        {
+            ["Properties"] = new JsonObject { ["AuthMethod"] = "RPS", ["SiteName"] = "user.auth.xboxlive.com", ["RpsTicket"] = "d=" + oauth["access_token"]!.GetValue<string>() },
+            ["RelyingParty"] = "http://auth.xboxlive.com", ["TokenType"] = "JWT"
+        }, cancellationToken);
+        var xsts = await JsonAsync("https://xsts.auth.xboxlive.com/xsts/authorize", new JsonObject
+        {
+            ["Properties"] = new JsonObject { ["SandboxId"] = "RETAIL", ["UserTokens"] = new JsonArray(xbl["Token"]!.GetValue<string>()) },
+            ["RelyingParty"] = "rp://api.minecraftservices.com/", ["TokenType"] = "JWT"
+        }, cancellationToken);
+        var mc = await JsonAsync("https://api.minecraftservices.com/authentication/login_with_xbox", new JsonObject
+        {
+            ["identityToken"] = $"XBL3.0 x={xsts["DisplayClaims"]!["xui"]![0]!["uhs"]!.GetValue<string>()};{xsts["Token"]!.GetValue<string>()}"
+        }, cancellationToken);
+        var token = mc["access_token"]!.GetValue<string>();
+        var entitlements = await AuthenticatedGetAsync("https://api.minecraftservices.com/entitlements/mcstore", token, cancellationToken);
+        if (entitlements["items"] is not JsonArray items || items.Count == 0) throw new InvalidOperationException("此账号尚未购买 Minecraft Java,或 Game Pass 已到期。");
+        var profile = await AuthenticatedGetAsync("https://api.minecraftservices.com/minecraft/profile", token, cancellationToken);
+        return new(profile["name"]!.GetValue<string>(), profile["id"]!.GetValue<string>(), token, "msa",
+            DateTimeOffset.UtcNow.AddSeconds(mc["expires_in"]!.GetValue<int>() - 60));
+    }
+
+    private async Task<JsonObject> FormAsync(string url, Dictionary<string, string> fields, CancellationToken token, bool allowOAuthError = false)
+    {
+        using var response = await http.PostAsync(url, new FormUrlEncodedContent(fields), token);
+        if (!allowOAuthError) response.EnsureSuccessStatusCode();
+        return JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject();
+    }
+
+    private async Task<JsonObject> JsonAsync(string url, JsonObject body, CancellationToken token)
+    {
+        using var response = await http.PostAsync(url, new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), token);
+        if (!response.IsSuccessStatusCode) throw new HttpRequestException($"账号服务返回 {(int)response.StatusCode},请检查 Xbox 账号设置后重试。");
+        return JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject();
+    }
+
+    private async Task<JsonObject> AuthenticatedGetAsync(string url, string token, CancellationToken cancellationToken)
+    {
+        using var request = new HttpRequestMessage(HttpMethod.Get, url);
+        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+        using var response = await http.SendAsync(request, cancellationToken);
+        response.EnsureSuccessStatusCode();
+        return JsonNode.Parse(await response.Content.ReadAsStringAsync(cancellationToken))!.AsObject();
+    }
+}

+ 136 - 0
CrossPlatform/PCL.Communist.Core/MinecraftInstaller.cs

@@ -0,0 +1,136 @@
+using System.IO.Compression;
+using System.Text.Json.Nodes;
+
+namespace PCL.Communist.Core;
+
+public sealed record MinecraftVersion(string Id, string Type, string Url, string Sha1)
+{
+    public override string ToString() => Id;
+}
+
+public sealed class MinecraftInstaller(DownloadClient downloads, LauncherPaths paths)
+{
+    public const string ManifestUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
+
+    public async Task<IReadOnlyList<MinecraftVersion>> GetVersionsAsync(CancellationToken cancellationToken = default)
+    {
+        var manifest = await downloads.GetJsonAsync(ManifestUrl, cancellationToken);
+        return manifest["versions"]!.AsArray().Select(node => new MinecraftVersion(
+            node!["id"]!.GetValue<string>(), node["type"]!.GetValue<string>(),
+            node["url"]!.GetValue<string>(), node["sha1"]!.GetValue<string>())).ToArray();
+    }
+
+    public async Task<JsonObject> InstallAsync(MinecraftVersion version, PlatformInfo platform,
+        IProgress<InstallProgress>? progress = null, CancellationToken cancellationToken = default)
+    {
+        progress?.Report(new("读取版本信息…", 0, 1));
+        await downloads.FetchAsync(new(version.Url, paths.VersionJson(version.Id), version.Sha1), cancellationToken);
+        var metadata = JsonNode.Parse(await File.ReadAllTextAsync(paths.VersionJson(version.Id), cancellationToken))!.AsObject();
+        if (metadata["id"]?.GetValue<string>() != version.Id) throw new InvalidDataException("版本元数据的 ID 不一致。");
+        return await InstallResourcesAsync(metadata, platform, progress, cancellationToken);
+    }
+
+    public async Task<JsonObject> RepairAsync(string versionId, PlatformInfo platform,
+        IProgress<InstallProgress>? progress = null, CancellationToken cancellationToken = default)
+    {
+        var metadata = JsonNode.Parse(await File.ReadAllTextAsync(paths.VersionJson(versionId), cancellationToken))!.AsObject();
+        if (metadata["id"]?.GetValue<string>() != versionId) throw new InvalidDataException("本地版本元数据的 ID 不一致。");
+        return await InstallResourcesAsync(metadata, platform, progress, cancellationToken);
+    }
+
+    private async Task<JsonObject> InstallResourcesAsync(JsonObject metadata, PlatformInfo platform,
+        IProgress<InstallProgress>? progress, CancellationToken cancellationToken)
+    {
+        var id = metadata["id"]!.GetValue<string>();
+        if (metadata["inheritsFrom"] is not null) throw new NotSupportedException("此移植版目前安装原版游戏;加载器继承版本尚待移植。");
+        MinecraftRules.ValidateNativeArchitecture(metadata, platform);
+        var files = new List<DownloadFile>();
+        var nativeArchives = new List<(string File, string[] Exclude)>();
+        files.Add(Artifact(metadata["downloads"]!["client"]!, Path.Combine(paths.VersionDirectory(id), id + ".jar")));
+
+        // A mobile installation downloads portable resources only. Desktop natives are never substituted for a mobile runtime.
+        if (!platform.IsMobile)
+        {
+            foreach (var library in metadata["libraries"]!.AsArray())
+            {
+                if (library is null || !MinecraftRules.Allows(library["rules"], platform)) continue;
+                if (library["downloads"]?["artifact"] is JsonNode artifact)
+                    files.Add(Artifact(artifact, LauncherPaths.SafeChild(paths.Libraries, artifact["path"]!.GetValue<string>())));
+                var classifier = MinecraftRules.NativeClassifier(library, platform);
+                if (classifier is null) continue;
+                var native = library["downloads"]?["classifiers"]?[classifier]
+                    ?? throw new PlatformNotSupportedException($"该版本未提供 {platform.DisplayName} 原生库:{library["name"]}");
+                var path = LauncherPaths.SafeChild(paths.Libraries, native["path"]!.GetValue<string>());
+                files.Add(Artifact(native, path));
+                nativeArchives.Add((path, library["extract"]?["exclude"]?.AsArray().Select(e => e!.GetValue<string>()).ToArray() ?? ["META-INF/"]));
+            }
+        }
+
+        if (metadata["assetIndex"] is JsonNode indexInfo)
+        {
+            var indexPath = LauncherPaths.SafeChild(Path.Combine(paths.Assets, "indexes"), indexInfo["id"]!.GetValue<string>() + ".json");
+            await downloads.FetchAsync(Artifact(indexInfo, indexPath), cancellationToken);
+            var index = JsonNode.Parse(await File.ReadAllTextAsync(indexPath, cancellationToken))!;
+            foreach (var asset in index["objects"]!.AsObject())
+            {
+                var hash = asset.Value!["hash"]!.GetValue<string>();
+                if (hash.Length != 40 || hash.Any(c => !Uri.IsHexDigit(c))) throw new InvalidDataException("资源哈希格式不正确。");
+                files.Add(new($"https://resources.download.minecraft.net/{hash[..2]}/{hash}",
+                    LauncherPaths.SafeChild(Path.Combine(paths.Assets, "objects"), $"{hash[..2]}/{hash}"), hash, asset.Value["size"]!.GetValue<long>()));
+            }
+        }
+        if (!platform.IsMobile && metadata["logging"]?["client"]?["file"] is JsonNode logging)
+            files.Add(Artifact(logging, LauncherPaths.SafeChild(Path.Combine(paths.Assets, "log_configs"), logging["id"]!.GetValue<string>())));
+
+        var uniqueFiles = files.DistinctBy(f => f.Path).ToArray();
+        var completed = 0;
+        await Parallel.ForEachAsync(uniqueFiles, new ParallelOptions { MaxDegreeOfParallelism = 6, CancellationToken = cancellationToken },
+            async (file, token) =>
+            {
+                await downloads.FetchAsync(file, token);
+                progress?.Report(new($"下载资源 · {Path.GetFileName(file.Path)}", Interlocked.Increment(ref completed), uniqueFiles.Length));
+            });
+        var nativeDirectory = Path.Combine(paths.VersionDirectory(id), "natives", platform.Kind + "-" + platform.Architecture);
+        foreach (var archive in nativeArchives) ExtractNatives(archive.File, nativeDirectory, archive.Exclude);
+        await CopyLegacyAssetsAsync(metadata, cancellationToken);
+        progress?.Report(new(platform.IsMobile ? "游戏资源已下载。移动端原生运行时尚未接入。" : "游戏安装完成。", uniqueFiles.Length, uniqueFiles.Length));
+        return metadata;
+    }
+
+    private async Task CopyLegacyAssetsAsync(JsonObject metadata, CancellationToken cancellationToken)
+    {
+        var id = metadata["assetIndex"]?["id"]?.GetValue<string>();
+        if (id is null) return;
+        var index = JsonNode.Parse(await File.ReadAllTextAsync(LauncherPaths.SafeChild(Path.Combine(paths.Assets, "indexes"), id + ".json"), cancellationToken))!;
+        var destinations = new List<string>();
+        if (index["virtual"]?.GetValue<bool>() == true) destinations.Add(LauncherPaths.SafeChild(Path.Combine(paths.Assets, "virtual"), id));
+        if (index["map_to_resources"]?.GetValue<bool>() == true) destinations.Add(Path.Combine(paths.Root, "resources"));
+        foreach (var asset in index["objects"]!.AsObject())
+        {
+            if (destinations.Count == 0) break;
+            var hash = asset.Value!["hash"]!.GetValue<string>();
+            foreach (var destination in destinations)
+            {
+                cancellationToken.ThrowIfCancellationRequested();
+                var target = LauncherPaths.SafeChild(destination, asset.Key);
+                Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+                File.Copy(LauncherPaths.SafeChild(Path.Combine(paths.Assets, "objects"), $"{hash[..2]}/{hash}"), target, true);
+            }
+        }
+    }
+
+    public static DownloadFile Artifact(JsonNode node, string path) => new(node["url"]!.GetValue<string>(), path,
+        node["sha1"]?.GetValue<string>(), node["size"]?.GetValue<long>());
+
+    public static void ExtractNatives(string archivePath, string destination, string[] exclude)
+    {
+        using var archive = ZipFile.OpenRead(archivePath);
+        foreach (var entry in archive.Entries)
+        {
+            if (entry.FullName.EndsWith('/') || exclude.Any(p => entry.FullName.StartsWith(p, StringComparison.Ordinal))) continue;
+            var path = LauncherPaths.SafeChild(destination, entry.FullName);
+            Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+            entry.ExtractToFile(path, true);
+        }
+    }
+}

+ 49 - 0
CrossPlatform/PCL.Communist.Core/MinecraftRules.cs

@@ -0,0 +1,49 @@
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace PCL.Communist.Core;
+
+public static class MinecraftRules
+{
+    public static bool Allows(JsonNode? rules, PlatformInfo platform, IReadOnlyDictionary<string, bool>? features = null)
+    {
+        if (rules is not JsonArray entries) return true;
+        var allowed = false;
+        foreach (var entry in entries)
+        {
+            if (entry is null) continue;
+            var os = entry["os"];
+            if (os?["name"] is JsonNode name && name.GetValue<string>() != platform.MinecraftOs) continue;
+            if (os?["arch"] is JsonNode arch && !Match(arch.GetValue<string>(), platform.MinecraftArch)) continue;
+            if (os?["version"] is JsonNode version && !Match(version.GetValue<string>(), platform.Version)) continue;
+            if (entry["features"] is JsonObject required && required.Any(f =>
+                (features?.GetValueOrDefault(f.Key) ?? false) != (f.Value?.GetValue<bool>() ?? false))) continue;
+            allowed = entry["action"]?.GetValue<string>() == "allow";
+        }
+        return allowed;
+    }
+
+    private static bool Match(string pattern, string value) =>
+        Regex.IsMatch(value, pattern, RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
+
+    public static string? NativeClassifier(JsonNode library, PlatformInfo platform)
+    {
+        if (platform.IsMobile) throw new PlatformNotSupportedException("桌面原生库不适用于移动端。");
+        var classifier = library["natives"]?[platform.MinecraftOs]?.GetValue<string>();
+        return classifier?.Replace("${arch}", platform.Architecture == System.Runtime.InteropServices.Architecture.X86 ? "32" : "64");
+    }
+
+    public static void ValidateNativeArchitecture(JsonObject metadata, PlatformInfo platform)
+    {
+        if (platform.IsMobile || platform.Architecture != System.Runtime.InteropServices.Architecture.Arm64) return;
+        foreach (var library in metadata["libraries"]!.AsArray())
+        {
+            if (library is null || !Allows(library["rules"], platform)) continue;
+            var classifier = NativeClassifier(library, platform);
+            if (classifier is null || classifier.Contains("arm64", StringComparison.OrdinalIgnoreCase) || classifier.Contains("aarch64", StringComparison.OrdinalIgnoreCase)) continue;
+            throw new PlatformNotSupportedException(platform.Kind == PlatformKind.MacOS
+                ? "此旧版本仅提供 Intel Mac 原生库,请使用 Intel 构建与 Intel Java(需要 Rosetta),或选择提供 Apple Silicon 原生库的版本。"
+                : "此版本的旧式原生库没有标明 ARM64 支持,请使用匹配的 x64 构建与 Java,或选择提供 ARM64 原生库的版本。");
+        }
+    }
+}

+ 6 - 0
CrossPlatform/PCL.Communist.Core/PCL.Communist.Core.csproj

@@ -0,0 +1,6 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net10.0</TargetFramework>
+    <IsAotCompatible>true</IsAotCompatible>
+  </PropertyGroup>
+</Project>

+ 65 - 0
CrossPlatform/PCL.Communist.Core/PlatformInfo.cs

@@ -0,0 +1,65 @@
+using System.Runtime.InteropServices;
+
+namespace PCL.Communist.Core;
+
+public enum PlatformKind { Windows, MacOS, Linux, Android, IOS }
+
+public sealed record PlatformInfo(PlatformKind Kind, Architecture Architecture, string Version)
+{
+    public static PlatformInfo Current => new(
+        OperatingSystem.IsAndroid() ? PlatformKind.Android :
+        OperatingSystem.IsIOS() ? PlatformKind.IOS :
+        OperatingSystem.IsMacOS() ? PlatformKind.MacOS :
+        OperatingSystem.IsWindows() ? PlatformKind.Windows : PlatformKind.Linux,
+        RuntimeInformation.ProcessArchitecture, Environment.OSVersion.Version.ToString());
+
+    public bool IsMobile => Kind is PlatformKind.Android or PlatformKind.IOS;
+    public string DisplayName => Kind switch
+    {
+        PlatformKind.MacOS => $"macOS · {(Architecture == Architecture.Arm64 ? "Apple Silicon" : "Intel")}",
+        PlatformKind.IOS => "iOS / iPadOS",
+        _ => $"{Kind} · {Architecture}"
+    };
+    public string MinecraftOs => Kind switch
+    {
+        PlatformKind.Windows => "windows",
+        PlatformKind.MacOS => "osx",
+        PlatformKind.Linux => "linux",
+        _ => throw new PlatformNotSupportedException("移动端需要专用 Java、图形和输入运行时,不能使用桌面原生库。")
+    };
+    public string MinecraftArch => Architecture switch
+    {
+        Architecture.X64 => "x86_64", Architecture.X86 => "x86",
+        Architecture.Arm64 => "aarch64", Architecture.Arm => "arm",
+        _ => throw new PlatformNotSupportedException($"不支持的架构:{Architecture}")
+    };
+    public string ClasspathSeparator => Kind == PlatformKind.Windows ? ";" : ":";
+    public string JavaExecutable => Kind == PlatformKind.Windows ? "java.exe" : "java";
+}
+
+public sealed class LauncherPaths
+{
+    public LauncherPaths(string root) => Root = Path.GetFullPath(root);
+    public string Root { get; }
+    public string Versions => Path.Combine(Root, "versions");
+    public string Libraries => Path.Combine(Root, "libraries");
+    public string Assets => Path.Combine(Root, "assets");
+    public string Runtimes => Path.Combine(Root, "runtimes");
+    public string VersionDirectory(string id) => SafeChild(Versions, id);
+    public string VersionJson(string id) => Path.Combine(VersionDirectory(id), id + ".json");
+
+    public static LauncherPaths Default => new(Path.Combine(
+        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PCL Communist", "minecraft"));
+
+    public static string SafeChild(string root, string relative)
+    {
+        if (string.IsNullOrWhiteSpace(relative) || relative.Contains('\\') || relative.Contains(':') ||
+            relative.StartsWith('/') || relative.Split('/').Any(p => p is "." or ".." or ""))
+            throw new InvalidDataException($"不安全的相对路径:{relative}");
+        var path = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
+        var prefix = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)) + Path.DirectorySeparatorChar;
+        if (!path.StartsWith(prefix, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))
+            throw new InvalidDataException("文件路径超出游戏目录。");
+        return path;
+    }
+}

+ 21 - 0
CrossPlatform/PCL.Communist.Desktop/PCL.Communist.Desktop.csproj

@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <!--If you are willing to use platform-specific APIs, use conditional compilation.
+    See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
+    <TargetFramework>net10.0</TargetFramework>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <PropertyGroup>
+    <ApplicationManifest>app.manifest</ApplicationManifest>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Avalonia.Desktop" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\PCL.Communist\PCL.Communist.csproj" />
+  </ItemGroup>
+</Project>

+ 21 - 0
CrossPlatform/PCL.Communist.Desktop/Program.cs

@@ -0,0 +1,21 @@
+using System;
+using Avalonia;
+
+namespace PCL.Communist.Desktop;
+
+sealed class Program
+{
+    // Initialization code. Don't use any Avalonia, third-party APIs or any
+    // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+    // yet and stuff might break.
+    [STAThread]
+    public static void Main(string[] args) => BuildAvaloniaApp()
+        .StartWithClassicDesktopLifetime(args);
+
+    // Avalonia configuration, don't remove; also used by visual designer.
+    public static AppBuilder BuildAvaloniaApp()
+        => AppBuilder.Configure<App>()
+            .UsePlatformDetect()
+            .WithInterFont()
+            .LogToTrace();
+}

+ 18 - 0
CrossPlatform/PCL.Communist.Desktop/app.manifest

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+  <!-- This manifest is used on Windows only.
+       Don't remove it as it might cause problems with window transparency and embedded controls.
+       For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
+  <assemblyIdentity version="1.0.0.0" name="PCL.Communist.Desktop"/>
+
+  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+    <application>
+      <!-- A list of the Windows versions that this application has been tested on
+           and is designed to work with. Uncomment the appropriate elements
+           and Windows will automatically select the most compatible environment. -->
+
+      <!-- Windows 10 -->
+      <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
+    </application>
+  </compatibility>
+</assembly>

+ 11 - 0
CrossPlatform/PCL.Communist.Tests/PCL.Communist.Tests.csproj

@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net10.0</TargetFramework>
+    <OutputType>Exe</OutputType>
+  </PropertyGroup>
+  <ItemGroup>
+    <ProjectReference Include="../PCL.Communist/PCL.Communist.csproj" />
+    <PackageReference Include="Avalonia.Headless" />
+    <PackageReference Include="Avalonia.Desktop" />
+  </ItemGroup>
+</Project>

+ 254 - 0
CrossPlatform/PCL.Communist.Tests/Program.cs

@@ -0,0 +1,254 @@
+using System.IO.Compression;
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using PCL.Communist;
+using PCL.Communist.Core;
+using PCL.Communist.ViewModels;
+using PCL.Communist.Views;
+
+var root = Path.Combine(Path.GetTempPath(), "pcl-cross-tests-" + Guid.NewGuid().ToString("N"));
+Directory.CreateDirectory(root);
+var windows = new PlatformInfo(PlatformKind.Windows, Architecture.X64, "10.0");
+var mac = new PlatformInfo(PlatformKind.MacOS, Architecture.X64, "14.0");
+var apple = new PlatformInfo(PlatformKind.MacOS, Architecture.Arm64, "14.0");
+var linux = new PlatformInfo(PlatformKind.Linux, Architecture.X64, "6.1");
+var android = new PlatformInfo(PlatformKind.Android, Architecture.Arm64, "14");
+var ios = new PlatformInfo(PlatformKind.IOS, Architecture.Arm64, "17");
+var count = 0;
+void Check(bool value, string message)
+{
+    if (!value) throw new Exception("FAIL: " + message);
+    count++;
+    Console.WriteLine("PASS: " + message);
+}
+void Throws<T>(Action action, string message) where T : Exception
+{
+    try { action(); }
+    catch (T) { Check(true, message); return; }
+    throw new Exception("FAIL: " + message);
+}
+async Task ThrowsAsync<T>(Func<Task> action, string message) where T : Exception
+{
+    try { await action(); }
+    catch (T) { Check(true, message); return; }
+    throw new Exception("FAIL: " + message);
+}
+JsonObject Json(string json) => JsonNode.Parse(json)!.AsObject();
+string Hash(byte[] bytes) => Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant();
+JsonObject DownloadNode(string url, byte[] data, string? path = null)
+{
+    var node = new JsonObject { ["url"] = url, ["sha1"] = Hash(data), ["size"] = data.Length };
+    if (path is not null) node["path"] = path;
+    return node;
+}
+byte[] NativeZip(string fileName)
+{
+    using var stream = new MemoryStream();
+    using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true))
+    {
+        using (var writer = new StreamWriter(zip.CreateEntry(fileName).Open())) writer.Write("native-fixture");
+        using (var writer = new StreamWriter(zip.CreateEntry("META-INF/ignored").Open())) writer.Write("excluded");
+    }
+    return stream.ToArray();
+}
+
+try
+{
+    Check(MinecraftRules.Allows(null, mac), "unconditional libraries are allowed");
+    var rules = JsonNode.Parse("""[{"action":"allow"},{"action":"disallow","os":{"name":"windows"}}]""");
+    Check(!MinecraftRules.Allows(rules, windows) && MinecraftRules.Allows(rules, mac), "rules use the last matching action");
+    var armRules = JsonNode.Parse("""[{"action":"allow","os":{"name":"osx","arch":"aarch64"}}]""");
+    Check(MinecraftRules.Allows(armRules, apple) && !MinecraftRules.Allows(armRules, mac), "Apple Silicon and Intel rules are distinct");
+    var features = JsonNode.Parse("""[{"action":"allow","features":{"is_demo_user":true}}]""");
+    Check(!MinecraftRules.Allows(features, linux), "demo and quick-play features default to disabled");
+    var nativeDefinition = Json("""{"natives":{"windows":"natives-windows-${arch}","osx":"natives-osx","linux":"natives-linux"}}""");
+    Check(MinecraftRules.NativeClassifier(nativeDefinition, mac) == "natives-osx", "macOS selects macOS natives");
+    Check(MinecraftRules.NativeClassifier(nativeDefinition, windows) == "natives-windows-64", "Windows classifier architecture substitution");
+    Throws<PlatformNotSupportedException>(() => MinecraftRules.NativeClassifier(nativeDefinition, android), "mobile cannot select desktop natives");
+    Check(JavaRuntimeService.RuntimePlatform(apple) == "mac-os-arm64" && JavaRuntimeService.RuntimePlatform(mac) == "mac-os", "Java runtime keys distinguish Mac architectures");
+    Throws<PlatformNotSupportedException>(() => JavaRuntimeService.RuntimePlatform(ios), "iOS cannot download a desktop JVM");
+    Check(AccountSession.Offline("Notch").Uuid == "b50ad385829d3141a2167e7d7539ba7f", "offline UUID matches Java nameUUIDFromBytes");
+    Throws<ArgumentException>(() => AccountSession.Offline("bad name;"), "offline player names are validated");
+
+    foreach (var unsafePath in new[] { "../escape", "/absolute", "C:/windows", "dir\\file", "a/../../b" })
+        Throws<InvalidDataException>(() => LauncherPaths.SafeChild(root, unsafePath), "reject path: " + unsafePath);
+
+    var metadata = Json("""{"id":"fixture","mainClass":"net.minecraft.client.main.Main","libraries":[{"downloads":{"artifact":{"path":"example/lib/1/lib.jar"}}}],"arguments":{"jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}",{"rules":[{"action":"allow","os":{"name":"windows"}}],"value":"-Dos.name=Windows 10"}],"game":["--username","${auth_player_name}","--gameDir","${game_directory}",{"rules":[{"action":"allow","features":{"is_demo_user":true}}],"value":"--demo"}]}}""");
+    var spacedPaths = new LauncherPaths(Path.Combine(root, "game with spaces"));
+    var account = AccountSession.Offline("Player");
+    var macPlan = GameLauncher.BuildPlan(metadata, mac, spacedPaths, "/jdk/bin/java", account, 2048);
+    var windowsPlan = GameLauncher.BuildPlan(metadata, windows, spacedPaths, "java.exe", account, 2048);
+    Check(macPlan.Arguments.Contains("-XstartOnFirstThread") && !macPlan.Arguments.Contains("-Dos.name=Windows 10"), "macOS starts Java on the first thread without Windows flags");
+    Check(windowsPlan.Arguments.Contains("-Dos.name=Windows 10") && !windowsPlan.Arguments.Contains("-XstartOnFirstThread"), "Windows JVM arguments stay intact");
+    Check(macPlan.Arguments[3].Contains(":") && windowsPlan.Arguments[3].Contains(";"), "classpath separators follow the target OS");
+    Check(macPlan.CreateStartInfo().ArgumentList.Contains(spacedPaths.Root) && !macPlan.CreateStartInfo().UseShellExecute, "paths with spaces use argument lists without a shell");
+    Check(!macPlan.Arguments.Contains("--demo") && !macPlan.Arguments.Any(a => a.Contains("${")), "conditional arguments and placeholders are resolved");
+    Throws<PlatformNotSupportedException>(() => GameLauncher.BuildPlan(metadata, ios, spacedPaths, "java", account, 2048), "iOS is prevented from invoking desktop Java");
+    Throws<InvalidOperationException>(() => GameLauncher.BuildPlan(metadata, mac, spacedPaths, "java", account with { ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) }, 2048), "expired sessions require login");
+
+    var bytes = Encoding.UTF8.GetBytes("verified content");
+    var badBytes = Encoding.UTF8.GetBytes("corrupted mirror");
+    var handler = new FakeHandler(request => request.RequestUri!.Host == "bmclapi2.bangbang93.com" ? badBytes : bytes);
+    using (var http = new HttpClient(handler))
+    {
+        var downloader = new DownloadClient(http);
+        var target = Path.Combine(root, "download.bin");
+        await downloader.FetchAsync(new("https://libraries.minecraft.net/test", target, Hash(bytes), bytes.Length));
+        Check(File.ReadAllBytes(target).SequenceEqual(bytes) && handler.Requests.Count == 2, "corrupt mirror falls back to verified official bytes");
+        await downloader.FetchAsync(new("https://libraries.minecraft.net/test", target, Hash(bytes), bytes.Length));
+        Check(handler.Requests.Count == 2, "valid cached downloads skip the network");
+        Check(!Directory.EnumerateFiles(root, "*.part", SearchOption.AllDirectories).Any(), "downloads leave no partial files");
+        Throws<InvalidDataException>(() => downloader.Sources("http://insecure.example/file").ToArray(), "insecure download URLs are rejected");
+        using var cancel = new CancellationTokenSource();
+        cancel.Cancel();
+        await ThrowsAsync<OperationCanceledException>(() => downloader.FetchAsync(new("https://example.test/file", Path.Combine(root, "cancelled.bin")), cancel.Token), "cancelled downloads do not publish files");
+    }
+
+    var zipPath = Path.Combine(root, "natives.zip");
+    await File.WriteAllBytesAsync(zipPath, NativeZip("liblwjgl.dylib"));
+    var nativeRoot = Path.Combine(root, "native-output");
+    MinecraftInstaller.ExtractNatives(zipPath, nativeRoot, ["META-INF/"]);
+    Check(File.Exists(Path.Combine(nativeRoot, "liblwjgl.dylib")) && !Directory.Exists(Path.Combine(nativeRoot, "META-INF")), "native extraction honors exclusions");
+    await File.WriteAllBytesAsync(zipPath, NativeZip("../escape.dll"));
+    Throws<InvalidDataException>(() => MinecraftInstaller.ExtractNatives(zipPath, nativeRoot, []), "native ZIP cannot escape its destination");
+
+    var assetBytes = Encoding.UTF8.GetBytes("asset-data");
+    var assetHash = Hash(assetBytes);
+    var indexBytes = Encoding.UTF8.GetBytes(new JsonObject { ["objects"] = new JsonObject { ["sound/example.ogg"] = new JsonObject { ["hash"] = assetHash, ["size"] = assetBytes.Length } }, ["virtual"] = true }.ToJsonString());
+    var macZip = NativeZip("libfixture.dylib");
+    var winZip = NativeZip("fixture.dll");
+    var client = Encoding.UTF8.GetBytes("client-jar");
+    var library = Encoding.UTF8.GetBytes("library-jar");
+    var installMetadata = new JsonObject
+    {
+        ["id"] = "fixture", ["mainClass"] = "net.minecraft.client.main.Main", ["downloads"] = new JsonObject { ["client"] = DownloadNode("https://fixture.test/client", client) },
+        ["assetIndex"] = DownloadNode("https://fixture.test/index", indexBytes),
+        ["libraries"] = new JsonArray(new JsonObject { ["name"] = "fixture:natives:1", ["natives"] = nativeDefinition["natives"]!.DeepClone(), ["downloads"] = new JsonObject
+        {
+            ["artifact"] = DownloadNode("https://fixture.test/lib", library, "fixture/lib.jar"),
+            ["classifiers"] = new JsonObject { ["natives-osx"] = DownloadNode("https://fixture.test/mac", macZip, "fixture/mac.jar"), ["natives-windows-64"] = DownloadNode("https://fixture.test/win", winZip, "fixture/win.jar") }
+        } })
+    };
+    installMetadata["assetIndex"]!["id"] = "fixture";
+    var metadataBytes = Encoding.UTF8.GetBytes(installMetadata.ToJsonString());
+    var responses = new Dictionary<string, byte[]>
+    {
+        ["/metadata"] = metadataBytes, ["/client"] = client, ["/index"] = indexBytes,
+        ["/lib"] = library, ["/mac"] = macZip, ["/win"] = winZip, [$"/{assetHash[..2]}/{assetHash}"] = assetBytes
+    };
+    var installHandler = new FakeHandler(request => responses[request.RequestUri!.AbsolutePath]);
+    using (var http = new HttpClient(installHandler))
+    {
+        var downloader = new DownloadClient(http, false);
+        var version = new MinecraftVersion("fixture", "release", "https://fixture.test/metadata", Hash(metadataBytes));
+        var paths = new LauncherPaths(Path.Combine(root, "mac-install"));
+        await new MinecraftInstaller(downloader, paths).InstallAsync(version, mac);
+        Check(File.Exists(Path.Combine(paths.VersionDirectory("fixture"), "natives", "MacOS-X64", "libfixture.dylib")), "Mac installation extracts the correct native library");
+        Check(!installHandler.Requests.Any(u => u.AbsolutePath == "/win"), "Mac installation never downloads Windows natives");
+        Check(File.Exists(Path.Combine(paths.Assets, "virtual", "fixture", "sound", "example.ogg")), "legacy virtual assets are materialized");
+        await File.WriteAllTextAsync(Path.Combine(paths.Libraries, "fixture", "lib.jar"), "corrupt cached library");
+        await new MinecraftInstaller(downloader, paths).RepairAsync("fixture", mac);
+        Check(File.ReadAllBytes(Path.Combine(paths.Libraries, "fixture", "lib.jar")).SequenceEqual(library), "repair revalidates and replaces corrupt local libraries");
+        Throws<PlatformNotSupportedException>(() => MinecraftRules.ValidateNativeArchitecture(installMetadata, apple), "legacy Intel natives are rejected on Apple Silicon before installation");
+        var beforeMobile = installHandler.Requests.Count;
+        var mobilePaths = new LauncherPaths(Path.Combine(root, "mobile-install"));
+        await new MinecraftInstaller(downloader, mobilePaths).InstallAsync(version, android);
+        Check(!Directory.Exists(mobilePaths.Libraries) && !installHandler.Requests.Skip(beforeMobile).Any(u => u.AbsolutePath is "/win" or "/mac"), "mobile downloads resources without desktop libraries");
+        var mobileRuntime = new GameLauncher(android, mobilePaths, new JavaRuntimeService(downloader, mobilePaths));
+        Check(!mobileRuntime.Capability.CanLaunch, "mobile runtime capability reports unavailable honestly");
+        await ThrowsAsync<PlatformNotSupportedException>(() => mobileRuntime.LaunchAsync(metadata, account, null, 2048, null, CancellationToken.None), "mobile launch fails before any Java execution");
+    }
+
+    var loginRequests = new List<string>();
+    using (var http = new HttpClient(new FakeHandler(request =>
+    {
+        loginRequests.Add(request.RequestUri!.Host + request.RequestUri.AbsolutePath);
+        var response = request.RequestUri.AbsolutePath switch
+        {
+            "/consumers/oauth2/v2.0/devicecode" => """{"user_code":"ABCD1234","device_code":"private-device","verification_uri":"https://microsoft.com/link","expires_in":600,"interval":1}""",
+            "/consumers/oauth2/v2.0/token" => """{"access_token":"oauth-private"}""",
+            "/user/authenticate" => """{"Token":"xbl-private"}""",
+            "/xsts/authorize" => """{"Token":"xsts-private","DisplayClaims":{"xui":[{"uhs":"user-hash"}]}}""",
+            "/authentication/login_with_xbox" => """{"access_token":"mc-private","expires_in":3600}""",
+            "/entitlements/mcstore" => """{"items":[{"name":"game_minecraft"}]}""",
+            "/minecraft/profile" => """{"name":"TestPlayer","id":"00000000000000000000000000000000"}""",
+            _ => throw new Exception("Unexpected authentication request")
+        };
+        return Encoding.UTF8.GetBytes(response);
+    })))
+    {
+        DeviceCodePrompt? received = null;
+        var session = await new MicrosoftLogin(http).LoginAsync(prompt => received = prompt);
+        Check(received?.Code == "ABCD1234" && session.Name == "TestPlayer" && session.UserType == "msa", "device-code login completes Xbox, entitlement and Minecraft profile exchanges");
+        Check(loginRequests.IndexOf("api.minecraftservices.com/entitlements/mcstore") < loginRequests.IndexOf("api.minecraftservices.com/minecraft/profile"), "game ownership is checked before accepting the profile");
+        Check(session.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(50), "Microsoft sessions track token expiration");
+    }
+
+    if (args.Contains("--network"))
+    {
+        using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(3) };
+        var downloader = new DownloadClient(http, false);
+        var installer = new MinecraftInstaller(downloader, new LauncherPaths(Path.Combine(root, "network")));
+        var versions = await installer.GetVersionsAsync();
+        var version = versions.Single(v => v.Id == "1.21.1");
+        var versionPath = Path.Combine(root, "real-version.json");
+        await downloader.FetchAsync(new(version.Url, versionPath, version.Sha1));
+        var real = JsonNode.Parse(await File.ReadAllTextAsync(versionPath))!.AsObject();
+        var clientPath = Path.Combine(root, "real-client.jar");
+        await downloader.FetchAsync(MinecraftInstaller.Artifact(real["downloads"]!["client"]!, clientPath));
+        Check(new FileInfo(clientPath).Length > 10000000, "real Minecraft 1.21.1 client download passes SHA1 validation");
+        foreach (var platform in new[] { windows, mac, apple, linux })
+        {
+            var plan = GameLauncher.BuildPlan(real, platform, spacedPaths, "java", account, 2048);
+            Check(plan.Arguments.Count > 15 && !plan.Arguments.Any(a => a.Contains("${")), "real launch arguments resolve for " + platform.DisplayName);
+        }
+        if (args.Contains("--java"))
+        {
+            var java = await new JavaRuntimeService(downloader, new LauncherPaths(Path.Combine(root, "runtime-test"))).ResolveAsync(real, PlatformInfo.Current, null);
+            Check(java.Major == 21, "official Java 21 runtime installs and executes on the host");
+        }
+    }
+
+    // Headless rendering exercises the same compiled XAML and bindings as every native entry point.
+    AppBuilder.Configure<App>().UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).UseSkia().WithInterFont().SetupWithoutStarting();
+    var output = Path.GetFullPath(args.FirstOrDefault(a => a.StartsWith("--screenshots="))?.Split('=', 2)[1] ?? Path.Combine(root, "screenshots"));
+    Directory.CreateDirectory(output);
+    foreach (var (platform, width, height, file) in new[] { (mac, 820, 1000, "desktop.png"), (android, 390, 1100, "android.png"), (ios, 390, 1100, "ios.png") })
+    {
+        var model = new MainViewModel(platform, new LauncherPaths(Path.Combine(root, file, "minecraft")));
+        model.Versions.Add(new("1.21.1", "release", "https://example.test/version", ""));
+        model.SelectedVersion = model.Versions[0];
+        Check(model.LaunchCommand.CanExecute(null) == !platform.IsMobile, "launch button capability matches " + platform.DisplayName);
+        Check(model.Themes.Count == 17, "all 17 theme choices are available on " + platform.DisplayName);
+        var window = new Window { Width = width, Height = height, Content = new MainView { DataContext = model } };
+        window.Show();
+        Dispatcher.UIThread.RunJobs();
+        AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+        using var frame = window.CaptureRenderedFrame() ?? throw new Exception("Could not render " + file);
+        frame.Save(Path.Combine(output, file), PngBitmapEncoderOptions.Default);
+        Check(frame.PixelSize.Width == width, "rendered responsive view: " + file);
+        window.Close();
+    }
+    Console.WriteLine($"Completed {count} checks. Screenshots: {output}");
+}
+finally { Directory.Delete(root, true); }
+
+sealed class FakeHandler(Func<HttpRequestMessage, byte[]> respond) : HttpMessageHandler
+{
+    public List<Uri> Requests { get; } = [];
+    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        byte[] content;
+        lock (Requests) { Requests.Add(request.RequestUri!); content = respond(request); }
+        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(content) });
+    }
+}

+ 23 - 0
CrossPlatform/PCL.Communist.iOS/AppDelegate.cs

@@ -0,0 +1,23 @@
+using Foundation;
+using UIKit;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.iOS;
+using Avalonia.Media;
+
+namespace PCL.Communist.iOS;
+
+// The UIApplicationDelegate for the application. This class is responsible for launching the
+// User Interface of the application, as well as listening (and optionally responding) to
+// application events from iOS.
+[Register("AppDelegate")]
+#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
+public partial class AppDelegate : AvaloniaAppDelegate<App>
+#pragma warning restore CA1711 // Identifiers should not have incorrect suffix
+{
+    protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
+    {
+        return base.CustomizeAppBuilder(builder)
+            .WithInterFont();
+    }
+}

+ 5 - 0
CrossPlatform/PCL.Communist.iOS/Entitlements.plist

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict/>
+</plist>

+ 39 - 0
CrossPlatform/PCL.Communist.iOS/Info.plist

@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDisplayName</key>
+	<string>PCL Communist</string>
+	<key>CFBundleIdentifier</key>
+	<string>org.pclcommunist.launcher</string>
+	<key>CFBundleShortVersionString</key>
+	<string>0.1.0</string>
+	<key>CFBundleVersion</key>
+	<string>1</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>MinimumOSVersion</key>
+	<string>13.0</string>
+	<key>UIDeviceFamily</key>
+	<array>
+		<integer>1</integer>
+		<integer>2</integer>
+	</array>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationPortraitUpsideDown</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>UISupportedInterfaceOrientations~ipad</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationPortraitUpsideDown</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+</dict>
+</plist>

+ 14 - 0
CrossPlatform/PCL.Communist.iOS/Main.cs

@@ -0,0 +1,14 @@
+using UIKit;
+
+namespace PCL.Communist.iOS;
+
+public class Application
+{
+    // This is the main entry point of the application.
+    static void Main(string[] args)
+    {
+        // if you want to use a different Application Delegate class from "AppDelegate"
+        // you can specify it here.
+        UIApplication.Main(args, null, typeof(AppDelegate));
+    }
+}

+ 21 - 0
CrossPlatform/PCL.Communist.iOS/PCL.Communist.iOS.csproj

@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net10.0-ios</TargetFramework>
+    <SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
+    <Nullable>enable</Nullable>
+    <ApplicationId>org.pclcommunist.launcher</ApplicationId>
+    <ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
+    <ApplicationVersion>1</ApplicationVersion>
+    <CodesignEntitlements>Entitlements.plist</CodesignEntitlements>
+    <AppIcon>AppIcon</AppIcon>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Avalonia.iOS" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\PCL.Communist\PCL.Communist.csproj" />
+  </ItemGroup>
+</Project>

+ 1 - 0
CrossPlatform/PCL.Communist.iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1 @@
+{"images": [{"idiom": "universal", "platform": "ios", "size": "1024x1024", "filename": "Icon-1024.png"}], "info": {"version": 1, "author": "xcode"}}

BIN
CrossPlatform/PCL.Communist.iOS/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png


+ 1 - 0
CrossPlatform/PCL.Communist.iOS/Resources/Assets.xcassets/Contents.json

@@ -0,0 +1 @@
+{"info": {"version": 1, "author": "xcode"}}

+ 43 - 0
CrossPlatform/PCL.Communist.iOS/Resources/LaunchScreen.xib

@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="6214" systemVersion="14A314h" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES">
+	<dependencies>
+		<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="6207" />
+		<capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1" />
+	</dependencies>
+	<objects>
+		<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" />
+		<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder" />
+		<view contentMode="scaleToFill" id="iN0-l3-epB">
+			<rect key="frame" x="0.0" y="0.0" width="480" height="480" />
+			<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES" />
+			<subviews>
+				<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="让所有颜色属于所有人。" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines"
+					minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="8ie-xW-0ye">
+					<rect key="frame" x="20" y="439" width="441" height="21" />
+					<fontDescription key="fontDescription" type="system" pointSize="17" />
+					<color key="textColor" red="0.94" green="0.88" blue="0.81" alpha="1" colorSpace="custom" customColorSpace="sRGB" />
+					<nil key="highlightedColor" />
+				</label>
+				<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="PCL Communist" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines"
+					minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="kId-c2-rCX">
+					<rect key="frame" x="20" y="140" width="441" height="43" />
+					<fontDescription key="fontDescription" type="boldSystem" pointSize="36" />
+					<color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB" />
+					<nil key="highlightedColor" />
+				</label>
+			</subviews>
+			<color key="backgroundColor" red="0.1" green="0.07" blue="0.1" alpha="1" colorSpace="custom" customColorSpace="sRGB" />
+			<constraints>
+				<constraint firstItem="kId-c2-rCX" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="bottom" multiplier="1/3" constant="1" id="5cJ-9S-tgC" />
+				<constraint firstAttribute="centerX" secondItem="kId-c2-rCX" secondAttribute="centerX" id="Koa-jz-hwk" />
+				<constraint firstAttribute="bottom" secondItem="8ie-xW-0ye" secondAttribute="bottom" constant="20" id="Kzo-t9-V3l" />
+				<constraint firstItem="8ie-xW-0ye" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="MfP-vx-nX0" />
+				<constraint firstAttribute="centerX" secondItem="8ie-xW-0ye" secondAttribute="centerX" id="ZEH-qu-HZ9" />
+				<constraint firstItem="kId-c2-rCX" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="fvb-Df-36g" />
+			</constraints>
+			<nil key="simulatedStatusBarMetrics" />
+			<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics" />
+			<point key="canvasLocation" x="548" y="455" />
+		</view>
+	</objects>
+</document>

+ 6 - 0
CrossPlatform/PCL.Communist.slnx

@@ -0,0 +1,6 @@
+<Solution>
+  <Project Path="PCL.Communist.Core/PCL.Communist.Core.csproj" />
+  <Project Path="PCL.Communist/PCL.Communist.csproj" />
+  <Project Path="PCL.Communist.Desktop/PCL.Communist.Desktop.csproj" />
+  <Project Path="PCL.Communist.Tests/PCL.Communist.Tests.csproj" />
+</Solution>

+ 10 - 0
CrossPlatform/PCL.Communist/App.axaml

@@ -0,0 +1,10 @@
+<Application xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             x:Class="PCL.Communist.App"
+             RequestedThemeVariant="Dark">
+             <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+
+    <Application.Styles>
+        <FluentTheme />
+    </Application.Styles>
+</Application>

+ 41 - 0
CrossPlatform/PCL.Communist/App.axaml.cs

@@ -0,0 +1,41 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using PCL.Communist.ViewModels;
+using PCL.Communist.Views;
+
+namespace PCL.Communist;
+
+public partial class App : Application
+{
+    private MainViewModel? _viewModel;
+    public override void Initialize()
+    {
+        AvaloniaXamlLoader.Load(this);
+    }
+
+    public override void OnFrameworkInitializationCompleted()
+    {
+        _viewModel ??= new MainViewModel();
+        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+        {
+            desktop.MainWindow = new MainWindow
+            {
+                DataContext = _viewModel
+            };
+        }
+        else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
+        {
+            singleViewFactoryApplicationLifetime.MainViewFactory = () => new MainView { DataContext = _viewModel };
+        }
+        else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
+        {
+            singleViewPlatform.MainView = new MainView
+            {
+                DataContext = _viewModel
+            };
+        }
+
+        base.OnFrameworkInitializationCompleted();
+    }
+}

BIN
CrossPlatform/PCL.Communist/Assets/Icon.png


BIN
CrossPlatform/PCL.Communist/Assets/Launcher.icns


+ 21 - 0
CrossPlatform/PCL.Communist/PCL.Communist.csproj

@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net10.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <LangVersion>latest</LangVersion>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <AvaloniaResource Include="Assets\**" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Avalonia" />
+    <PackageReference Include="Avalonia.Themes.Fluent" />
+    <PackageReference Include="Avalonia.Fonts.Inter" />
+    <PackageReference Include="CommunityToolkit.Mvvm" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\PCL.Communist.Core\PCL.Communist.Core.csproj" />
+  </ItemGroup>
+</Project>

+ 233 - 0
CrossPlatform/PCL.Communist/ViewModels/MainViewModel.cs

@@ -0,0 +1,233 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Nodes;
+using Avalonia.Media;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PCL.Communist.Core;
+
+namespace PCL.Communist.ViewModels;
+
+public sealed record LauncherTheme(string Name, string Color)
+{
+    public override string ToString() => Name;
+}
+
+public partial class MainViewModel : ViewModelBase
+{
+    private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(3) };
+    private readonly PlatformInfo _platform;
+    private LauncherPaths _paths;
+    private readonly string _settingsPath;
+    private CancellationTokenSource? _operation;
+    private AccountSession? _account;
+
+    public MainViewModel() : this(PlatformInfo.Current, LauncherPaths.Default) { }
+
+    public MainViewModel(PlatformInfo platform, LauncherPaths paths)
+    {
+        _platform = platform;
+        _paths = paths;
+        _settingsPath = Path.Combine(Path.GetDirectoryName(paths.Root)!, "launcher-settings.json");
+        GameRoot = paths.Root;
+        _http.DefaultRequestHeaders.UserAgent.ParseAdd("PCL-Communist/0.1.0");
+        SelectedTheme = Themes[0];
+        LoadSettings();
+        LoadInstalledVersions();
+    }
+
+    public string PlatformName => _platform.DisplayName;
+    public bool IsDesktop => !_platform.IsMobile;
+    public string RuntimeDescription => Runtime.Capability.Description;
+    public string LaunchLabel => IsDesktop ? "启动游戏  →" : "本机运行时待接入";
+    public string MobileNotice => "资源可以在本机下载和校验。游戏画面、Java 和触控运行时尚待接入,当前构建暂不能在手机或 iPad 上启动游戏。";
+    public bool IsMobile => _platform.IsMobile;
+    public ObservableCollection<MinecraftVersion> Versions { get; } = [];
+    public IReadOnlyList<LauncherTheme> Themes { get; } =
+    [
+        new("苏维埃红", "#C93746"), new("天空蓝", "#3E87CD"), new("秋叶金", "#B98A35"),
+        new("清水绿", "#348F86"), new("森林绿", "#4C9560"), new("星空紫", "#8062BE"),
+        new("樱花粉", "#C96794"), new("落日橙", "#D7753B"), new("深海蓝", "#425AB0"),
+        new("薄荷青", "#329D9C"), new("玫瑰红", "#BA476F"), new("丁香紫", "#A175BF"),
+        new("琥珀金", "#AD7C2A"), new("青瓷绿", "#509285"), new("冰川蓝", "#548DB2"),
+        new("石墨灰", "#78818F"), new("午夜紫", "#5F4D99")
+    ];
+
+    [ObservableProperty]
+    [NotifyCanExecuteChangedFor(nameof(InstallCommand))]
+    [NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
+    public partial MinecraftVersion? SelectedVersion { get; set; }
+
+    [ObservableProperty]
+    [NotifyCanExecuteChangedFor(nameof(RefreshCommand))]
+    [NotifyCanExecuteChangedFor(nameof(InstallCommand))]
+    [NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
+    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
+    [NotifyCanExecuteChangedFor(nameof(LogoutCommand))]
+    [NotifyCanExecuteChangedFor(nameof(SaveSettingsCommand))]
+    public partial bool IsBusy { get; set; }
+
+    [ObservableProperty] public partial string Status { get; set; } = "选择已安装版本,或刷新列表下载游戏。";
+    [ObservableProperty] public partial double Progress { get; set; }
+    [ObservableProperty] public partial string PlayerName { get; set; } = "Player";
+    [ObservableProperty] public partial string AccountStatus { get; set; } = "离线账号 · 可登录微软正版账号";
+    [ObservableProperty] public partial string LoginCode { get; set; } = "";
+    [ObservableProperty] public partial bool ShowLoginCode { get; set; }
+    [ObservableProperty] public partial string LoginAddress { get; set; } = "https://microsoft.com/link";
+    [ObservableProperty] public partial string GameRoot { get; set; } = "";
+    [ObservableProperty] public partial string JavaPath { get; set; } = "";
+    [ObservableProperty] public partial decimal MemoryMb { get; set; } = 2048;
+    [ObservableProperty] public partial LauncherTheme SelectedTheme { get; set; } = null!;
+    public IBrush AccentBrush => Brush.Parse(SelectedTheme?.Color ?? "#C93746");
+
+    partial void OnSelectedThemeChanged(LauncherTheme value) => OnPropertyChanged(nameof(AccentBrush));
+    private bool CanWork() => !IsBusy;
+    private bool CanInstall() => !IsBusy && SelectedVersion is not null;
+    private bool CanLaunch() => CanInstall() && IsDesktop;
+    private DownloadClient Downloads => new(_http);
+    private MinecraftInstaller Installer => new(Downloads, _paths);
+    private GameLauncher Runtime => new(_platform, _paths, new JavaRuntimeService(Downloads, _paths));
+    private IProgress<InstallProgress> InstallProgress => new Progress<InstallProgress>(p => { Status = p.Message; Progress = p.Percent; });
+
+    [RelayCommand(CanExecute = nameof(CanWork))]
+    private Task RefreshAsync() => RunAsync(async token =>
+    {
+        Status = "获取可用版本…";
+        var versions = await Installer.GetVersionsAsync(token);
+        var selected = SelectedVersion?.Id;
+        Versions.Clear();
+        foreach (var version in versions.Where(v => v.Type == "release")) Versions.Add(version);
+        SelectedVersion = Versions.FirstOrDefault(v => v.Id == selected) ?? Versions.FirstOrDefault();
+        Status = $"已获取 {Versions.Count} 个正式版本。";
+    });
+
+    [RelayCommand(CanExecute = nameof(CanInstall))]
+    private Task InstallAsync() => RunAsync(async token =>
+    {
+        ApplySettings();
+        if (SelectedVersion is { Url.Length: 0 } local)
+            await Installer.RepairAsync(local.Id, _platform, InstallProgress, token);
+        else await InstallSelectedAsync(token);
+    });
+
+    private async Task<JsonObject> InstallSelectedAsync(CancellationToken token)
+    {
+        var version = SelectedVersion ?? throw new InvalidOperationException("请先选择游戏版本。");
+        // Complete cached installations work offline; interrupted downloads are repaired before launching.
+        if (string.IsNullOrEmpty(version.Url))
+            return await Installer.RepairAsync(version.Id, _platform, InstallProgress, token);
+        return await Installer.InstallAsync(version, _platform, InstallProgress, token);
+    }
+
+    [RelayCommand(CanExecute = nameof(CanLaunch))]
+    private Task LaunchAsync() => RunAsync(async token =>
+    {
+        ApplySettings();
+        var account = _account ?? AccountSession.Offline(PlayerName.Trim());
+        var metadata = await InstallSelectedAsync(token);
+        Status = "准备游戏运行时…";
+        await Runtime.LaunchAsync(metadata, account, JavaPath.Trim(), (int)MemoryMb, InstallProgress, token);
+        Status = "游戏进程已启动。";
+    });
+
+    [RelayCommand(CanExecute = nameof(CanWork))]
+    private Task LoginAsync() => RunAsync(async token =>
+    {
+        Status = "正在准备微软登录…";
+        try
+        {
+            _account = await new MicrosoftLogin(_http).LoginAsync(prompt =>
+            {
+                LoginCode = prompt.Code;
+                LoginAddress = prompt.VerificationUri.AbsoluteUri;
+                ShowLoginCode = true;
+                Status = "打开登录网页,输入上方代码完成授权。";
+            }, token);
+            PlayerName = _account.Name;
+            AccountStatus = "正版账号 · " + _account.Name;
+            Status = "微软登录成功,本次会话可直接启动游戏。";
+        }
+        finally { ShowLoginCode = false; }
+    });
+
+    [RelayCommand(CanExecute = nameof(CanWork))]
+    private void Logout()
+    {
+        _account = null;
+        AccountStatus = "离线账号 · 可登录微软正版账号";
+        Status = "已退出账号。";
+    }
+
+    [RelayCommand]
+    private void Cancel() => _operation?.Cancel();
+
+    [RelayCommand(CanExecute = nameof(CanWork))]
+    private void SaveSettings()
+    {
+        try { ApplySettings(); LoadInstalledVersions(); Status = "设置已保存。"; }
+        catch (Exception ex) { Status = ex.Message; }
+    }
+
+    private void ApplySettings()
+    {
+        if (IsDesktop)
+        {
+            if (!Path.IsPathFullyQualified(GameRoot)) throw new ArgumentException("游戏目录需要填写绝对路径。");
+            _paths = new LauncherPaths(GameRoot.Trim());
+        }
+        Directory.CreateDirectory(_paths.Root);
+        MemoryMb = Math.Clamp(MemoryMb, 512, 32768);
+        var settings = new JsonObject
+        {
+            ["gameRoot"] = _paths.Root, ["javaPath"] = JavaPath, ["memoryMb"] = (int)MemoryMb,
+            ["theme"] = SelectedTheme.Name, ["playerName"] = _account is null ? PlayerName : "Player"
+        };
+        Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
+        var temporary = _settingsPath + ".tmp";
+        File.WriteAllText(temporary, settings.ToJsonString());
+        File.Move(temporary, _settingsPath, true);
+    }
+
+    private void LoadSettings()
+    {
+        if (!File.Exists(_settingsPath)) return;
+        try
+        {
+            var settings = JsonNode.Parse(File.ReadAllText(_settingsPath))!;
+            var root = settings["gameRoot"]?.GetValue<string>();
+            if (IsDesktop && root is not null && Path.IsPathFullyQualified(root)) { _paths = new(root); GameRoot = root; }
+            JavaPath = settings["javaPath"]?.GetValue<string>() ?? "";
+            MemoryMb = Math.Clamp(settings["memoryMb"]?.GetValue<int>() ?? 2048, 512, 32768);
+            PlayerName = settings["playerName"]?.GetValue<string>() ?? "Player";
+            SelectedTheme = Themes.FirstOrDefault(t => t.Name == settings["theme"]?.GetValue<string>()) ?? Themes[0];
+        }
+        catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException or InvalidOperationException)
+        { Status = "设置文件无法读取,已使用默认设置。"; }
+    }
+
+    private void LoadInstalledVersions()
+    {
+        Versions.Clear();
+        SelectedVersion = null;
+        if (!Directory.Exists(_paths.Versions)) return;
+        foreach (var directory in Directory.EnumerateDirectories(_paths.Versions).OrderDescending())
+        {
+            var id = Path.GetFileName(directory);
+            if (File.Exists(Path.Combine(directory, id + ".json")) && File.Exists(Path.Combine(directory, id + ".jar")))
+                Versions.Add(new(id, "installed", "", ""));
+        }
+        SelectedVersion = Versions.FirstOrDefault();
+    }
+
+    private async Task RunAsync(Func<CancellationToken, Task> operation)
+    {
+        if (IsBusy) return;
+        IsBusy = true;
+        Progress = 0;
+        using var cancellation = new CancellationTokenSource();
+        _operation = cancellation;
+        try { await operation(cancellation.Token); }
+        catch (OperationCanceledException) { Status = "操作已取消,可重新开始。"; }
+        catch (Exception ex) { Status = ex.Message; }
+        finally { _operation = null; IsBusy = false; }
+    }
+}

+ 7 - 0
CrossPlatform/PCL.Communist/ViewModels/ViewModelBase.cs

@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace PCL.Communist.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}

+ 108 - 0
CrossPlatform/PCL.Communist/Views/MainView.axaml

@@ -0,0 +1,108 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:vm="using:PCL.Communist.ViewModels"
+             xmlns:core="using:PCL.Communist.Core"
+             x:Class="PCL.Communist.Views.MainView"
+             x:DataType="vm:MainViewModel" Background="#120F13">
+  <UserControl.Styles>
+    <Style Selector="TextBlock"><Setter Property="Foreground" Value="#F5F0F2" /></Style>
+    <Style Selector="TextBlock.muted"><Setter Property="Foreground" Value="#B6A9B1" /><Setter Property="FontSize" Value="12" /></Style>
+    <Style Selector="TextBlock.label"><Setter Property="FontWeight" Value="SemiBold" /><Setter Property="Margin" Value="0,0,0,8" /></Style>
+    <Style Selector="Border.card"><Setter Property="Background" Value="#211A21" /><Setter Property="BorderBrush" Value="#3B2B35" /><Setter Property="BorderThickness" Value="1" /><Setter Property="CornerRadius" Value="16" /><Setter Property="Padding" Value="22" /></Style>
+    <Style Selector="Button"><Setter Property="MinHeight" Value="44" /><Setter Property="CornerRadius" Value="8" /></Style>
+    <Style Selector="TextBox"><Setter Property="MinHeight" Value="44" /><Setter Property="CornerRadius" Value="8" /></Style>
+    <Style Selector="ComboBox"><Setter Property="MinHeight" Value="44" /><Setter Property="CornerRadius" Value="8" /></Style>
+  </UserControl.Styles>
+  <Grid RowDefinitions="Auto,*">
+    <Border Padding="22,16" Background="#1B141B" BorderBrush="#382630" BorderThickness="0,0,0,1">
+      <Grid ColumnDefinitions="Auto,*">
+        <Image Source="/Assets/Icon.png" Width="42" Height="42" Margin="0,0,12,0" />
+        <StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="3">
+          <TextBlock Text="PCL Communist" FontWeight="Bold" FontSize="20" />
+          <TextBlock Text="让所有颜色属于所有人。" Classes="muted" />
+        </StackPanel>
+      </Grid>
+    </Border>
+    <ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Disabled">
+      <StackPanel Margin="20,24" Spacing="18" MaxWidth="920" HorizontalAlignment="Stretch">
+        <Border Background="{Binding AccentBrush}" CornerRadius="18" Padding="24,26">
+          <StackPanel Spacing="12">
+            <TextBlock Text="每个世界,都属于你。" FontSize="27" FontWeight="Bold" TextWrapping="Wrap" />
+            <TextBlock Text="自由选择颜色,下载游戏,开始探索。" TextWrapping="Wrap" Foreground="#FFF2E7" />
+            <TextBlock Text="{Binding PlatformName}" FontSize="12" FontWeight="SemiBold" />
+          </StackPanel>
+        </Border>
+        <Border Classes="card" IsVisible="{Binding IsMobile}" BorderBrush="#846138">
+          <StackPanel Spacing="8">
+            <TextBlock Text="移动端预览" Foreground="#E7BE77" FontWeight="Bold" />
+            <TextBlock Text="{Binding MobileNotice}" TextWrapping="Wrap" FontSize="13" />
+          </StackPanel>
+        </Border>
+        <Border Classes="card">
+          <StackPanel Spacing="14">
+            <TextBlock Text="准备出发" FontSize="20" FontWeight="SemiBold" />
+            <TextBlock Text="游戏版本" Classes="label" />
+            <Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
+              <ComboBox ItemsSource="{Binding Versions}" SelectedItem="{Binding SelectedVersion}" HorizontalAlignment="Stretch" IsEnabled="{Binding !IsBusy}" PlaceholderText="刷新列表以选择版本">
+                <ComboBox.ItemTemplate><DataTemplate x:DataType="core:MinecraftVersion"><TextBlock Text="{Binding Id}" /></DataTemplate></ComboBox.ItemTemplate>
+              </ComboBox>
+              <Button Grid.Column="1" Content="刷新" Command="{Binding RefreshCommand}" />
+            </Grid>
+            <TextBlock Text="玩家名称" Classes="label" />
+            <TextBox Text="{Binding PlayerName}" PlaceholderText="Player" IsEnabled="{Binding !IsBusy}" MaxLength="16" />
+            <TextBlock Text="{Binding AccountStatus}" Classes="muted" TextWrapping="Wrap" />
+            <WrapPanel Orientation="Horizontal">
+              <Button Content="微软账号登录" Command="{Binding LoginCommand}" Margin="0,0,10,8" />
+              <Button Content="退出账号" Command="{Binding LogoutCommand}" Margin="0,0,0,8" />
+            </WrapPanel>
+            <Border Background="#30212A" Padding="16" CornerRadius="10" IsVisible="{Binding ShowLoginCode}">
+              <StackPanel Spacing="10">
+                <TextBlock Text="在登录网页输入此代码" />
+                <SelectableTextBlock Text="{Binding LoginCode}" FontSize="28" FontWeight="Bold" Foreground="#F2D29B" />
+                <Button Content="打开微软登录网页" Click="OpenLoginSite" />
+                <SelectableTextBlock Text="{Binding LoginAddress}" TextWrapping="Wrap" FontSize="12" />
+              </StackPanel>
+            </Border>
+            <Grid ColumnDefinitions="*,*" ColumnSpacing="10">
+              <Button Content="下载 / 校验" Command="{Binding InstallCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" />
+              <Button Grid.Column="1" Content="{Binding LaunchLabel}" Command="{Binding LaunchCommand}" Background="{Binding AccentBrush}" Foreground="White" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" FontWeight="Bold" />
+            </Grid>
+            <TextBlock Text="{Binding RuntimeDescription}" Classes="muted" TextWrapping="Wrap" />
+          </StackPanel>
+        </Border>
+        <Border Classes="card">
+          <StackPanel Spacing="12">
+            <Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
+              <TextBlock Text="{Binding Status}" TextWrapping="Wrap" VerticalAlignment="Center" FontSize="13" />
+              <Button Grid.Column="1" Content="取消" Command="{Binding CancelCommand}" IsVisible="{Binding IsBusy}" />
+            </Grid>
+            <ProgressBar Value="{Binding Progress}" Minimum="0" Maximum="100" Height="5" Foreground="{Binding AccentBrush}" />
+          </StackPanel>
+        </Border>
+        <Border Classes="card">
+          <StackPanel Spacing="14">
+            <TextBlock Text="你的启动器,你做主" FontSize="18" FontWeight="SemiBold" />
+            <TextBlock Text="主题颜色 · 全部开放" Classes="label" />
+            <ComboBox ItemsSource="{Binding Themes}" SelectedItem="{Binding SelectedTheme}" HorizontalAlignment="Stretch" IsEnabled="{Binding !IsBusy}">
+              <ComboBox.ItemTemplate><DataTemplate x:DataType="vm:LauncherTheme"><TextBlock Text="{Binding Name}" /></DataTemplate></ComboBox.ItemTemplate>
+            </ComboBox>
+            <Expander Header="运行设置" HorizontalAlignment="Stretch">
+              <StackPanel Spacing="10">
+                <TextBlock Text="内存(MB)" Classes="label" />
+                <NumericUpDown Value="{Binding MemoryMb}" Minimum="512" Maximum="32768" Increment="512" FormatString="0" IsEnabled="{Binding !IsBusy}" />
+                <StackPanel IsVisible="{Binding IsDesktop}" Spacing="10">
+                  <TextBlock Text="Java 可执行文件(留空自动安装)" TextWrapping="Wrap" />
+                  <TextBox Text="{Binding JavaPath}" PlaceholderText="自动选择对应版本和架构" IsEnabled="{Binding !IsBusy}" />
+                  <TextBlock Text="游戏目录" />
+                  <TextBox Text="{Binding GameRoot}" IsEnabled="{Binding !IsBusy}" />
+                </StackPanel>
+              </StackPanel>
+            </Expander>
+            <Button Content="保存设置" Command="{Binding SaveSettingsCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" />
+          </StackPanel>
+        </Border>
+        <TextBlock Text="共产共享 · 免费使用 · 源码开放" HorizontalAlignment="Center" Classes="muted" Margin="0,4,0,12" />
+      </StackPanel>
+    </ScrollViewer>
+  </Grid>
+</UserControl>

+ 28 - 0
CrossPlatform/PCL.Communist/Views/MainView.axaml.cs

@@ -0,0 +1,28 @@
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using PCL.Communist.ViewModels;
+
+namespace PCL.Communist.Views;
+
+public partial class MainView : UserControl
+{
+    public MainView()
+    {
+        InitializeComponent();
+    }
+
+    private async void OpenLoginSite(object? sender, RoutedEventArgs e)
+    {
+        if (DataContext is not MainViewModel model || TopLevel.GetTopLevel(this) is not { } top) return;
+        try
+        {
+            var uri = new Uri(model.LoginAddress);
+            if (uri.Scheme != "https" || (uri.Host != "microsoft.com" && !uri.Host.EndsWith(".microsoft.com", StringComparison.Ordinal)))
+                throw new InvalidOperationException("登录网页地址无法验证,请重新发起微软登录。");
+            if (!await top.Launcher.LaunchUriAsync(uri)) model.Status = "请在浏览器打开显示的登录地址。";
+        }
+        catch (Exception ex) { model.Status = ex.Message; }
+    }
+}

+ 13 - 0
CrossPlatform/PCL.Communist/Views/MainWindow.axaml

@@ -0,0 +1,13 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:vm="using:PCL.Communist.ViewModels"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:views="using:PCL.Communist.Views"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="PCL.Communist.Views.MainWindow"
+        Width="820" Height="900" MinWidth="360" MinHeight="520"
+        Background="#120F13" Icon="/Assets/Icon.png"
+        Title="PCL Communist · 跨平台预览">
+        <views:MainView />
+</Window>

+ 11 - 0
CrossPlatform/PCL.Communist/Views/MainWindow.axaml.cs

@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace PCL.Communist.Views;
+
+public partial class MainWindow : Window
+{
+    public MainWindow()
+    {
+        InitializeComponent();
+    }
+}

+ 6 - 0
CrossPlatform/global.json

@@ -0,0 +1,6 @@
+{
+  "sdk": {
+    "version": "10.0.100",
+    "rollForward": "latestFeature"
+  }
+}

+ 7 - 6
README.md

@@ -6,7 +6,7 @@
 
 基于 Plain Craft Launcher 的第三方独立修改版。原作者为龙腾猫跃及 PCL 贡献者;本项目由社区独立维护。
 
-[下载单文件 EXE](https://github.com/Angelhellwolf/PCL-Communist/releases/latest) · [共产共享协议](COMMUNIST-AGREEMENT.txt) · [查看源代码](https://github.com/Angelhellwolf/PCL-Communist) · [反馈问题](https://github.com/Angelhellwolf/PCL-Communist/issues)
+[下载单文件 EXE](https://github.com/Angelhellwolf/PCL-Communist/releases/latest) · [下载跨平台预览](https://github.com/Angelhellwolf/PCL-Communist/releases/tag/v0.1.0-preview.1) · [共产共享协议](COMMUNIST-AGREEMENT.txt) · [查看源代码](https://github.com/Angelhellwolf/PCL-Communist) · [反馈问题](https://github.com/Angelhellwolf/PCL-Communist/issues)
 
 ![启动器主页](docs/images/launcher.png)
 
@@ -16,14 +16,15 @@
 
 ## 平台支持
 
-| 平台 | 发布状态 | 所需移植工作 |
+| 平台 | 当前状态 | 游戏启动与验证 |
 | --- | --- | --- |
 | Windows | 已发布单文件 EXE | 当前支持的平台 |
-| macOS(Intel / Apple Silicon) | 尚无可用构建 | 替换 Windows 界面与系统接口,适配 Java、游戏原生库及启动流程 |
-| Android | 尚无可用构建 | 移动端界面、Java 运行时、图形后端和触控适配 |
-| iOS / iPadOS | 尚无可用构建 | 移动端界面、Java 运行时、图形后端、输入与设备安装适配 |
+| macOS(Intel / Apple Silicon) | 新增独立跨平台预览工程与 `.app` 打包 | 已实现分架构 Java、原生库与启动参数;尚需 Mac 真机游戏验证 |
+| Linux | 新增共用桌面预览工程 | 已实现原版启动路径;核心与界面检查在 Linux 执行 |
+| Android | 新增原生预览工程,可构建 arm64 APK | 共用界面、登录与资源下载已移植;**游戏运行时尚未接入** |
+| iOS / iPadOS | 新增 iPhone/iPad 原生入口与模拟器构建工作流 | 需要 Mac/Xcode 编译;**游戏运行时尚未接入** |
 
-当前代码使用 .NET Framework 4.8、WPF 和 Windows Forms;[WPF 仅支持 Windows](https://learn.microsoft.com/dotnet/desktop/wpf/overview/)。游戏启动代码的系统规则与原生库选择、Java 下载也按 Windows 实现,因此其他平台需要单独移植。当前 Releases 中的 EXE 和完整源码包均为 Windows 版本,尚未完成 macOS、Android 或 iOS 的游戏启动验证。
+原 Windows 启动器使用 .NET Framework 4.8、WPF 和 Windows Forms;[WPF 仅支持 Windows](https://learn.microsoft.com/dotnet/desktop/wpf/overview/)。新移植工程位于 `CrossPlatform/`,使用 .NET 10 与 Avalonia,共用核心与界面,提供各系统原生入口。当前 Releases 中的 EXE 仍为 Windows 正式版;跨平台工程是功能尚未完整迁移的预览版,移动端当前不能启动游戏。构建方法、能力和验证边界见[跨平台预览说明](docs/cross-platform.md)。
 
 ## 功能
 

+ 76 - 0
docs/cross-platform.md

@@ -0,0 +1,76 @@
+# PCL Communist 跨平台预览
+
+这是独立于原 Windows WPF 程序的移植工程,位于 `CrossPlatform/`。共用 .NET 10 核心和 Avalonia 12.1.4 界面,并提供桌面、Android、iOS/iPadOS 原生入口。原 Windows 程序的全部功能尚未移植到此预览版。
+
+## 当前能力
+
+| 平台 | 应用工程与构建 | Minecraft Java 本机启动 |
+| --- | --- | --- |
+| Windows | 原 WPF 正式版继续可用;另有共用界面的桌面预览工程 | 正式版可用;预览版新增原版启动路径 |
+| macOS Intel | 自包含桌面程序和 `.app` 打包脚本 | 已实现 Java、系统规则、原生库、classpath 与主线程参数;需 Mac 真机验证 |
+| macOS Apple Silicon | 独立 `osx-arm64` 自包含构建 | 选择 ARM Java 与对应原生库;旧版 Intel 原生库会明确提示使用 Intel 构建与 Rosetta,需 Mac 真机验证 |
+| Linux | 共用桌面工程,可生成自包含包 | 已实现原版启动路径;核心和界面测试在 Linux 执行 |
+| Android arm64 | 原生应用工程,可生成预览 APK | **尚未接入移动端游戏运行时,不能在本机启动游戏** |
+| iOS / iPadOS | iPhone/iPad 共用原生入口和模拟器构建工作流 | **尚未接入移动端游戏运行时,不能在本机启动游戏**;编译需要 Mac 与 Xcode |
+
+预览界面提供 17 个开放的静态配色、正式版本列表、微软设备码登录、离线账号、游戏下载与 SHA1 校验、下载取消、已有游戏目录和设置保存。17 个配色是此界面的静态预设,尚未复制 WPF 版本的动态和自定义主题实现。
+
+下载默认优先 BMCLAPI,失败或哈希不符时回退官方源;不按地区限制客户端下载。跨平台核心没有 WPF、Windows Forms、注册表或 `cmd.exe` 依赖。启动命令使用参数列表,路径包含空格也无需 shell。Java 从 Mojang 运行时清单按系统、CPU 架构和游戏所需版本安装,并保留运行时原有许可文件。
+
+移动端目前只下载版本 JSON、客户端和资源文件,不会把 Windows、macOS、Linux 的桌面原生库当成手机运行时。启动按钮会明确禁用并说明缺少的运行时。`IGameRuntime` 预留本机运行时接口,接入时需要完成 JVM、游戏原生库、图形表面、输入与触控、生命周期,以及 iOS 的运行与设备安装条件;这些不包含在现有 APK 或 iOS 入口中。
+
+暂未移植 Forge/Fabric/NeoForge 安装、继承版本、模组、整合包、皮肤和账号长期保存。微软访问令牌仅保留在当前会话内,未写入普通 JSON;关闭程序后需要重新登录。微软真实账号授权需用户在官方页面完成。
+
+## 构建
+
+桌面和测试需要 .NET 10 SDK;打包还需 Python 3.11+。运行时已包含在桌面发布包中,用户无需安装 .NET SDK。
+
+```powershell
+# 运行离线检查并输出桌面和移动端宽度的截图
+pwsh ./tools/Build-CrossPlatform.ps1 -Target test
+
+# 额外真实下载 Minecraft 1.21.1 客户端并验证 SHA1
+pwsh ./tools/Build-CrossPlatform.ps1 -Target test -Network
+
+# 分别打包 Intel Mac、Apple Silicon
+pwsh ./tools/Build-CrossPlatform.ps1 -Target desktop -Runtime osx-x64
+pwsh ./tools/Build-CrossPlatform.ps1 -Target desktop -Runtime osx-arm64
+
+# Windows / Linux 同样使用 win-x64 / linux-x64
+pwsh ./tools/Build-CrossPlatform.ps1 -Target desktop -Runtime win-x64
+```
+
+结果在 `artifacts/cross-platform/`;桌面归档和 SHA256 清单在 `packages/`。macOS `.app` 包含 ICNS 图标,可跨系统生成;在 Mac 打包时会使用本机 ad-hoc 签名,尚未进行开发者签名或 Apple 公证。
+
+Android 需要 .NET Android workload、JDK 与 Android SDK:
+
+```powershell
+dotnet workload install android --skip-manifest-update
+dotnet build CrossPlatform/PCL.Communist.Android -t:InstallAndroidDependencies -f net10.0-android -p:AndroidSdkDirectory=/absolute/android-sdk -p:JavaSdkDirectory=/absolute/jdk -p:AcceptAndroidSDKLicenses=True
+pwsh ./tools/Build-CrossPlatform.ps1 -Target android -AndroidSdkDirectory /absolute/android-sdk -JavaSdkDirectory /absolute/jdk
+```
+
+生成的 `*-Signed.apk` 使用本机构建环境的调试密钥,仅用于预览。应用使用私有存储目录,要求 Android 6.0/API 23 或更高版本。
+
+iOS 模拟器构建需要 Mac、兼容的 Xcode 和 .NET iOS workload:
+
+```powershell
+dotnet workload install ios --skip-manifest-update
+pwsh ./tools/Build-CrossPlatform.ps1 -Target ios-simulator
+```
+
+模拟器产物不是可安装到 iPhone 的 IPA。真机构建还需匹配的设备签名与配置。工作流 `.github/workflows/cross-platform.yml` 分别构建 Windows、Linux、两种 Mac 架构、Android APK 和 iOS 模拟器应用;新增工作流本身不代表远程构建已经执行成功。
+
+## 验证边界
+
+自动化检查覆盖 OS/架构规则、条件参数、Mac 主线程启动、不同 classpath 分隔符、含空格路径、离线 UUID、会话过期、SHA1 与镜像回退、取消、ZIP 路径、正确的原生库、旧版虚拟资源目录、移动端禁止误用桌面启动流程,以及同一套 XAML 在桌面和手机宽度下的渲染。可加 `--network --java` 实际验证官方客户端和当前宿主的官方 Java 安装。
+
+编译成功、界面截图和启动参数通过检查都不等于各平台已经实机进入 Minecraft。macOS 和移动端需要在对应设备上继续验收;移动端还需要实现上文列出的原生游戏运行时。
+
+## 实现参考
+
+- Avalonia 官方跨平台模板:<https://github.com/AvaloniaUI/avalonia-dotnet-templates>
+- Mojang 版本清单:<https://piston-meta.mojang.com/mc/game/version_manifest_v2.json>
+- Microsoft 设备码流程:<https://learn.microsoft.com/entra/identity-platform/v2-oauth2-device-code>
+
+此目录新增代码适用仓库《共产共享协议》;Avalonia、.NET、游戏文件和 Java 运行时继续适用各自许可。游戏和 Java 按需下载,不包含在预览安装包内。

+ 55 - 0
tools/Build-CrossPlatform.ps1

@@ -0,0 +1,55 @@
+param(
+    [ValidateSet('desktop', 'android', 'ios-simulator', 'test')][string]$Target = 'desktop',
+    [ValidateSet('', 'win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64', 'android-arm64', 'android-x64', 'iossimulator-arm64', 'iossimulator-x64')][string]$Runtime = '',
+    [string]$DotnetPath = 'dotnet',
+    [string]$PythonPath = 'python',
+    [string]$AndroidSdkDirectory = '',
+    [string]$JavaSdkDirectory = '',
+    [switch]$Network
+)
+$ErrorActionPreference = 'Stop'
+$repo = Split-Path -Parent $PSScriptRoot
+$cross = Join-Path $repo 'CrossPlatform'
+$output = Join-Path $repo 'artifacts/cross-platform'
+
+function Invoke-Dotnet([string[]]$Arguments) {
+    & $DotnetPath @Arguments
+    if ($LASTEXITCODE -ne 0) { throw "Cross-platform $Target failed (exit $LASTEXITCODE)." }
+}
+
+Push-Location $cross
+try {
+    switch ($Target) {
+        'test' {
+            $arguments = @('run', '--project', 'PCL.Communist.Tests', '-c', 'Release', '--', "--screenshots=$output/qa")
+            if ($Network) { $arguments += '--network' }
+            Invoke-Dotnet $arguments
+        }
+        'desktop' {
+            if ($Runtime -notmatch '^(win|linux|osx)-(x64|arm64)$') { throw 'Specify a desktop -Runtime, for example osx-arm64.' }
+            $publish = Join-Path $output $Runtime
+            Invoke-Dotnet @('publish', 'PCL.Communist.Desktop', '-c', 'Release', '-r', $Runtime, '--self-contained', 'true', '-p:PublishTrimmed=false', '-o', $publish)
+            & $PythonPath (Join-Path $PSScriptRoot 'package_cross_platform.py') --runtime $Runtime --input $publish --output (Join-Path $output 'packages')
+            if ($LASTEXITCODE -ne 0) { throw 'Desktop packaging failed.' }
+        }
+        'android' {
+            if (-not $Runtime) { $Runtime = 'android-arm64' }
+            if ($Runtime -notmatch '^android-(arm64|x64)$') { throw 'Android requires android-arm64 or android-x64.' }
+            $arguments = @('publish', 'PCL.Communist.Android', '-c', 'Release', '-r', $Runtime, '-p:AndroidKeyStore=false', '-p:RunAOTCompilation=false', '-o', (Join-Path $output $Runtime))
+            if ($AndroidSdkDirectory) { $arguments += "-p:AndroidSdkDirectory=$AndroidSdkDirectory" }
+            if ($JavaSdkDirectory) { $arguments += "-p:JavaSdkDirectory=$JavaSdkDirectory" }
+            Invoke-Dotnet $arguments
+            Write-Output 'Preview APK uses the local debug signing key. Production signing is configured separately.'
+        }
+        'ios-simulator' {
+            if (-not [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::OSX)) {
+                throw 'An iOS simulator build requires macOS, Xcode and the .NET iOS workload.'
+            }
+            if (-not $Runtime) {
+                $Runtime = if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [Runtime.InteropServices.Architecture]::Arm64) { 'iossimulator-arm64' } else { 'iossimulator-x64' }
+            }
+            if ($Runtime -notmatch '^iossimulator-(arm64|x64)$') { throw 'Use an iOS simulator runtime.' }
+            Invoke-Dotnet @('build', 'PCL.Communist.iOS', '-c', 'Debug', '-r', $Runtime, '-p:EnableCodeSigning=false', '-o', (Join-Path $output $Runtime))
+        }
+    }
+} finally { Pop-Location }

+ 6 - 4
tools/Test-IsolatedProcess.ps1

@@ -4,7 +4,9 @@ param(
     [string]$ArgumentLine = '',
     [Parameter(Mandatory = $true)][string]$OutputDirectory,
     [int]$TimeoutSeconds = 30,
-    [switch]$ExpectMainWindow
+    [switch]$ExpectMainWindow,
+    [string]$ExpectedWindowTitle = 'PCL Communist',
+    [string]$ExpectedWindowClassPrefix = 'HwndWrapper'
 )
 $ErrorActionPreference = 'Stop'
 $Executable = [IO.Path]::GetFullPath($Executable)
@@ -55,7 +57,7 @@ public static class RedIsolatedTest {
     [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr window, out Rect rectangle);
     [DllImport("user32.dll")] private static extern bool PrintWindow(IntPtr window, IntPtr context, uint flags);
 
-    public static int Run(string executable, string arguments, string output, int seconds, bool expectWindow) {
+    public static int Run(string executable, string arguments, string output, int seconds, bool expectWindow, string expectedTitle, string expectedClassPrefix) {
         string name = "RedTest-" + Guid.NewGuid().ToString("N");
         IntPtr desktop = CreateDesktop(name, IntPtr.Zero, IntPtr.Zero, 0, 0x01FF, IntPtr.Zero);
         if (desktop == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
@@ -86,7 +88,7 @@ public static class RedIsolatedTest {
                 GetWindowText(window, title, title.Capacity);
                 GetClassName(window, kind, kind.Capacity);
                 windows.Add(kind + " | " + title);
-                if (title.ToString() == "PCL Communist" && kind.ToString().StartsWith("HwndWrapper")) main = window;
+                if (title.ToString() == expectedTitle && kind.ToString().StartsWith(expectedClassPrefix, StringComparison.Ordinal)) main = window;
                 if (kind.ToString() == "#32770") {
                     errorDialog = true;
                     EnumChildWindows(window, delegate(IntPtr child, IntPtr unused) {
@@ -128,6 +130,6 @@ public static class RedIsolatedTest {
 }
 '@
 }
-$testExitCode = [RedIsolatedTest]::Run($Executable, $ArgumentLine, $OutputDirectory, $TimeoutSeconds, $ExpectMainWindow.IsPresent)
+$testExitCode = [RedIsolatedTest]::Run($Executable, $ArgumentLine, $OutputDirectory, $TimeoutSeconds, $ExpectMainWindow.IsPresent, $ExpectedWindowTitle, $ExpectedWindowClassPrefix)
 if ($testExitCode -ne 0) { throw "Isolated test failed with exit code $testExitCode. See $OutputDirectory." }
 Write-Output "PASS: isolated process check. Results: $OutputDirectory"

+ 89 - 0
tools/package_cross_platform.py

@@ -0,0 +1,89 @@
+"""Package a self-contained desktop publish. No signing credentials are read."""
+import argparse
+import hashlib
+import os
+import platform
+from pathlib import Path
+import plistlib
+import shutil
+import subprocess
+import tarfile
+import zipfile
+
+
+def package(runtime: str, source: Path, output: Path) -> Path:
+    source = source.resolve()
+    output = output.resolve()
+    if not source.is_dir():
+        raise ValueError(f"Missing publish directory: {source}")
+    output.mkdir(parents=True, exist_ok=True)
+    repo = Path(__file__).resolve().parent.parent
+    name = f"PCL-Communist-0.1.0-preview-{runtime}"
+    stage = output / name
+    if stage.exists():
+        # This is a generated staging directory with a fixed child name.
+        if stage.parent != output or stage.is_symlink():
+            raise ValueError("Unexpected staging path")
+        shutil.rmtree(stage)
+    stage.mkdir()
+    if runtime.startswith("osx-"):
+        app = stage / "PCL Communist.app"
+        contents = app / "Contents"
+        executable_dir = contents / "MacOS"
+        resources = contents / "Resources"
+        shutil.copytree(source, executable_dir)
+        resources.mkdir()
+        executable = executable_dir / "PCL.Communist.Desktop"
+        executable.chmod(0o755)
+        plist = {
+            "CFBundleExecutable": executable.name,
+            "CFBundleIdentifier": "org.pclcommunist.launcher",
+            "CFBundleName": "PCL Communist",
+            "CFBundleDisplayName": "PCL Communist",
+            "CFBundlePackageType": "APPL",
+            "CFBundleShortVersionString": "0.1.0",
+            "CFBundleVersion": "1",
+            "LSMinimumSystemVersion": "12.0",
+            "NSHighResolutionCapable": True,
+            "NSRequiresAquaSystemAppearance": False,
+        }
+        shutil.copyfile(repo / "CrossPlatform/PCL.Communist/Assets/Launcher.icns", resources / "Launcher.icns")
+        plist["CFBundleIconFile"] = "Launcher.icns"
+        with (contents / "Info.plist").open("wb") as stream:
+            plistlib.dump(plist, stream)
+        shutil.copyfile(repo / "COMMUNIST-AGREEMENT.txt", resources / "COMMUNIST-AGREEMENT.txt")
+        shutil.copytree(repo / "licenses", resources / "licenses")
+        # Ad-hoc signing on a Mac makes the local bundle internally consistent; it is not Apple notarization.
+        if platform.system() == "Darwin":
+            subprocess.run(["codesign", "--force", "--deep", "--sign", "-", str(app)], check=True)
+    else:
+        shutil.copytree(source, stage, dirs_exist_ok=True)
+        shutil.copyfile(repo / "COMMUNIST-AGREEMENT.txt", stage / "COMMUNIST-AGREEMENT.txt")
+        shutil.copytree(repo / "licenses", stage / "licenses", dirs_exist_ok=True)
+        if runtime.startswith("linux-"):
+            (stage / "PCL.Communist.Desktop").chmod(0o755)
+    shutil.copyfile(repo / "docs/cross-platform.md", stage / "PREVIEW-README.md")
+    if runtime.startswith("win-"):
+        archive = output / f"{name}.zip"
+        with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zip_file:
+            for file in sorted(stage.rglob("*")):
+                if file.is_file():
+                    zip_file.write(file, file.relative_to(output))
+    else:
+        archive = output / f"{name}.tar.gz"
+        with tarfile.open(archive, "w:gz") as tar_file:
+            tar_file.add(stage, arcname=stage.name)
+    with archive.open("rb") as stream:
+        digest = hashlib.file_digest(stream, "sha256").hexdigest()
+    archive.with_suffix(archive.suffix + ".sha256").write_text(f"{digest}  {archive.name}\n", encoding="utf-8")
+    print(archive)
+    return archive
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--runtime", required=True, choices=["win-x64", "win-arm64", "linux-x64", "linux-arm64", "osx-x64", "osx-arm64"])
+    parser.add_argument("--input", required=True, type=Path)
+    parser.add_argument("--output", required=True, type=Path)
+    args = parser.parse_args()
+    package(args.runtime, args.input, args.output)