diff --git a/.github/Install-WindowsSdkISO.ps1 b/.github/Install-WindowsSdkISO.ps1
index f4964d23..e4918f5e 100644
--- a/.github/Install-WindowsSdkISO.ps1
+++ b/.github/Install-WindowsSdkISO.ps1
@@ -251,7 +251,7 @@ if ($InstallWindowsSDK)
if($buildNumber -eq 19041)
{
# Workaround for missing SDK
- $uri = "https://software-download.microsoft.com/download/pr/19041.1.191206-1406.vb_release_WindowsSDK.iso";
+ $uri = "https://go.microsoft.com/fwlink/?linkid=2120735";
}
if ($env:TEMP -eq $null)
@@ -311,4 +311,4 @@ if ($StrongNameHijack)
}
Write-Host "Done"
-}
+}
\ No newline at end of file
diff --git a/.github/steps/install_dependencies/action.yml b/.github/steps/install_dependencies/action.yml
index 5d9dc7cf..e62e7722 100644
--- a/.github/steps/install_dependencies/action.yml
+++ b/.github/steps/install_dependencies/action.yml
@@ -1,83 +1,71 @@
# .github/steps/install_dependencies/action.yml
name: Install Dependencies
-description: "Installs .NET SDK, OS-specific SDKs/dependencies, and Uno workloads"
+description: "Installs .NET SDK and OS-specific dependencies"
inputs:
job-platform:
- description: 'The target platform for the current job (windows, macos, linux)'
+ description: 'The target platform (windows, macos, linux)'
required: true
dotnet-version:
description: 'Installs and sets the .NET SDK Version'
required: false
- # UPDATED: Default to .NET 9.x based on project requirements
default: '9.0.x'
windows-sdk-version:
- description: 'The version of the Windows SDK to install (Windows only)'
+ description: 'Windows SDK version (Windows only)'
required: false
- # UPDATED: Default to match the project's Windows target framework (22621)
- default: '22621'
+ default: '19041'
runs:
using: "composite"
steps:
- # Install .NET SDK
+ # 1. Install .NET 9 SDK
- name: Setup .NET ${{ inputs.dotnet-version }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: '${{ inputs.dotnet-version }}'
- # Install Windows SDK (Windows Runner Only)
+ # 2. Install Windows SDK (Windows Only)
- name: Install Windows SDK ${{ inputs.windows-sdk-version }}
shell: pwsh
if: runner.os == 'Windows' && inputs.job-platform == 'windows'
run: |
- Write-Host "Attempting to install Windows SDK version: ${{ inputs.windows-sdk-version }}"
+ Write-Host "Installing Windows SDK version: ${{ inputs.windows-sdk-version }}"
$sdkIsoScript = Join-Path $env:GITHUB_WORKSPACE ".github\Install-WindowsSdkISO.ps1"
if (Test-Path $sdkIsoScript) {
- # Ensure your script can handle the specified version
& $sdkIsoScript ${{ inputs.windows-sdk-version }}
} else {
- Write-Warning "Windows SDK ISO installation script not found at $sdkIsoScript. Skipping SDK installation. Build might fail if required SDK components are missing."
+ Write-Warning "Script not found at $sdkIsoScript. Skipping."
}
- # Install GTK Dependencies (Linux Runner Only - for Skia.Gtk/Desktop)
+ # 3. Install GTK Dependencies (Linux Only - Required for Skia.Gtk)
- name: Install GTK Dependencies
shell: bash
if: runner.os == 'Linux' && inputs.job-platform == 'linux'
run: |
- echo "Installing GTK dependencies for Skia.Gtk/Desktop..."
+ echo "Installing GTK dependencies..."
sudo apt-get update
- sudo apt-get install -y snapd
- sudo snap install core24
- sudo snap install multipass
- sudo snap install lxd
- sudo snap install snapcraft --classic
- lxd init --minimal
- sudo usermod --append --groups lxd $USER # In order for the current user to use LXD
-
-
- # Install Uno Check Tool and Run Check for the target platform
- - name: Install Uno Platform Workloads via uno-check for ${{ inputs.job-platform }}
- shell: pwsh
+
+ # 4. Run Uno.Check
+ # FIXED: Changed ${{ inputs.target-platform }} to ${{ inputs.job-platform }}
+ - name: Install ${{ inputs.job-platform }} Workloads
+ shell: pwsh
+ if: runner.os != 'Windows' && inputs.job-platform != 'windows'
run: |
- echo "Installing/Updating uno-check tool..."
- # Consider using a specific version if needed, otherwise install latest stable
- dotnet tool update -g uno.check # Use update instead of install to get latest patch within major.minor
-
- $unoTarget = "${{ inputs.job-platform }}"
- # Map simple platform names to potential uno-check target names if needed
- # Example: if uno-check needs 'desktop' instead of 'linux'
- if ($unoTarget -eq 'linux') { $unoTarget = 'desktop' } # Adjust if uno-check uses 'desktop' for linux GTK head
-
- echo "Running uno-check for target: $unoTarget"
- # Run uno-check for the specific platform of this job runner
- # Adjust skips as needed for your project
- uno-check --ci --non-interactive --fix --target $unoTarget --skip vswin --skip vsmac --skip xcode --skip vswinworkloads --skip androidemulator --skip dotnetnewunotemplates --skip mauiinstallationcheck
-
- # Optional: Check exit code if needed, though continue-on-error is handled at job level
- if ($LASTEXITCODE -ne 0) {
- Write-Warning "uno-check completed with errors (Exit Code: $LASTEXITCODE) for target $unoTarget. Build might fail."
- } else {
- echo "uno-check finished successfully for target: $unoTarget"
- }
-
+ dotnet tool install -g uno.check
+
+ # We use inputs.job-platform here
+ $platforms = "${{ inputs.job-platform }}"
+
+ $platforms.Split(' ') | ForEach-Object {
+ $target = $_.Replace("_win", "").Replace("_macos", "")
+
+ # Map 'linux' to 'skia' or 'linux' as expected by uno-check if needed
+ if ($target -eq 'linux') { $target = 'linux' }
+
+ if (![string]::IsNullOrEmpty($target)) {
+ echo "Running uno-check for target: $target"
+ # Added --continue-on-error logic implicitly by not failing script on exit code
+ uno-check -v --ci --non-interactive --fix --target $target --skip vswin --skip vsmac --skip xcode --skip vswinworkloads --skip androidemulator --skip dotnetnewunotemplates
+ echo "uno-check finished for target: $target"
+ }
+ }
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3a996e60..14f7ec7c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,157 +1,123 @@
# .github/workflows/CI.yml
name: CI Build & Artifacts
-# Workflow triggers
on:
push:
- branches:
- - main
- - release/**
+ branches: [ main, release/** ]
pull_request:
types: [opened, synchronize, reopened]
- branches:
- - main
- - release/**
+ branches: [ main, release/** ]
-# Concurrency control to cancel older runs on the same branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
- # Job name includes the OS from the matrix
name: Build (${{ matrix.os }})
- # Runner OS defined by the matrix
runs-on: ${{ matrix.os }}
- # Allow macOS and Linux builds to fail without failing the entire workflow run
continue-on-error: ${{ matrix.os != 'windows-latest' }}
strategy:
- # Don't cancel other matrix jobs if one fails
fail-fast: false
matrix:
- # Define the operating systems to run on
- os: [windows-latest, macos-latest, ubuntu-latest]
- # Include specific configurations for each OS
include:
- # == WINDOWS CONFIGURATION ==
+ # == WINDOWS (WinUI 3 Unpackaged) ==
- os: windows-latest
platform: windows
- # UPDATED: Use the correct .NET 9 Windows target framework from your project
- target_framework: 'net9.0-windows10.0.22621'
- # UPDATED: Adjust publish path for the new framework
- # IMPORTANT: Verify this path matches your actual build output structure and Runtime Identifier (RID)
- publish_path: './bin/Release/net9.0-windows10.0.22621/win-x64/publish/'
- artifact_name: 'Emerald-Windows'
-
- artifact_Path: './Emerald/bin/Release/net9.0-windows10.0.22621/win-x64/publish/'
- # IMPORTANT: Define the path to your Windows/Shared project file
- project_path: './Emerald/Emerald.csproj'
+ target_framework: 'net9.0-windows10.0.26100'
+ project_path: './Emerald/Emerald.csproj'
+ publish_output: 'Emerald_Windows'
+ artifact_name: 'Emerald-Windows-Release'
- # == MACOS CONFIGURATION ==
+ # == MACOS (Skia Desktop) ==
- os: macos-latest
platform: macos
- # UPDATED: Use the correct .NET 9 macOS target framework from your project
- target_framework: 'net9.0-maccatalyst'
- # UPDATED: Adjust publish path for the new framework
- # IMPORTANT: Verify this path matches your actual build output structure and RID
- publish_path: './Emerald/bin/Release/net9.0-maccatalyst/maccatalyst-x64/publish/'
-
- artifact_Path: './Emerald/bin/Release/net9.0-maccatalyst/maccatalyst-x64/publish/'
-
- artifact_name: 'Emerald-macOS'
- # IMPORTANT: Define the path to your macOS/Shared project file
- project_path: './Emerald/Emerald.csproj'
+ target_framework: 'net9.0-desktop'
+ project_path: './Emerald/Emerald.csproj'
+ publish_output: 'Emerald_macOS'
+ artifact_name: 'Emerald-macOS-Release'
- # == LINUX CONFIGURATION ==
- - os: ubuntu-24.04
+ # == LINUX (Skia Gtk) ==
+ - os: ubuntu-latest
platform: linux
- # UPDATED: Use the correct .NET 9 Linux target framework from your project
target_framework: 'net9.0-desktop'
- # UPDATED: Point to the main project since 'net9.0-desktop' is likely defined there
- project_path: './Emerald/Emerald.csproj'
- # UPDATED: Adjust publish path for the new framework and project
- # IMPORTANT: Verify this path matches your actual build output structure.
- publish_path: './Emerald/bin/Release/net9.0-desktop/publish/'
-
- artifact_Path: './Emerald/bin/Release/net9.0-desktop/'
-
- artifact_name: 'Emerald-Linux'
+ project_path: './Emerald/Emerald.csproj'
+ publish_output: 'Emerald_Linux'
+ artifact_name: 'Emerald-Linux-Release'
- # Steps to execute for each job in the matrix
steps:
- # Step 1: Check out the repository code
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- # Step 2: Install dependencies using the composite action
- - name: Install Dependencies for ${{ matrix.platform }}
- timeout-minutes: 15
+ - name: Install Dependencies
uses: ./.github/steps/install_dependencies
with:
job-platform: ${{ matrix.platform }}
- # Ensure correct .NET 9 SDK is used (default in action.yml)
- # Ensure correct Windows SDK is used (default in action.yml)
- # Step 2.5: Setup MSBuild (Windows Only) - ADDED BACK
- - name: Setup MSBuild
+ - name: Setup MSBuild (Windows Only)
uses: microsoft/setup-msbuild@v1.3.1
- if: matrix.os == 'windows-latest' # Only run this step on Windows runners
+ if: matrix.os == 'windows-latest'
- # Step 3: Build and publish the application
- - name: Build and Publish Emerald (${{ matrix.platform }})
- timeout-minutes: 20
+ # == BUILD STEP ==
+ - name: Build and Publish (${{ matrix.platform }})
shell: pwsh
run: |
- Write-Host "Publishing project: ${{ matrix.project_path }} for framework: ${{ matrix.target_framework }}"
-
- # UPDATED: Use msbuild /t:publish for Windows based on Uno documentation for unpackaged apps
+ $project = "${{ matrix.project_path }}"
+ $output = Join-Path $env:GITHUB_WORKSPACE "${{ matrix.publish_output }}"
+ Write-Host "Building $project to $output..."
+
+ # FIXED: Use an Array @() to pass multiple arguments correctly
+ # This ensures PowerShell passes them as separate flags, not one long string.
+ $commonArgs = @("/p:PublishTrimmed=false", "/p:PublishSingleFile=false")
+
if ('${{ matrix.platform }}' -eq 'windows') {
- # Navigate to project directory - Made mandatory as requested
- cd (Split-Path -Path "${{ matrix.project_path }}" -Parent) # Navigate to the directory containing the csproj
-
- Write-Host "Using msbuild /t:publish for Windows (x64)..."
- # Use msbuild with specified parameters. MSBuild should now be in the PATH thanks to the setup-msbuild step.
- # /r: Restore dependencies
- # /t:publish: Execute the publish target
- # /p:TargetFramework: Set the target framework from the matrix
- # /p:Configuration: Set the build configuration
- # /p:Platform: Set the target platform (x64 for standard windows-latest runner)
- # /p:PublishDir: Set the output directory using the path from the matrix
- # Pass only the project file name since we changed directory
- msbuild /r /t:publish `
+ # Windows WinUI 3 (Unpackaged & Self-Contained)
+ msbuild $project /r /t:publish `
/p:TargetFramework=${{ matrix.target_framework }} `
/p:Configuration=Release `
/p:Platform=x64 `
- /p:PublishDir=${{ matrix.publish_path }} `
- (Split-Path -Path "${{ matrix.project_path }}" -Leaf) # Pass only the project file name
-
- # Note: Building for x86/arm64 would require additional msbuild calls with different /p:Platform values
- # and potentially different PublishDir paths, or separate matrix jobs.
- }
- # Use dotnet publish for macOS and Linux
+ /p:PublishDir="$output" `
+ /p:WindowsPackageType=None `
+ /p:WindowsAppSDKSelfContained=true `
+ /p:SelfContained=true `
+ /p:ErrorOnDuplicatePublishOutputFiles=false `
+ $commonArgs
+ }
+ elseif ('${{ matrix.platform }}' -eq 'macos') {
+ # macOS (App Bundle)
+ dotnet publish $project -c Release -f "${{ matrix.target_framework }}" `
+ -r osx-x64 --self-contained true `
+ -p:PackageFormat=app `
+ -o "$output" `
+ $commonArgs
+ }
else {
-
- Write-Host "Using dotnet publish for ${{ matrix.platform }}..."
- if ('${{ matrix.platform }}' -eq 'linux') {
- dotnet publish "${{ matrix.project_path }}" -c Release -f "${{ matrix.target_framework }}" -p:SelfContained=true -p:PackageFormat=snap -p:UnoSnapcraftAdditionalParameters=--destructive-mode
-
- }
- else {
- dotnet publish "${{ matrix.project_path }}" -c Release -f "${{ matrix.target_framework }}" --no-self-contained
- }
+ # Linux (Standard Binary)
+ dotnet publish $project -c Release -f "${{ matrix.target_framework }}" `
+ -r linux-x64 --self-contained true `
+ -o "$output" `
+ $commonArgs
}
-
- Write-Host "Publish completed for ${{ matrix.platform }}."
- # Step 4: Upload the build artifact
- - name: Upload Artifact (${{ matrix.artifact_name }})
- if: success() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
+ # == ZIP STEP ==
+ - name: Zip Release Artifact
+ shell: pwsh
+ run: |
+ $source = Join-Path $env:GITHUB_WORKSPACE "${{ matrix.publish_output }}"
+ $zipName = "${{ matrix.artifact_name }}.zip"
+ $destination = Join-Path $env:GITHUB_WORKSPACE $zipName
+
+ Write-Host "Zipping $source to $destination"
+
+ # NOTE: Zips the folder itself, not the contents inside, preserving structure.
+ Compress-Archive -Path "$source" -DestinationPath $destination
+
+ - name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
- path: ${{ matrix.artifact_Path }}
- retention-days: 7
+ path: ${{ github.workspace }}/${{ matrix.artifact_name }}.zip
+ retention-days: 5
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 1d6e6504..5c5560ed 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -4,6 +4,7 @@
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"version": "0.2.0",
"configurations": [
+
{
"name": "Uno Platform Mobile",
"type": "Uno",
@@ -20,7 +21,7 @@
"request": "launch",
"preLaunchTask": "build-desktop",
// If you have changed target frameworks, make sure to update the program path.
- "program": "${workspaceFolder}/Emerald/bin/Debug/net8.0-desktop/Emerald.dll",
+ "program": "${workspaceFolder}/Emerald/bin/Debug/net9.0-desktop/Emerald.dll",
"args": [],
"launchSettingsProfile": "Emerald (Desktop)",
"env": {
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index ac5be062..7e63da46 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -9,7 +9,7 @@
"build",
"${workspaceFolder}/Emerald/Emerald.csproj",
"/property:GenerateFullPaths=true",
- "/property:TargetFramework=net8.0-desktop",
+ "/property:TargetFramework=net9.0-desktop",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
@@ -22,7 +22,7 @@
"publish",
"${workspaceFolder}/Emerald/Emerald.csproj",
"/property:GenerateFullPaths=true",
- "/property:TargetFramework=net8.0-desktop",
+ "/property:TargetFramework=net9.0-desktop",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d6115ad9..42d395b3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,14 +6,14 @@
-->
-
+
-
+
@@ -21,9 +21,9 @@
-
+
-
+
diff --git a/Emerald.CoreX/Core.cs b/Emerald.CoreX/Core.cs
index 2f461eb0..ea42b9ad 100644
--- a/Emerald.CoreX/Core.cs
+++ b/Emerald.CoreX/Core.cs
@@ -1,14 +1,23 @@
-using Microsoft.Extensions.Logging;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
using CmlLib.Core;
+using CmlLib.Core.Utils;
using CmlLib.Core.VersionMetadata;
-using Emerald.CoreX.Notifications;
-using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
using Emerald.CoreX.Helpers;
+using Emerald.CoreX.Notifications;
using Emerald.Services;
+using Microsoft.Extensions.Logging;
namespace Emerald.CoreX;
-public class Core(ILogger _logger, INotificationService _notify, BaseSettingsService settingsService)
+public record SavedGame(string Path, Versions.Version Version, Models.GameSettings GameOptions);
+
+public record SavedGameCollection(string BasePath, SavedGame[] Games);
+
+public partial class Core(ILogger _logger, INotificationService _notify, IBaseSettingsService settingsService) : ObservableObject
{
+ public const string GamesFolderName = "EmeraldGames";
public MinecraftLauncher Launcher { get; set; }
public bool IsRunning { get; set; } = false;
@@ -19,14 +28,80 @@ public class Core(ILogger _logger, INotificationService _notify, BaseSetti
public readonly ObservableCollection Games = new();
- private bool initialized = false;
+ [ObservableProperty]
+ private bool _initialized = false;
public Models.GameSettings GameOptions = new();
- public async Task Refresh()
+
+ private SavedGameCollection[] SavedgamesWithPaths = [];
+
+ public void LoadGames()
+ {
+ if (BasePath == null)
+ {
+ _logger.LogWarning("Cannot load games, BasePath is not set");
+ throw new InvalidOperationException("Cannot load games, BasePath is not set");
+ }
+
+ var gamesFolder = Path.Combine(BasePath.BasePath, GamesFolderName);
+ if (!Path.Exists(gamesFolder))
+ {
+ _logger.LogInformation("Games folder does not exist, creating...");
+ Directory.CreateDirectory(gamesFolder);
+ }
+
+ SavedgamesWithPaths = settingsService.Get(SettingsKeys.SavedGames, [], true);
+
+ var collection = SavedgamesWithPaths.FirstOrDefault(x => x.BasePath == BasePath.BasePath);
+ if (collection == null)
+ {
+ _logger.LogInformation("Saved games paths does not contain any games");
+ return;
+ }
+
+ foreach (var sg in collection.Games)
+ {
+ try
+ {
+ var game = Game.FromTuple((sg.Path, sg.Version, sg.GameOptions));
+ Games.Add(game);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Failed to load game from {dir}: {ex}", sg.Path, ex.Message);
+ _notify.Error("FailedToLoadGame", $"Failed to load game from {sg.Path}", ex: ex);
+ }
+ }
+
+ _logger.LogInformation("Loaded {count} games from", Games.Count);
+ }
+ public void SaveGames()
{
+ _logger.LogInformation("Saving {count} games", Games.Count);
+
+ var toSave = Games.Select(x =>
+ new SavedGame(x.Path.BasePath, x.Version, x.Options)
+ ).ToArray();
+
+ try
+ {
+ var list = SavedgamesWithPaths.ToList();
+ list.RemoveAll(x => x.BasePath == BasePath.BasePath);
+ list.Add(new SavedGameCollection(BasePath.BasePath, toSave));
+
+ SavedgamesWithPaths = list.ToArray();
+ settingsService.Set(SettingsKeys.SavedGames, SavedgamesWithPaths, true);
+ _logger.LogInformation("Saved {count} games", toSave.Length);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to save games");
+ throw;
+ }
}
+
///
/// Initializes the Core with the given Minecraft path and retrieves the list of available vanilla Minecraft versions.
///
@@ -44,7 +119,7 @@ public async Task InitializeAndRefresh(MinecraftPath? basePath = null)
GameOptions = settingsService.Get("BaseGameOptions", Models.GameSettings.FromMLaunchOption(new()));
_logger.LogInformation("Trying to load vanilla minecraft versions from servers");
- if (!initialized && basePath == null)
+ if (!Initialized && basePath == null)
{
_logger.LogInformation("Minecraft Path must be set on first initialize");
throw new InvalidOperationException("Minecraft Path must be set on first initialize");
@@ -54,8 +129,9 @@ public async Task InitializeAndRefresh(MinecraftPath? basePath = null)
Launcher = new MinecraftLauncher(basePath);
BasePath = basePath;
}
-
- initialized = true;
+
+ LoadGames();
+ Initialized = true;
var l = await Launcher.GetAllVersionsAsync(not.CancellationToken.Value);
@@ -72,7 +148,7 @@ public async Task InitializeAndRefresh(MinecraftPath? basePath = null)
{
_logger.LogCritical(ex, "Failed to load vanilla minecraft versions: {ex}", ex.Message);
_notify.Complete(not.Id, false, ex.Message, ex);
- initialized = false;
+ Initialized = false;
}
_logger.LogInformation("Loaded {count} vanilla versions", VanillaVersions.Count);
}
@@ -83,14 +159,14 @@ public async Task InitializeAndRefresh(MinecraftPath? basePath = null)
/// The version of the game to be installed. Must exist in the collection of games.
/// Specifies whether to display file progress during installation.
/// A task that represents the asynchronous operation of installing the game version.
-public async Task InstallGame(Versions.Version version, bool showFileprog = false)
+public async Task InstallGame(Game game, bool showFileprog = false)
{
-
+ var version = game.Version;
+
try
{
- _logger.LogInformation("Installing game {version}", version.BasedOn);
- var game = Games.Where(x => x.Equals(version)).First();
+ _logger.LogInformation("Installing game {version}", version.BasedOn);
if(game == null)
{
@@ -116,11 +192,13 @@ public void AddGame(Versions.Version version)
{
_logger.LogInformation("Adding game {version}", version.BasedOn);
- var path = Path.Combine( BasePath.BasePath, version.DisplayName);
+ var path = Path.Combine( BasePath.BasePath, GamesFolderName, version.DisplayName);
+
+ var game = new Game(new(path), GameOptions, version);
- var game = new Game(new(path), GameOptions);
Games.Add(game);
+ SaveGames();
var not = _notify.Info(
"AddedGame",
@@ -137,4 +215,39 @@ public void AddGame(Versions.Version version)
);
}
}
+
+ public void RemoveGame(Game game, bool deleteFolder = false)
+ {
+ try
+ {
+ _logger.LogInformation("Removing game {version}", game.Version.BasedOn);
+ if (!Games.Contains(game))
+ {
+ _logger.LogWarning("Game {version} not found in collection", game.Version.BasedOn);
+ throw new NullReferenceException($"Game {game.Version.BasedOn} not found in collection");
+ }
+ Games.Remove(game);
+ SaveGames();
+
+ if (deleteFolder && Path.Exists(game.Path.BasePath))
+ {
+ _logger.LogInformation("Deleting game folder {path}", game.Path.BasePath);
+ Directory.Delete(game.Path.BasePath, true);
+ }
+
+ var not = _notify.Info(
+ "RemovedGame",
+ $"{game.Version.DisplayName} based on {game.Version.BasedOn} {game.Version.Type}"
+ );
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Failed to remove game {version}: {ex}", game.Version.BasedOn, ex.Message);
+ _notify.Error(
+ "FailedToRemoveGame",
+ $"Failed to remove game {game.Version.DisplayName} based on {game.Version.BasedOn} {game.Version.Type}",
+ ex: ex
+ );
+ }
+ }
}
diff --git a/Emerald.CoreX/Emerald.CoreX.csproj b/Emerald.CoreX/Emerald.CoreX.csproj
index 4690b3e5..541e5864 100644
--- a/Emerald.CoreX/Emerald.CoreX.csproj
+++ b/Emerald.CoreX/Emerald.CoreX.csproj
@@ -1,11 +1,8 @@
-
+
-
- net9.0-desktop;
-
- $(TargetFrameworks);net9.0-windows10.0.22621
- $(TargetFrameworks);net9.0-maccatalyst
+
+ net9.0-windows10.0.26100;net9.0-desktop
true
Library
@@ -58,5 +55,6 @@
+
diff --git a/Emerald.CoreX/Game.cs b/Emerald.CoreX/Game.cs
index 31cec9b9..03668597 100644
--- a/Emerald.CoreX/Game.cs
+++ b/Emerald.CoreX/Game.cs
@@ -11,6 +11,9 @@ namespace Emerald.CoreX;
public class Game
{
+ public static Game FromTuple((string Path, Versions.Version version, Models.GameSettings Options) t)
+ => new(new MinecraftPath(t.Path), t.Options, t.version);
+
private readonly ILogger _logger;
private readonly Notifications.INotificationService _notify;
@@ -26,13 +29,14 @@ public class Game
/// Represents a Game instance, responsible for managing the installation, configuration,
/// and launching of Minecraft versions.
///
- public Game(MinecraftPath path, Models.GameSettings options)
+ public Game(MinecraftPath path, Models.GameSettings options, Versions.Version version)
{
_notify = Ioc.Default.GetService();
_logger = this.Log();
Launcher = new MinecraftLauncher();
Path = path;
Options = options;
+ Version = version;
_logger.LogInformation("Game instance created with path: {Path} and options: {Options}", path, options);
}
@@ -71,24 +75,31 @@ public async Task InstallVersion(bool isOffline = false, bool showFileProgress =
try
{
- string ver = await Ioc.Default.GetService().RouteAndInitializeAsync(Path, Version);
+ string? ver = await Ioc.Default.GetService().RouteAndInitializeAsync(Path, Version);
_logger.LogInformation("Version initialization completed. Version: {Version}", ver);
if (ver == null)
{
- var vers = await Launcher.GetAllVersionsAsync();
- ver = vers.First(x => x.Name.ToLower().Contains(Version.BasedOn.ToLower())).Name;
+ _logger.LogWarning("Version {VersionType} {ModVersion} {BasedOn} not found.", Version.Type, Version.ModVersion, Version.BasedOn);
- _logger.LogWarning("Version {VersionType} {ModVersion} {BasedOn} not found. Using {FallbackVersion} instead.", Version.Type, Version.ModVersion, Version.BasedOn, ver);
-
- _notify.Update(
+ _notify.Complete(
not.Id,
- message: $"Version {Version.Type} {Version.ModVersion} {Version.BasedOn} not found. Using {ver} instead.",
- isIndeterminate: false
+ message: $"Version {Version.Type} {Version.ModVersion} {Version.BasedOn} not found. Check your internet connection.",
+ success: false
);
return;
}
+ if (isOffline) //checking if verison actually exists
+ {
+ var vers = await Launcher.GetAllVersionsAsync();
+ var mver = vers.Where(x => x.Name == ver).First();
+ if (mver == null)
+ {
+ _logger.LogWarning("Version {Version} not found in offline mode. Can't proceed installation.", ver);
+ throw new NullReferenceException($"Version {ver} not found in offline mode. Can't proceed installation.");
+ }
+ }
(string Files, string bytes, double prog) prog = (string.Empty, string.Empty, 0);
@@ -125,6 +136,7 @@ await Launcher.InstallAsync(
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred during version installation.");
+ _notify.Complete(not.Id, false, "Installation Failed", ex);
}
}
@@ -133,10 +145,12 @@ await Launcher.InstallAsync(
///
/// The version of the game to be launched.
/// A Task that represents the process used to launch the Minecraft instance.
- public async Task BuildProcess(string version)
+ public async Task BuildProcess(string version, CmlLib.Core.Auth.MSession session)
{
_logger.LogInformation("Building process for version: {Version}", version);
+ var launchOpt = Options.ToMLaunchOption();
+ launchOpt.Session = session;
return await Launcher.BuildProcessAsync(
- version, Options.ToMLaunchOption());
+ version, launchOpt);
}
}
diff --git a/Emerald.CoreX/Helpers/BaseSettingsService.cs b/Emerald.CoreX/Helpers/BaseSettingsService.cs
index f0649da2..c0bf42eb 100644
--- a/Emerald.CoreX/Helpers/BaseSettingsService.cs
+++ b/Emerald.CoreX/Helpers/BaseSettingsService.cs
@@ -1,16 +1,19 @@
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System;
-using System.Collections.Generic;
-using System.Linq;
+using System.IO;
using System.Threading.Tasks;
using Windows.Storage;
namespace Emerald.Services;
-public class BaseSettingsService
+public class BaseSettingsService : IBaseSettingsService
{
private readonly ILogger _logger;
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ WriteIndented = true
+ };
public event EventHandler? APINoMatch;
@@ -18,37 +21,86 @@ public BaseSettingsService(ILogger logger)
{
_logger = logger;
}
- public void Set(string key, T value)
+
+ public void Set(string key, T value, bool storeInFile = false)
{
try
{
- string json = JsonSerializer.Serialize(value);
- ApplicationData.Current.LocalSettings.Values[key] = json;
+ if (storeInFile)
+ {
+ SaveToFileAsync(key, value).Wait();
+ }
+ else
+ {
+ string json = JsonSerializer.Serialize(value, _jsonOptions);
+ ApplicationData.Current.LocalSettings.Values[key] = json;
+ }
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error deserializing key '{Key}' from settings", key);
+ _logger.LogError(ex, "Error saving key '{Key}'", key);
+ // fallback: if in-memory save failed, try file once
+ if (!storeInFile)
+ {
+ try { SaveToFileAsync(key, value).Wait(); }
+ catch (Exception fileEx) { _logger.LogError(fileEx, "Fallback file save failed for '{Key}'", key); }
+ }
}
}
- public T Get(string key, T defaultVal)
+ public T Get(string key, T defaultVal, bool loadFromFile = false)
{
try
{
- string json = ApplicationData.Current.LocalSettings.Values[key] as string;
- if (!string.IsNullOrWhiteSpace(json))
+ if (loadFromFile)
+ {
+ return LoadFromFileAsync(key, defaultVal).GetAwaiter().GetResult();
+ }
+ else if (ApplicationData.Current.LocalSettings.Values.TryGetValue(key, out object? value)
+ && value is string json
+ && !string.IsNullOrWhiteSpace(json))
{
- return JsonSerializer.Deserialize(json);
+ return JsonSerializer.Deserialize(json) ?? defaultVal;
}
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error deserializing key '{Key}' from settings", key);
+ _logger.LogError(ex, "Error loading key '{Key}'", key);
+ // fallback: if in-memory load failed, try file once
+ if (!loadFromFile)
+ {
+ try { return LoadFromFileAsync(key, defaultVal).GetAwaiter().GetResult(); }
+ catch (Exception fileEx) { _logger.LogError(fileEx, "Fallback file load failed for '{Key}'", key); }
+ }
}
- // Save default value if deserialization fails or key is missing
- Set(key, defaultVal);
-
+ // if all else fails, persist default so next time there's a valid value
+ Set(key, defaultVal, storeInFile: loadFromFile);
return defaultVal;
}
+
+ private async Task SaveToFileAsync(string key, T value)
+ {
+ string fileName = $"{key}.json";
+ StorageFile file = await ApplicationData.Current.LocalFolder.CreateFileAsync(
+ fileName, CreationCollisionOption.ReplaceExisting);
+
+ string json = JsonSerializer.Serialize(value, _jsonOptions);
+ await FileIO.WriteTextAsync(file, json);
+ }
+
+ private async Task LoadFromFileAsync(string key, T defaultVal)
+ {
+ try
+ {
+ string fileName = $"{key}.json";
+ StorageFile file = await ApplicationData.Current.LocalFolder.GetFileAsync(fileName);
+ string json = await FileIO.ReadTextAsync(file);
+ return JsonSerializer.Deserialize(json) ?? defaultVal;
+ }
+ catch (FileNotFoundException)
+ {
+ return defaultVal;
+ }
+ }
}
diff --git a/Emerald.CoreX/Helpers/Extensions.cs b/Emerald.CoreX/Helpers/Extensions.cs
index 58abe91b..034c5ab3 100644
--- a/Emerald.CoreX/Helpers/Extensions.cs
+++ b/Emerald.CoreX/Helpers/Extensions.cs
@@ -36,7 +36,6 @@ public static class Extensions
public static int? GetMemoryGB()
{
-
var _logger = Ioc.Default.GetService>();
try
{
@@ -135,11 +134,15 @@ public static string Localize(this string resourceKey)
return cached;
}
- string s = Windows.ApplicationModel.Resources.ResourceLoader
+ string? s = Windows.ApplicationModel.Resources.ResourceLoader
.GetForViewIndependentUse()
.GetString(resourceKey);
+
if (string.IsNullOrEmpty(s))
- throw new NullReferenceException("ResourceLoader.GetString returned empty/null");
+ {
+ _logger.LogWarning("ResourceLoader.GetString returned empty/null, returning defaultkey");
+ return resourceKey;
+ }
cachedResources.AddOrUpdate(resourceKey, s, (_, _) => s);
diff --git a/Emerald.CoreX/Helpers/SettingsKeys.cs b/Emerald.CoreX/Helpers/SettingsKeys.cs
new file mode 100644
index 00000000..2b47bfb1
--- /dev/null
+++ b/Emerald.CoreX/Helpers/SettingsKeys.cs
@@ -0,0 +1,12 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Emerald.CoreX.Helpers;
+
+public static class SettingsKeys
+{
+ public const string SavedGames = "SavedGames";
+}
diff --git a/Emerald.CoreX/Installers/Fabric.cs b/Emerald.CoreX/Installers/Fabric.cs
index 1ecf5987..c368dd5a 100644
--- a/Emerald.CoreX/Installers/Fabric.cs
+++ b/Emerald.CoreX/Installers/Fabric.cs
@@ -69,7 +69,7 @@ public async Task InstallAsync(MinecraftPath path, string mcversion, str
{
this.Log().LogWarning("Fabric Loader installation is not supported offline. sending the version name");
_notify.Complete(not.Id, false, "Fabric Loader installation is not supported offline. Passed the version name.");
- return FabricInstaller.GetVersionName(mcversion, modversion ?? (await fabricInstaller.GetFirstLoader(modversion)).Version);
+ return FabricInstaller.GetVersionName(mcversion, modversion ?? (await fabricInstaller.GetFirstLoader(mcversion))?.Version ?? throw new NullReferenceException("No internet and no mod name found."));
}
diff --git a/Emerald.CoreX/Installers/Forge.cs b/Emerald.CoreX/Installers/Forge.cs
index c6c48a92..5529d644 100644
--- a/Emerald.CoreX/Installers/Forge.cs
+++ b/Emerald.CoreX/Installers/Forge.cs
@@ -63,10 +63,10 @@ public async Task InstallAsync(MinecraftPath path, string mcversion, str
this.Log().LogInformation("Installing Forge Loader for {mcversion}", mcversion);
try
{
-
-
var forge = new ForgeInstaller(new(path));
+ //TODO: check whether ForgeInstaller supports offline installation
+
string? versionName = null;
if (modversion == null)
diff --git a/Emerald.CoreX/Installers/LoaderInfo.cs b/Emerald.CoreX/Installers/LoaderInfo.cs
index 46f14d87..e511c9a8 100644
--- a/Emerald.CoreX/Installers/LoaderInfo.cs
+++ b/Emerald.CoreX/Installers/LoaderInfo.cs
@@ -8,6 +8,8 @@
namespace Emerald.CoreX.Installers;
public class LoaderInfo
{
+ public string? Tag { get; set; }
+
public string? Version { get; set; }
public bool? Stable { get; set; } = null;
diff --git a/Emerald.CoreX/Installers/ModLoaderRouter.cs b/Emerald.CoreX/Installers/ModLoaderRouter.cs
index eeadad64..59a981d1 100644
--- a/Emerald.CoreX/Installers/ModLoaderRouter.cs
+++ b/Emerald.CoreX/Installers/ModLoaderRouter.cs
@@ -16,7 +16,7 @@ public ModLoaderRouter()
Installers = Ioc.Default.GetServices();
}
- public async Task RouteAndInitializeAsync(MinecraftPath path, Versions.Version version)
+ public async Task RouteAndInitializeAsync(MinecraftPath path, Versions.Version version)
{
if (version.Type == Versions.Type.Vanilla)
diff --git a/Emerald.CoreX/Installers/Quilt.cs b/Emerald.CoreX/Installers/Quilt.cs
index 33459c96..2a567526 100644
--- a/Emerald.CoreX/Installers/Quilt.cs
+++ b/Emerald.CoreX/Installers/Quilt.cs
@@ -69,7 +69,7 @@ public async Task InstallAsync(MinecraftPath path, string mcversion, str
{
this.Log().LogWarning("Fabric Loader installation is not supported offline. sending the version name");
_notify.Complete(not.Id, false, "Fabric Loader installation is not supported offline. Passed the version name.");
- return QuiltInstaller.GetVersionName(mcversion, modversion ?? (await QuiltInstaller.GetFirstLoader(modversion)).Version);
+ return QuiltInstaller.GetVersionName(mcversion, modversion ?? (await QuiltInstaller.GetFirstLoader(mcversion))?.Version ?? throw new NullReferenceException("No internet and no mod name found."));
}
diff --git a/Emerald.CoreX/Models/EAccount.cs b/Emerald.CoreX/Models/EAccount.cs
new file mode 100644
index 00000000..08db8c0b
--- /dev/null
+++ b/Emerald.CoreX/Models/EAccount.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Emerald.CoreX.Models;
+
+public enum AccountType
+{
+ Offline,
+ Microsoft
+}
+
+[ObservableObject]
+public partial class EAccount
+{
+ [ObservableProperty]
+
+ private string _name = string.Empty;
+ [ObservableProperty]
+ private AccountType _type;
+
+ [ObservableProperty]
+ private string _UUID = string.Empty;
+
+ [ObservableProperty]
+ private DateTime _lastUsed;
+
+ [ObservableProperty]
+ private string _uniqueId = string.Empty;
+
+ public EAccount() { }
+
+ public EAccount(string name, AccountType type, string uuid = "", string uniqueId = "")
+ {
+ Name = name;
+ Type = type;
+ UUID = uuid;
+ UniqueId = uniqueId ?? Guid.NewGuid().ToString();
+ LastUsed = DateTime.UtcNow;
+ }
+}
diff --git a/Emerald.CoreX/Models/GameSettings.cs b/Emerald.CoreX/Models/GameSettings.cs
index 7da297d7..938bb9ad 100644
--- a/Emerald.CoreX/Models/GameSettings.cs
+++ b/Emerald.CoreX/Models/GameSettings.cs
@@ -9,14 +9,15 @@
using CmlLib.Core.ProcessBuilder;
using Emerald.CoreX.Helpers;
using CommunityToolkit.Mvvm.ComponentModel;
+using System.Runtime.Serialization;
namespace Emerald.CoreX.Models;
public partial class GameSettings : ObservableObject
{
-
[JsonIgnore]
public double MaxRAMinGB => Math.Round((MaximumRamMb / 1024.00), 2);
-
+
+ [NotifyPropertyChangedFor(nameof(MaxRAMinGB))]
[ObservableProperty]
private int _maximumRamMb;
@@ -29,12 +30,15 @@ public partial class GameSettings : ObservableObject
[ObservableProperty]
private bool _isDemo;
+ [NotifyPropertyChangedFor(nameof(ScreenSizeStatus))]
[ObservableProperty]
private int _screenWidth;
+ [NotifyPropertyChangedFor(nameof(ScreenSizeStatus))]
[ObservableProperty]
private int _screenHeight;
+ [NotifyPropertyChangedFor(nameof(ScreenSizeStatus))]
[ObservableProperty]
private bool _fullScreen;
diff --git a/Emerald.CoreX/Services/AccountService.cs b/Emerald.CoreX/Services/AccountService.cs
new file mode 100644
index 00000000..88ad452e
--- /dev/null
+++ b/Emerald.CoreX/Services/AccountService.cs
@@ -0,0 +1,296 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using CmlLib.Core.Auth;
+using CmlLib.Core.Auth.Microsoft;
+using CmlLib.Core.Auth.Microsoft.Sessions;
+using Emerald.CoreX.Models;
+using Emerald.Services;
+using Microsoft.Extensions.Logging;
+using Microsoft.Identity.Client;
+using Uno.Extensions.Specialized;
+using XboxAuthNet.Game.Accounts;
+using XboxAuthNet.Game.Msal;
+using XboxAuthNet.Game.Msal.OAuth;
+
+namespace Emerald.CoreX.Services;
+
+public class AccountService : IAccountService
+{
+ private readonly ILogger _logger;
+ private readonly IBaseSettingsService _settingsService;
+ private readonly ObservableCollection _accounts;
+ private JELoginHandler? _loginHandler;
+ private IPublicClientApplication? _msalApp;
+
+ private const string ACCOUNTS_SETTINGS_KEY = "MinecraftAccounts";
+
+ public ObservableCollection Accounts => _accounts;
+
+ public AccountService(ILogger logger, IBaseSettingsService settingsService)
+ {
+ _logger = logger;
+ _settingsService = settingsService;
+ _accounts = new ObservableCollection();
+ }
+
+ public async Task InitializeAsync(string clientId)
+ {
+ try
+ {
+ _logger.LogInformation("Initializing AccountService with client ID: {ClientId}", clientId);
+
+ // Initialize MSAL application
+ _msalApp = await MsalClientHelper.BuildApplicationWithCache(clientId);
+
+ // Initialize login handler with MSAL OAuth provider
+ _loginHandler = new JELoginHandlerBuilder()
+ .WithLogger(_logger)
+ .WithOAuthProvider(new MsalCodeFlowProvider(_msalApp))
+ .WithAccountManager(new InMemoryXboxGameAccountManager(JEGameAccount.FromSessionStorage))
+ .Build();
+
+ _logger.LogInformation("AccountService initialized successfully");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize AccountService");
+ throw;
+ }
+ }
+
+ public async Task LoadAllAccountsAsync()
+ {
+ try
+ {
+ _logger.LogInformation("Loading all accounts");
+
+ if (_loginHandler == null)
+ {
+ _logger.LogWarning("LoginHandler not initialized. Call InitializeAsync first.");
+ return;
+ }
+
+ // Clear current accounts
+ _accounts.Clear();
+
+ // Load stored offline accounts
+ var storedAccounts = _settingsService.Get>(ACCOUNTS_SETTINGS_KEY, new List());
+ _logger.LogInformation("Found {Count} stored accounts", storedAccounts.Count);
+
+ // Get online accounts from MSAL
+ var onlineAccounts = _loginHandler.AccountManager.GetAccounts().Select(x => x as JEGameAccount);
+ _logger.LogInformation("Found {Count} online accounts", onlineAccounts.Count());
+
+ _accounts.AddRange(storedAccounts.Where(acc => acc.Type == AccountType.Offline));
+
+
+ // Add any new online accounts that aren't in storage
+ foreach (var onlineAccount in onlineAccounts)
+ {
+ if (!_accounts.Any(acc => acc.UniqueId == onlineAccount.Identifier && acc.Type == AccountType.Microsoft))
+ {
+ var newAccount = new EAccount(onlineAccount.Profile?.Username, AccountType.Microsoft, onlineAccount.Profile?.UUID, onlineAccount.Identifier);
+ newAccount.LastUsed = onlineAccount.LastAccess;
+
+ _accounts.Add(newAccount);
+
+ _logger.LogInformation("Added new Microsoft account: {Identifier}", onlineAccount.Identifier);
+ }
+ }
+
+ // Save updated accounts to storage
+ SaveAccounts();
+
+ _logger.LogInformation("Successfully loaded {Count} total accounts", _accounts.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load accounts");
+ throw;
+ }
+ }
+
+ public void CreateOfflineAccount(string username)
+ {
+ try
+ {
+ _logger.LogInformation("Creating offline account for username: {Username}", username);
+
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ throw new ArgumentException("Username cannot be empty", nameof(username));
+ }
+
+ // Check if account already exists
+ if (_accounts.Any(acc => acc.Name.Equals(username, StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException($"Account with username '{username}' already exists");
+ }
+
+ var account = new EAccount(username, AccountType.Offline);
+ _accounts.Add(account);
+
+ SaveAccounts();
+
+ _logger.LogInformation("Successfully created offline account: {Username}", username);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create offline account for username: {Username}", username);
+ throw;
+ }
+ }
+
+ public async Task SignInMicrosoftAccountAsync()
+ {
+ try
+ {
+ _logger.LogInformation("Starting Microsoft account sign-in");
+
+ if (_loginHandler == null)
+ {
+ throw new InvalidOperationException("LoginHandler not initialized. Call InitializeAsync first.");
+ }
+
+ // Authenticate interactively to add a new account
+ var session = await _loginHandler.AuthenticateInteractively();
+
+ _logger.LogInformation("Successfully signed in Microsoft account: {Username}", session.Username);
+ await LoadAllAccountsAsync();
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to sign in Microsoft account");
+ throw;
+ }
+ }
+
+ public async Task RemoveAccountAsync(EAccount account)
+ {
+ try
+ {
+ _logger.LogInformation("Removing account: {Name} ({Type})", account.Name, account.Type);
+
+ if (account.Type == AccountType.Microsoft && _loginHandler != null)
+ {
+ // Sign out from Microsoft account
+ var accounts = _loginHandler.AccountManager.GetAccounts().Select(x => x as JEGameAccount);
+ var selectedAccount = accounts.FirstOrDefault(acc => acc?.Profile?.UUID == account.UUID);
+
+ if (selectedAccount != null)
+ {
+ await _loginHandler.Signout(selectedAccount);
+ _logger.LogInformation("Signed out Microsoft account: {Name}", account.Name);
+ }
+ }
+
+ // Remove from collection
+ _accounts.Remove(account);
+
+ // Save updated accounts
+ SaveAccounts();
+
+ _logger.LogInformation("Successfully removed account: {Name}", account.Name);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to remove account: {Name}", account.Name);
+ throw;
+ }
+ }
+
+ public async Task AuthenticateAccountAsync(EAccount account)
+ {
+ try
+ {
+ _logger.LogInformation("Authenticating account: {Name} ({Type})", account.Name, account.Type);
+
+ MSession session;
+
+ if (account.Type == AccountType.Offline)
+ {
+ session = MSession.CreateOfflineSession(account.Name);
+ _logger.LogInformation("Created offline session for: {Name}", account.Name);
+ }
+ else if (account.Type == AccountType.Microsoft)
+ {
+ if (_loginHandler == null)
+ {
+ throw new InvalidOperationException("LoginHandler not initialized. Call InitializeAsync first.");
+ }
+
+ var accounts = _loginHandler.AccountManager.GetAccounts().Select(x => x as JEGameAccount);
+ var selectedAccount = accounts.FirstOrDefault(acc => acc?.Profile?.UUID == account.UUID);
+
+ if (selectedAccount == null)
+ {
+ throw new InvalidOperationException($"Microsoft account '{account.Name}' not found in login handler");
+ }
+
+ session = await _loginHandler.Authenticate(selectedAccount);
+ _logger.LogInformation("Authenticated Microsoft account: {Name}", account.Name);
+ }
+ else
+ {
+ throw new ArgumentException($"Unknown account type: {account.Type}");
+ }
+
+ // Update last used time
+ account.LastUsed = DateTime.UtcNow;
+ SaveAccounts();
+
+ return session;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to authenticate account: {Name}", account.Name);
+ throw;
+ }
+ }
+
+ public EAccount? GetMostRecentlyUsedAccount()
+ {
+ try
+ {
+ var mostRecent = _accounts.OrderByDescending(acc => acc.LastUsed).FirstOrDefault();
+
+ if (mostRecent != null)
+ {
+ _logger.LogInformation("Most recently used account: {Name} (Last used: {LastUsed})",
+ mostRecent.Name, mostRecent.LastUsed);
+ }
+ else
+ {
+ _logger.LogInformation("No accounts available");
+ }
+
+ return mostRecent;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get most recently used account");
+ return null;
+ }
+ }
+
+ private void SaveAccounts()
+ {
+ try
+ {
+ var accountsToSave = _accounts.ToList();
+ _settingsService.Set(ACCOUNTS_SETTINGS_KEY, accountsToSave);
+ _loginHandler?.AccountManager.SaveAccounts();
+ _logger.LogDebug("Saved {Count} accounts to settings", accountsToSave.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to save accounts to settings");
+ throw;
+ }
+ }
+}
diff --git a/Emerald.CoreX/Services/IAccountService.cs b/Emerald.CoreX/Services/IAccountService.cs
new file mode 100644
index 00000000..579308f5
--- /dev/null
+++ b/Emerald.CoreX/Services/IAccountService.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using CmlLib.Core.Auth;
+using Emerald.CoreX.Models;
+
+namespace Emerald.CoreX.Services;
+
+public interface IAccountService
+{
+ ObservableCollection Accounts { get; }
+ Task LoadAllAccountsAsync();
+ void CreateOfflineAccount(string username);
+ Task SignInMicrosoftAccountAsync();
+ Task RemoveAccountAsync(EAccount account);
+ Task AuthenticateAccountAsync(EAccount account);
+ EAccount? GetMostRecentlyUsedAccount();
+ Task InitializeAsync(string clientId);
+}
diff --git a/Emerald.CoreX/Services/IBaseSettingsService.cs b/Emerald.CoreX/Services/IBaseSettingsService.cs
new file mode 100644
index 00000000..16f6a779
--- /dev/null
+++ b/Emerald.CoreX/Services/IBaseSettingsService.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.Logging;
+using System;
+
+namespace Emerald.Services;
+
+public interface IBaseSettingsService
+{
+ event EventHandler? APINoMatch;
+
+ void Set(string key, T value, bool storeInFile = false);
+
+ T Get(string key, T defaultVal, bool loadFromFile = false);
+}
diff --git a/Emerald.CoreX/Versions/Version.cs b/Emerald.CoreX/Versions/Version.cs
index acde23e9..cefc62ad 100644
--- a/Emerald.CoreX/Versions/Version.cs
+++ b/Emerald.CoreX/Versions/Version.cs
@@ -28,7 +28,7 @@ public class Version
public CmlLib.Core.VersionMetadata.IVersionMetadata? Metadata { get; set; }
- public string DisplayName;
+ public string DisplayName; //This Should be unique among all versions
public override bool Equals(object? obj)
{
diff --git a/Emerald.sln b/Emerald.sln
index bb5184e0..cddebe04 100644
--- a/Emerald.sln
+++ b/Emerald.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.11.35103.136
+# Visual Studio Version 18
+VisualStudioVersion = 18.0.11018.127 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Emerald", "Emerald\Emerald.csproj", "{9D3213F4-E514-4E7D-872A-725DB4872436}"
EndProject
@@ -32,10 +32,6 @@ Global
Release|arm64 = Release|arm64
Release|x64 = Release|x64
Release|x86 = Release|x86
- SkipOld|Any CPU = SkipOld|Any CPU
- SkipOld|arm64 = SkipOld|arm64
- SkipOld|x64 = SkipOld|x64
- SkipOld|x86 = SkipOld|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9D3213F4-E514-4E7D-872A-725DB4872436}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -62,18 +58,6 @@ Global
{9D3213F4-E514-4E7D-872A-725DB4872436}.Release|x86.ActiveCfg = Release|Any CPU
{9D3213F4-E514-4E7D-872A-725DB4872436}.Release|x86.Build.0 = Release|Any CPU
{9D3213F4-E514-4E7D-872A-725DB4872436}.Release|x86.Deploy.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|Any CPU.ActiveCfg = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|Any CPU.Build.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|Any CPU.Deploy.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|arm64.ActiveCfg = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|arm64.Build.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|arm64.Deploy.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|x64.ActiveCfg = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|x64.Build.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|x64.Deploy.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|x86.ActiveCfg = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|x86.Build.0 = Release|Any CPU
- {9D3213F4-E514-4E7D-872A-725DB4872436}.SkipOld|x86.Deploy.0 = Release|Any CPU
{196FD412-FCBA-4266-A75E-5648F6AADDFB}.Debug|Any CPU.ActiveCfg = Debug|x64
{196FD412-FCBA-4266-A75E-5648F6AADDFB}.Debug|Any CPU.Build.0 = Debug|x64
{196FD412-FCBA-4266-A75E-5648F6AADDFB}.Debug|arm64.ActiveCfg = Debug|arm64
@@ -90,14 +74,6 @@ Global
{196FD412-FCBA-4266-A75E-5648F6AADDFB}.Release|x64.Build.0 = Release|x64
{196FD412-FCBA-4266-A75E-5648F6AADDFB}.Release|x86.ActiveCfg = Release|x86
{196FD412-FCBA-4266-A75E-5648F6AADDFB}.Release|x86.Build.0 = Release|x86
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|Any CPU.ActiveCfg = Debug|x64
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|Any CPU.Build.0 = Debug|x64
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|arm64.ActiveCfg = Debug|arm64
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|arm64.Build.0 = Debug|arm64
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|x64.ActiveCfg = Debug|x64
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|x64.Build.0 = Debug|x64
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|x86.ActiveCfg = Debug|x86
- {196FD412-FCBA-4266-A75E-5648F6AADDFB}.SkipOld|x86.Build.0 = Debug|x86
{59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.Debug|Any CPU.ActiveCfg = Debug|x64
{59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.Debug|Any CPU.Build.0 = Debug|x64
{59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.Debug|Any CPU.Deploy.0 = Debug|x64
@@ -122,18 +98,6 @@ Global
{59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.Release|x86.ActiveCfg = Release|x86
{59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.Release|x86.Build.0 = Release|x86
{59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.Release|x86.Deploy.0 = Release|x86
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|Any CPU.ActiveCfg = SkipOld|x64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|Any CPU.Build.0 = SkipOld|x64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|Any CPU.Deploy.0 = SkipOld|x64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|arm64.ActiveCfg = SkipOld|arm64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|arm64.Build.0 = SkipOld|arm64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|arm64.Deploy.0 = SkipOld|arm64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|x64.ActiveCfg = SkipOld|x64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|x64.Build.0 = SkipOld|x64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|x64.Deploy.0 = SkipOld|x64
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|x86.ActiveCfg = SkipOld|x86
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|x86.Build.0 = SkipOld|x86
- {59A6F3D1-B6E1-48AD-80D3-215DF2791AC1}.SkipOld|x86.Deploy.0 = SkipOld|x86
{BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.Debug|arm64.ActiveCfg = Debug|Any CPU
@@ -150,14 +114,6 @@ Global
{BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.Release|x64.Build.0 = Release|Any CPU
{BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.Release|x86.ActiveCfg = Release|Any CPU
{BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.Release|x86.Build.0 = Release|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|Any CPU.ActiveCfg = Debug|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|Any CPU.Build.0 = Debug|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|arm64.ActiveCfg = Debug|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|arm64.Build.0 = Debug|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|x64.ActiveCfg = Debug|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|x64.Build.0 = Debug|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|x86.ActiveCfg = Debug|Any CPU
- {BE4AEE6B-3F2E-4FFB-940D-3E4916EAE913}.SkipOld|x86.Build.0 = Debug|Any CPU
{D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.Debug|arm64.ActiveCfg = Debug|Any CPU
@@ -174,14 +130,6 @@ Global
{D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.Release|x64.Build.0 = Release|Any CPU
{D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.Release|x86.ActiveCfg = Release|Any CPU
{D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.Release|x86.Build.0 = Release|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|Any CPU.ActiveCfg = Debug|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|Any CPU.Build.0 = Debug|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|arm64.ActiveCfg = Debug|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|arm64.Build.0 = Debug|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|x64.ActiveCfg = Debug|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|x64.Build.0 = Debug|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|x86.ActiveCfg = Debug|Any CPU
- {D103EFF9-90EA-49C7-9DD9-636E6056E9C3}.SkipOld|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Emerald/App.xaml.cs b/Emerald/App.xaml.cs
index eb9a60fa..6d2b1b7c 100644
--- a/Emerald/App.xaml.cs
+++ b/Emerald/App.xaml.cs
@@ -8,6 +8,7 @@
using System;
using Emerald.Helpers;
using CommunityToolkit.Mvvm.DependencyInjection;
+using Emerald.CoreX.Helpers;
namespace Emerald;
public partial class App : Application
@@ -21,9 +22,6 @@ public App()
{
this.InitializeComponent();
- System.Net.ServicePointManager.DefaultConnectionLimit = 256;
- // Register exception handlers
-
this.UnhandledException += App_UnhandledException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
@@ -43,7 +41,7 @@ private void ConfigureServices(IServiceCollection services)
//Settings
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
//Notifications
services.AddSingleton();
@@ -58,9 +56,15 @@ private void ConfigureServices(IServiceCollection services)
//Core
services.AddSingleton();
+ //Accounts
+ services.AddSingleton();
+
//Notifications
services.AddTransient();
+
+ //ViewModels
+ services.AddTransient();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
@@ -95,7 +99,7 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
#if DEBUG
MainWindow.UseStudio();
#endif
- MainWindow.SetWindowIcon();
+ MainWindow.SetWindowIcon("Assets/Icon.ico");
Host = builder.Build();
diff --git a/Emerald/Emerald.csproj b/Emerald/Emerald.csproj
index 96edc158..f3c7e191 100644
--- a/Emerald/Emerald.csproj
+++ b/Emerald/Emerald.csproj
@@ -1,10 +1,7 @@
-
+
-
- net9.0-desktop;
-
- $(TargetFrameworks);net9.0-windows10.0.22621
- $(TargetFrameworks);net9.0-maccatalyst
+
+ net9.0-windows10.0.26100;net9.0-desktop
Exe
true
@@ -114,10 +111,34 @@
+
+
+ GamesPage.xaml
+
+
+
MSBuild:Compile
+
+
+ MSBuild:Compile
+
+
+
+
+
+ MSBuild:Compile
+
+
+
+
+
+ MSBuild:Compile
+
+
+
diff --git a/Emerald/Helpers/Converters/CountToVisibilityConverter.cs b/Emerald/Helpers/Converters/CountToVisibilityConverter.cs
new file mode 100644
index 00000000..424fe495
--- /dev/null
+++ b/Emerald/Helpers/Converters/CountToVisibilityConverter.cs
@@ -0,0 +1,29 @@
+using System;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+
+namespace Emerald.Helpers.Converters;
+
+public class CountToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is int count)
+ {
+ int targetCount = 0;
+ if (parameter is string paramStr && int.TryParse(paramStr, out int parsed))
+ {
+ targetCount = parsed;
+ }
+
+ return count == targetCount ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ return Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Emerald/Helpers/Converters/StepToVisibilityConverter.cs b/Emerald/Helpers/Converters/StepToVisibilityConverter.cs
new file mode 100644
index 00000000..52825a99
--- /dev/null
+++ b/Emerald/Helpers/Converters/StepToVisibilityConverter.cs
@@ -0,0 +1,24 @@
+using Microsoft;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+using System;
+
+namespace Emerald.Helpers.Converters;
+
+public class StepToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is not int currentStep || parameter is not string targetStepString || !int.TryParse(targetStepString, out int targetStep))
+ {
+ return Visibility.Collapsed;
+ }
+
+ return currentStep == targetStep ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Emerald/Helpers/Extensions.cs b/Emerald/Helpers/Extensions.cs
index 00fdd6bb..541fd2bc 100644
--- a/Emerald/Helpers/Extensions.cs
+++ b/Emerald/Helpers/Extensions.cs
@@ -143,36 +143,6 @@ public static string ToMD5(this string s)
return sb.ToString();
}
- public static string Localize(this string resourceKey)
- {
- var _logger = Ioc.Default.GetService>();
- try
- {
- _logger.LogDebug("Localizing {resourceKey}", resourceKey);
-
- if (cachedResources.TryGetValue(resourceKey, out string cached) && !string.IsNullOrEmpty(cached))
- {
- _logger.LogDebug("Found cached {resourceKey} in cache", resourceKey);
- return cached;
- }
-
- string s = Windows.ApplicationModel.Resources.ResourceLoader
- .GetForViewIndependentUse()
- .GetString(resourceKey);
- if (string.IsNullOrEmpty(s))
- throw new NullReferenceException("ResourceLoader.GetString returned empty/null");
-
- cachedResources.AddOrUpdate(resourceKey, s, (_, _) => s);
-
- _logger.LogDebug("Localized {resourceKey} to {s}", resourceKey, s);
- return string.IsNullOrEmpty(s) ? resourceKey : s;
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to localize {resourceKey}", resourceKey);
- return resourceKey;
- }
- }
//public static string Localize(this Core.Localized resourceKey) =>
// resourceKey.ToString().Localize();
diff --git a/Emerald/Helpers/MarkupExtensions/LocalizeString.cs b/Emerald/Helpers/MarkupExtensions/LocalizeString.cs
index 495182dc..8812d857 100644
--- a/Emerald/Helpers/MarkupExtensions/LocalizeString.cs
+++ b/Emerald/Helpers/MarkupExtensions/LocalizeString.cs
@@ -1,12 +1,13 @@
using Microsoft.UI.Xaml.Markup;
using CommunityToolkit.Mvvm;
+using Emerald.CoreX.Helpers;
namespace Emerald.Helpers;
[MarkupExtensionReturnType(ReturnType = typeof(string))]
public sealed class Localize : MarkupExtension
{
- public string Name { get; set; }
+ public string KeyName { get; set; }
protected override object ProvideValue()
- => Name.Localize();
+ => KeyName.Localize();
}
diff --git a/Emerald/Helpers/MessageBox.cs b/Emerald/Helpers/MessageBox.cs
index a40ef08b..e5c54ed1 100644
--- a/Emerald/Helpers/MessageBox.cs
+++ b/Emerald/Helpers/MessageBox.cs
@@ -1,5 +1,6 @@
using CommonServiceLocator;
using CommunityToolkit.Mvvm.DependencyInjection;
+using Emerald.CoreX.Helpers;
using Emerald.Helpers;
using Emerald.Helpers.Enums;
using Microsoft.UI;
diff --git a/Emerald/Helpers/Settings/JSON.cs b/Emerald/Helpers/Settings/JSON.cs
index f04fb80b..1aa91bd8 100644
--- a/Emerald/Helpers/Settings/JSON.cs
+++ b/Emerald/Helpers/Settings/JSON.cs
@@ -10,6 +10,7 @@
using CmlLib.Core.ProcessBuilder;
using Emerald.CoreX.Models;
using Emerald.CoreX.Store.Modrinth;
+using Emerald.CoreX.Helpers;
namespace Emerald.Helpers.Settings.JSON;
public class JSON : Models.Model
diff --git a/Emerald/Helpers/WindowManager.cs b/Emerald/Helpers/WindowManager.cs
index 398165e9..1f227031 100644
--- a/Emerald/Helpers/WindowManager.cs
+++ b/Emerald/Helpers/WindowManager.cs
@@ -1,24 +1,51 @@
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
using Microsoft.UI;
using Microsoft.UI.Composition;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
-using System;
-using System.Runtime.InteropServices;
using Windows.ApplicationModel;
using WinRT;
using WinRT.Interop;
+using Windows.Win32;
+using Windows.Win32.UI.WindowsAndMessaging;
+using Windows.Win32.Foundation;
namespace Emerald.Helpers;
public static class WindowManager
{
+ ///
+ /// This will set the Window Icon for the given using the provided UnoIcon.
+ ///
+ public static void SetWindowIcon(this global::Microsoft.UI.Xaml.Window window, string iconpath = "icon.ico")
+ {
+#if WINDOWS && !HAS_UNO
+ var hWnd = global::WinRT.Interop.WindowNative.GetWindowHandle(window);
+
+ // Retrieve the WindowId that corresponds to hWnd.
+ global::Microsoft.UI.WindowId windowId = global::Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hWnd);
+
+ // Lastly, retrieve the AppWindow for the current (XAML) WinUI 3 window.
+ global::Microsoft.UI.Windowing.AppWindow appWindow = global::Microsoft.UI.Windowing.AppWindow.GetFromWindowId(windowId);
+ appWindow.SetIcon(iconpath);
+
+ // Set the Window Title Only if it has the Default WinUI Desktop value and we are running Unpackaged
+ if (appWindow.Title == "WinUI Desktop")
+ {
+ appWindow.Title = "Emerald";
+ }
+#endif
+ }
///
/// Add mica and the icon to the
///
public static MicaBackground? IntializeWindow(Window window)
{
#if WINDOWS
+
var s = new MicaBackground(window);
s.TrySetMicaBackdrop();
return s;
diff --git a/Emerald/MainPage.xaml.cs b/Emerald/MainPage.xaml.cs
index b31f5ae9..fca45657 100644
--- a/Emerald/MainPage.xaml.cs
+++ b/Emerald/MainPage.xaml.cs
@@ -1,9 +1,11 @@
using System.Security.Cryptography.X509Certificates;
using CommonServiceLocator;
using CommunityToolkit.Mvvm.DependencyInjection;
+using Emerald.CoreX.Helpers;
using Emerald.CoreX.Installers;
using Emerald.Helpers;
using Emerald.Models;
+using Emerald.Views;
using Emerald.Views.Settings;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Xaml.Controls;
@@ -173,6 +175,9 @@ private void Navigate(SquareNavigationViewItem itm)
case "Tasks":
frame.Content = new Page { Content = new UserControls.NotificationListControl() };
break;
+ case "Home":
+ NavigateOnce(typeof(GamesPage));
+ break;
default:
NavigateOnce(typeof(SettingsPage));
break;
diff --git a/Emerald/Models/ArgTemplate.cs b/Emerald/Models/ArgTemplate.cs
index ee708800..f43a5e2d 100644
--- a/Emerald/Models/ArgTemplate.cs
+++ b/Emerald/Models/ArgTemplate.cs
@@ -1,13 +1,8 @@
-using CommunityToolkit.Mvvm.ComponentModel;
-
namespace Emerald.Models;
-//Copied from Emerald.UWP
[ObservableObject]
-public partial class ArgTemplate
+public partial class LaunchArg
{
[ObservableProperty]
- private string arg;
-
- public int Count { get; set; }
+ public string value = string.Empty;
}
diff --git a/Emerald/Services/SettingsService.cs b/Emerald/Services/SettingsService.cs
index 3328d2a9..1f85b9d4 100644
--- a/Emerald/Services/SettingsService.cs
+++ b/Emerald/Services/SettingsService.cs
@@ -8,7 +8,7 @@
using Windows.Storage;
namespace Emerald.Services;
-public class SettingsService(BaseSettingsService _baseService, ILogger _logger)
+public class SettingsService(IBaseSettingsService _baseService, ILogger _logger)
{
public Helpers.Settings.JSON.Settings Settings { get; private set; }
diff --git a/Emerald/UserControls/AddGameWizardControl.xaml b/Emerald/UserControls/AddGameWizardControl.xaml
new file mode 100644
index 00000000..1167e5da
--- /dev/null
+++ b/Emerald/UserControls/AddGameWizardControl.xaml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Emerald/UserControls/AddGameWizardControl.xaml.cs b/Emerald/UserControls/AddGameWizardControl.xaml.cs
new file mode 100644
index 00000000..96aea632
--- /dev/null
+++ b/Emerald/UserControls/AddGameWizardControl.xaml.cs
@@ -0,0 +1,52 @@
+using System;
+using Microsoft.UI.Xaml.Controls;
+using Emerald.ViewModels;
+using Microsoft.UI.Xaml;
+using Emerald.CoreX.Helpers;
+
+namespace Emerald.UserControls;
+
+public sealed partial class AddGameWizardControl : UserControl
+{
+ // A little hacky, but avoids complex event bubbling or direct ViewModel reference here
+ public GamesPageViewModel? ViewModel => this.DataContext as GamesPageViewModel;
+
+ public AddGameWizardControl()
+ {
+ this.InitializeComponent();
+ }
+
+ private async void ModLoaderType_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (ViewModel == null) return;
+
+ try
+ {
+ if (sender is ComboBox comboBox && comboBox.SelectedItem is ComboBoxItem item)
+ {
+ var typeString = item.Tag?.ToString();
+ if (Enum.TryParse(typeString, out var type))
+ {
+ ViewModel.SelectedModLoaderType = type;
+
+ bool showModLoaderOptions = type != CoreX.Versions.Type.Vanilla;
+ ModLoaderVersionListView.Visibility = showModLoaderOptions ? Visibility.Visible : Visibility.Collapsed;
+
+ if (showModLoaderOptions && ViewModel.SelectedVersion != null)
+ {
+ await ViewModel.LoadModLoadersCommand.ExecuteAsync(null);
+ }
+ else
+ {
+ ViewModel.AvailableModLoaders.Clear();
+ ViewModel.SelectedModLoader = null; // Clear selection
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Log().LogError(ex, "Failed to change modloader type");
+ }
+ }
+}
diff --git a/Emerald/UserControls/ArgumentsListView.xaml b/Emerald/UserControls/ArgumentsListView.xaml
index ca819bcd..c0022268 100644
--- a/Emerald/UserControls/ArgumentsListView.xaml
+++ b/Emerald/UserControls/ArgumentsListView.xaml
@@ -1,62 +1,45 @@
+ mc:Ignorable="d"
+ d:DesignHeight="300"
+ d:DesignWidth="400">
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Emerald/UserControls/ArgumentsListView.xaml.cs b/Emerald/UserControls/ArgumentsListView.xaml.cs
index a8b6622a..1c6610a2 100644
--- a/Emerald/UserControls/ArgumentsListView.xaml.cs
+++ b/Emerald/UserControls/ArgumentsListView.xaml.cs
@@ -1,98 +1,116 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
using System.Linq;
-using System.Runtime.InteropServices.WindowsRuntime;
-using Windows.Foundation;
-using Windows.Foundation.Collections;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Controls.Primitives;
-using Microsoft.UI.Xaml.Data;
-using Microsoft.UI.Xaml.Input;
-using Microsoft.UI.Xaml.Media;
-using Microsoft.UI.Xaml.Navigation;
-using System.Collections.ObjectModel;
using Emerald.Models;
-using CommonServiceLocator;
-using CommunityToolkit.Mvvm.DependencyInjection;
-
namespace Emerald.UserControls;
-//Copied from Emerald.UWP
public sealed partial class ArgumentsListView : UserControl
{
- private int count = 0;
- public ObservableCollection Args { get; set; }
-
+ // Public API → strings
+ public ObservableCollection Args
+ {
+ get => (ObservableCollection)GetValue(ArgsProperty);
+ set => SetValue(ArgsProperty, value);
+ }
+
+ public static readonly DependencyProperty ArgsProperty =
+ DependencyProperty.Register(
+ nameof(Args),
+ typeof(ObservableCollection),
+ typeof(ArgumentsListView),
+ new PropertyMetadata(new ObservableCollection(), OnArgsChanged)
+ );
+
+ // Internal collection for binding
+ private readonly ObservableCollection _internal = new();
+
public ArgumentsListView()
{
InitializeComponent();
- view.ItemsSource = Source;
- UpdateSource();
+ view.ItemsSource = _internal;
}
- private ObservableCollection Source = new();
- private void btnAdd_Click(object sender, RoutedEventArgs e)
+ private static void OnArgsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
- count++;
- var r = new ArgTemplate { Arg = "", Count = count };
- Source.Add(r);
- UpdateMainSource();
- view.SelectedItem = r;
+ var control = (ArgumentsListView)d;
+
+ if (e.OldValue is ObservableCollection oldCol)
+ oldCol.CollectionChanged -= control.ExternalChanged;
+
+ if (e.NewValue is ObservableCollection newCol)
+ {
+ newCol.CollectionChanged += control.ExternalChanged;
+ control.SyncFromExternal();
+ }
}
- public void UpdateSource()
+
+ // Sync external → internal
+ private void SyncFromExternal()
{
- Source.Clear();
- if (Args != null)
+ _internal.Clear();
+
+ if (Args == null) return;
+
+ foreach (var s in Args)
{
- foreach (var item in Args)
- {
- count++;
- var r = new ArgTemplate { Arg = item, Count = count };
- r.PropertyChanged += (_, _) =>
- {
- UpdateMainSource();
- };
- Source.Add(r);
- }
+ var arg = new LaunchArg { Value = s };
+ arg.PropertyChanged += InternalArgChanged;
+ _internal.Add(arg);
}
- btnRemove.IsEnabled = Source.Any();
}
- private void UpdateMainSource()
+ // Sync internal → external
+ private void SyncToExternal()
{
+ if (Args == null) return;
+
+ Args.CollectionChanged -= ExternalChanged;
Args.Clear();
- Args.AddRange(Source.Select(x=> x.Arg));
- }
- private void btnRemove_Click(object sender, RoutedEventArgs e)
- {
- foreach (var item in view.SelectedItems)
- {
- Source.Remove((ArgTemplate)item);
- }
- UpdateMainSource();
+ foreach (var arg in _internal)
+ Args.Add(arg.Value);
+ Args.CollectionChanged += ExternalChanged;
}
- private void TextBox_PointerPressed(object sender, PointerRoutedEventArgs e)
+ private void InternalArgChanged(object? sender, PropertyChangedEventArgs e)
{
- view.SelectedIndex = Source.IndexOf(Source.FirstOrDefault(x => x.Count == ((sender as FrameworkElement).DataContext as ArgTemplate).Count));
+ if (e.PropertyName == nameof(LaunchArg.Value))
+ SyncToExternal();
}
- private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
+ private void ExternalChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
+ SyncFromExternal();
+
+ private void btnAdd_Click(object sender, RoutedEventArgs e)
{
- view.SelectedIndex = Source.IndexOf(Source.FirstOrDefault(x => x.Count == ((sender as FrameworkElement).DataContext as ArgTemplate).Count));
- UpdateMainSource();
+ var newArg = new LaunchArg { Value = string.Empty };
+ newArg.PropertyChanged += InternalArgChanged;
+ _internal.Add(newArg);
+ view.SelectedIndex = _internal.Count - 1;
+ SyncToExternal();
}
- private void TextBox_GotFocus(object sender, RoutedEventArgs e)
+ private void btnRemove_Click(object sender, RoutedEventArgs e)
{
- view.SelectedIndex = Source.IndexOf(Source.FirstOrDefault(x => x.Count == ((sender as FrameworkElement).DataContext as ArgTemplate).Count));
+ foreach (var selected in view.SelectedItems.Cast().ToList())
+ {
+ selected.PropertyChanged -= InternalArgChanged;
+ _internal.Remove(selected);
+ }
+
+ SyncToExternal();
}
private void view_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
btnRemove.IsEnabled = view.SelectedItems.Any();
}
+ private void TextBox_GotFocus(object sender, RoutedEventArgs e)
+ {
+ if (sender is TextBox tb && tb.DataContext is LaunchArg arg)
+ view.SelectedItem = arg;
+ }
}
diff --git a/Emerald/UserControls/MinecraftSettingsUC.xaml b/Emerald/UserControls/MinecraftSettingsUC.xaml
new file mode 100644
index 00000000..b2c455ad
--- /dev/null
+++ b/Emerald/UserControls/MinecraftSettingsUC.xaml
@@ -0,0 +1,176 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Emerald/UserControls/MinecraftSettingsUC.xaml.cs b/Emerald/UserControls/MinecraftSettingsUC.xaml.cs
new file mode 100644
index 00000000..ff97cd09
--- /dev/null
+++ b/Emerald/UserControls/MinecraftSettingsUC.xaml.cs
@@ -0,0 +1,122 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.DependencyInjection;
+using Emerald.CoreX.Helpers;
+using Emerald.CoreX.Models;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.Storage.Pickers;
+
+namespace Emerald.UserControls;
+public sealed partial class MinecraftSettingsUC : UserControl
+{
+ public bool ShowMainSettings
+ {
+ get => (bool)GetValue(ShowMainSettingsProperty);
+ set => SetValue(ShowMainSettingsProperty, value);
+ }
+
+ public static readonly DependencyProperty ShowMainSettingsProperty =
+ DependencyProperty.Register(nameof(ShowMainSettings), typeof(bool), typeof(MinecraftSettingsUC), new PropertyMetadata(false));
+
+ public GameSettings GameSettings
+ {
+ get => (GameSettings)GetValue(GameSettingsProperty);
+ set => SetValue(GameSettingsProperty, value);
+ }
+
+ public static readonly DependencyProperty GameSettingsProperty =
+ DependencyProperty.Register(nameof(GameSettings), typeof(GameSettings), typeof(MinecraftSettingsUC), new PropertyMetadata(null));
+
+ // expose SS as a public property if you bind to it from x:Bind in XAML, otherwise x:Bind may not resolve.
+ public Services.SettingsService SS { get; }
+
+ public MinecraftSettingsUC()
+ {
+ InitializeComponent();
+ SS = Ioc.Default.GetService();
+ }
+
+ // helper method so we can call from multiple handlers
+ private async Task PickMinecraftFolderAsync()
+ {
+ this.Log().LogInformation("Choosing MC path");
+
+ var fop = new FolderPicker { CommitButtonText = "Select".Localize() };
+ fop.FileTypeFilter.Add("*");
+
+ if (DirectResoucres.Platform == "Windows")
+ WinRT.Interop.InitializeWithWindow.Initialize(fop, WinRT.Interop.WindowNative.GetWindowHandle(App.Current.MainWindow));
+
+ var f = await fop.PickSingleFolderAsync();
+
+ if (f == null)
+ {
+ this.Log().LogInformation("User did not select a MC path");
+ return;
+ }
+
+ var path = f.Path;
+ this.Log().LogInformation("New Minecraft path: {path}", path);
+ SS.Settings.Minecraft.Path = path;
+
+ await Ioc.Default.GetService().InitializeAndRefresh(new(path));
+ }
+
+ private async void btnChangeMPath_Click(object sender, RoutedEventArgs e)
+ {
+ await PickMinecraftFolderAsync();
+ }
+
+ private async void ChangePath_OnClick(object sender, RoutedEventArgs e)
+ {
+ // call the same helper instead of calling the handler with nulls
+ await PickMinecraftFolderAsync();
+ }
+
+ private void CopyPath_OnClick(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var path = ShowMainSettings ? SS.Settings.Minecraft.Path : Path.Combine(SS.Settings.Minecraft.Path, CoreX.Core.GamesFolderName);
+ var dp = new DataPackage();
+ dp.SetText(path);
+ Clipboard.SetContent(dp);
+ }
+ catch (Exception ex)
+ {
+ this.Log().LogError(ex, "Failed to copy path");
+ }
+ }
+
+ private void AdjustRam(int delta)
+ {
+ int newValue = GameSettings.MaximumRamMb + delta;
+
+ GameSettings.MaximumRamMb = Math.Clamp(
+ newValue,
+ DirectResoucres.MinRAM,
+ DirectResoucres.MaxRAM
+ );
+ }
+
+ private void btnRamPlus_Click(object sender, RoutedEventArgs e) => AdjustRam(64);
+ private void btnRamMinus_Click(object sender, RoutedEventArgs e) => AdjustRam(-64);
+
+ private void btnAutoRAM_Click(object sender, RoutedEventArgs e)
+ {
+ int sysMax = DirectResoucres.MaxRAM;
+
+ int recommended = sysMax switch
+ {
+ <= 4096 => DirectResoucres.MinRAM,
+ <= 8192 => sysMax / 3,
+ <= 16384 => sysMax / 2,
+ _ => (int)(sysMax * 0.65)
+ };
+
+ GameSettings.MaximumRamMb = recommended;
+ }
+}
diff --git a/Emerald/UserControls/NotificationListControl.xaml b/Emerald/UserControls/NotificationListControl.xaml
index 4604c9d1..3288540b 100644
--- a/Emerald/UserControls/NotificationListControl.xaml
+++ b/Emerald/UserControls/NotificationListControl.xaml
@@ -40,7 +40,7 @@
-
@@ -63,8 +63,8 @@
-
-
+
+
@@ -91,8 +91,8 @@
-
-
+
+
@@ -108,7 +108,7 @@
diff --git a/Emerald/ViewModels/GamesPageViewModel.cs b/Emerald/ViewModels/GamesPageViewModel.cs
new file mode 100644
index 00000000..6a9c84ec
--- /dev/null
+++ b/Emerald/ViewModels/GamesPageViewModel.cs
@@ -0,0 +1,405 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using CmlLib.Core;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.DependencyInjection;
+using CommunityToolkit.Mvvm.Input;
+using Emerald.CoreX;
+using Emerald.CoreX.Helpers;
+using Emerald.CoreX.Installers;
+using Emerald.CoreX.Notifications;
+using Emerald.CoreX.Services;
+using Emerald.CoreX.Versions;
+using Emerald.Services;
+using Microsoft.Extensions.Logging;
+
+namespace Emerald.ViewModels;
+
+public partial class GamesPageViewModel : ObservableObject
+{
+ private readonly Core _core;
+ private readonly ILogger _logger;
+ private readonly INotificationService _notificationService;
+ private readonly IAccountService _accountService;
+ private readonly SettingsService _settingsService;
+ private readonly ModLoaderRouter _modLoaderRouter;
+
+ [ObservableProperty]
+ private ObservableCollection _games;
+
+ [ObservableProperty]
+ private Game? _selectedGame;
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private bool _isRefreshing;
+
+ [ObservableProperty]
+ private string _searchQuery = string.Empty;
+
+ [ObservableProperty]
+ private ObservableCollection _filteredGames;
+
+ // For Add Game Dialog Wizard
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPrimaryButtonEnabled))]
+ private int _addGameWizardStep = 0;
+
+ [ObservableProperty]
+ private ObservableCollection _availableVersions;
+
+ [ObservableProperty]
+ private ObservableCollection _filteredAvailableVersions;
+
+ [ObservableProperty]
+ private string _versionSearchQuery = string.Empty;
+
+ [ObservableProperty]
+ private ObservableCollection _releaseTypes = new();
+
+ [ObservableProperty]
+ private string _selectedReleaseTypeFilter = "All";
+
+ [ObservableProperty]
+ private ObservableCollection _availableModLoaders;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPrimaryButtonEnabled))]
+ private CoreX.Versions.Version? _selectedVersion;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPrimaryButtonEnabled))]
+ private LoaderInfo? _selectedModLoader;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPrimaryButtonEnabled))]
+ private CoreX.Versions.Type _selectedModLoaderType = CoreX.Versions.Type.Vanilla;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsPrimaryButtonEnabled))]
+ private string _newGameName = string.Empty;
+
+ // This single property now controls the primary button's state across all steps
+ public bool IsPrimaryButtonEnabled
+ {
+ get
+ {
+ return AddGameWizardStep switch
+ {
+ 0 => SelectedVersion != null,
+ 1 => !string.IsNullOrWhiteSpace(NewGameName) && (SelectedModLoaderType == CoreX.Versions.Type.Vanilla || SelectedModLoader != null),
+ _ => false,
+ };
+ }
+ }
+
+ public GamesPageViewModel(Core core, ILogger logger, INotificationService notificationService, IAccountService accountService, ModLoaderRouter modLoaderRouter, SettingsService settingsService)
+ {
+ _core = core;
+ _logger = logger;
+ _notificationService = notificationService;
+ _accountService = accountService;
+ _modLoaderRouter = modLoaderRouter;
+ _settingsService = settingsService;
+ Games = _core.Games;
+ FilteredGames = new ObservableCollection(Games);
+ AvailableVersions = new ObservableCollection();
+ FilteredAvailableVersions = new ObservableCollection();
+ AvailableModLoaders = new ObservableCollection();
+
+ Games.CollectionChanged += (s, e) => UpdateFilteredGames();
+ }
+
+ // New, simplified navigation commands
+ [RelayCommand]
+ private void GoToNextStep() => AddGameWizardStep++;
+
+ [RelayCommand]
+ private void GoToPreviousStep() => AddGameWizardStep--;
+
+ [RelayCommand]
+ private void StartAddGame()
+ {
+ // Reset all wizard properties to their default state
+ AddGameWizardStep = 0;
+ NewGameName = string.Empty;
+ SelectedVersion = null;
+ SelectedModLoader = null;
+ SelectedModLoaderType = CoreX.Versions.Type.Vanilla;
+ VersionSearchQuery = string.Empty;
+ SelectedReleaseTypeFilter = "All";
+ AvailableModLoaders.Clear();
+ }
+
+ partial void OnSearchQueryChanged(string value) => UpdateFilteredGames();
+ partial void OnVersionSearchQueryChanged(string value) => UpdateFilteredAvailableVersions();
+ partial void OnSelectedReleaseTypeFilterChanged(string value) => UpdateFilteredAvailableVersions();
+
+ private void UpdateFilteredGames()
+ {
+ var filtered = string.IsNullOrWhiteSpace(SearchQuery)
+ ? Games
+ : Games.Where(g =>
+ g.Version.DisplayName.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
+ g.Version.BasedOn.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase));
+
+ FilteredGames.Clear();
+ foreach (var game in filtered)
+ {
+ FilteredGames.Add(game);
+ }
+ }
+
+ private void UpdateFilteredAvailableVersions()
+ {
+ var filtered = AvailableVersions.AsEnumerable();
+
+ if (!string.IsNullOrWhiteSpace(VersionSearchQuery))
+ {
+ filtered = filtered.Where(v => v.BasedOn.Contains(VersionSearchQuery, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (SelectedReleaseTypeFilter != "All")
+ {
+ filtered = filtered.Where(v => v.ReleaseType.Equals(SelectedReleaseTypeFilter, StringComparison.OrdinalIgnoreCase));
+ }
+
+ FilteredAvailableVersions.Clear();
+ foreach (var version in filtered.OrderByDescending(v => v.Metadata?.ReleaseTime ?? DateTime.MinValue))
+ {
+ FilteredAvailableVersions.Add(version);
+ }
+ }
+
+ [RelayCommand]
+ private async Task InitializeAsync()
+ {
+ try
+ {
+ IsLoading = true;
+ _logger.LogInformation("Initializing GamesPage");
+
+ if (!_core.Initialized)
+ {
+ var path = _settingsService.Settings.Minecraft.Path;
+ var mcPath = path != null ? new MinecraftPath(path) : new();
+ await _core.InitializeAndRefresh(mcPath);
+ }
+
+ AvailableVersions.Clear();
+ foreach (var version in _core.VanillaVersions)
+ {
+ AvailableVersions.Add(version);
+ }
+
+ // Populate release types for filtering
+ ReleaseTypes.Clear();
+ ReleaseTypes.Add("All");
+ var distinctTypes = AvailableVersions.Select(v => v.ReleaseType).Distinct().OrderBy(t => t);
+ foreach (var type in distinctTypes)
+ {
+ if (!string.IsNullOrWhiteSpace(type))
+ {
+ ReleaseTypes.Add(type);
+ }
+ }
+
+ UpdateFilteredAvailableVersions();
+ UpdateFilteredGames();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize GamesPage");
+ _notificationService.Error("InitializationError", "Failed to initialize games page", ex: ex);
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RefreshGamesAsync()
+ {
+ try
+ {
+ IsRefreshing = true;
+ await _core.InitializeAndRefresh();
+ UpdateFilteredGames();
+ _notificationService.Info("RefreshComplete", "Games list has been refreshed");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to refresh games");
+ _notificationService.Error("RefreshError", "Failed to refresh games", ex: ex);
+ }
+ finally
+ {
+ IsRefreshing = false;
+ }
+ }
+
+
+ [RelayCommand]
+ private async Task LoadModLoadersAsync()
+ {
+ if (SelectedVersion == null || SelectedModLoaderType == CoreX.Versions.Type.Vanilla)
+ {
+ AvailableModLoaders.Clear();
+ return;
+ }
+
+ try
+ {
+ IsLoading = true;
+ _logger.LogInformation("Loading mod loaders for {Version} - Type: {Type}", SelectedVersion.BasedOn, SelectedModLoaderType);
+
+ var installer = GetModLoaderInstaller(SelectedModLoaderType);
+ if (installer != null)
+ {
+ var loaders = await installer.GetVersionsAsync(SelectedVersion.BasedOn);
+ AvailableModLoaders.Clear();
+ AvailableModLoaders.Add(new LoaderInfo { Tag = "Latest", Version = "Latest Available", Stable = true });
+ foreach (var loader in loaders)
+ {
+ AvailableModLoaders.Add(loader);
+ }
+ SelectedModLoader = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load mod loaders");
+ _notificationService.Error("ModLoaderError", "Failed to load mod loaders", ex: ex);
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task CreateGameAsync()
+ {
+ if (SelectedVersion == null || string.IsNullOrWhiteSpace(NewGameName))
+ {
+ _notificationService.Warning("InvalidInput", "Please select a version and enter a name");
+ return;
+ }
+
+ try
+ {
+ IsLoading = true;
+ _logger.LogInformation("Creating new game: {Name}", NewGameName);
+
+ var modVer = SelectedModLoader?.Tag == "latest" ? null : SelectedModLoader?.Version;
+
+ var version = new CoreX.Versions.Version
+ {
+ DisplayName = NewGameName,
+ BasedOn = SelectedVersion.BasedOn,
+ Type = SelectedModLoaderType,
+ ReleaseType = SelectedVersion.ReleaseType,
+ Metadata = SelectedVersion.Metadata,
+ ModVersion = modVer
+ };
+
+ _core.AddGame(version);
+
+ _notificationService.Info("GameCreated", $"Successfully created {NewGameName}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create game");
+ _notificationService.Error("CreateGameError", "Failed to create game", ex: ex);
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ // Unchanged methods below...
+ [RelayCommand]
+ private async Task InstallGameAsync(Game? game)
+ {
+ if (game == null) return;
+ try
+ {
+ _logger.LogInformation("Installing game: {Name}", game.Version.DisplayName);
+ await _core.InstallGame(game, showFileprog: true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to install game");
+ _notificationService.Error("InstallError", $"Failed to install {game.Version.DisplayName}", ex: ex);
+ }
+ }
+
+ [RelayCommand]
+ private async Task LaunchGameAsync(Game? game)
+ {
+ if (game == null) return;
+ try
+ {
+ _logger.LogInformation("Launching game: {Name}", game.Version.DisplayName);
+ var account = _accountService.GetMostRecentlyUsedAccount();
+ if (account == null)
+ {
+ _notificationService.Warning("NoAccount", "Please sign in to an account first");
+ return;
+ }
+ var session = await _accountService.AuthenticateAccountAsync(account);
+ var process = await game.BuildProcess(game.Version.BasedOn, session);
+ process.Start();
+ _notificationService.Info("GameLaunched", $"Launched {game.Version.DisplayName}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to launch game");
+ _notificationService.Error("LaunchError", $"Failed to launch {game.Version.DisplayName}", ex: ex);
+ }
+ }
+
+ [RelayCommand]
+ private void RemoveGame(Game? game)
+ {
+ if (game == null) return;
+ try
+ {
+ _logger.LogInformation("Removing game: {Name}", game.Version.DisplayName);
+ _core.RemoveGame(game, deleteFolder: false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to remove game");
+ _notificationService.Error("RemoveError", $"Failed to remove {game.Version.DisplayName}", ex: ex);
+ }
+ }
+
+ [RelayCommand]
+ private async Task RemoveGameWithFilesAsync(Game? game)
+ {
+ if (game == null) return;
+ try
+ {
+ _logger.LogInformation("Removing game with files: {Name}", game.Version.DisplayName);
+ await Task.Run(() => _core.RemoveGame(game, deleteFolder: true));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to remove game with files");
+ _notificationService.Error("RemoveError", $"Failed to remove {game.Version.DisplayName}", ex: ex);
+ }
+ }
+
+ private IModLoaderInstaller? GetModLoaderInstaller(CoreX.Versions.Type type)
+ {
+ var Installers = Ioc.Default.GetServices();
+ return Installers.FirstOrDefault(x => x.Type == type);
+ }
+}
diff --git a/Emerald/Views/GamesPage.xaml b/Emerald/Views/GamesPage.xaml
new file mode 100644
index 00000000..fcfa58e1
--- /dev/null
+++ b/Emerald/Views/GamesPage.xaml
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Emerald/Views/GamesPage.xaml.cs b/Emerald/Views/GamesPage.xaml.cs
new file mode 100644
index 00000000..ebc1fdda
--- /dev/null
+++ b/Emerald/Views/GamesPage.xaml.cs
@@ -0,0 +1,175 @@
+using System;
+using System.ComponentModel;
+using CommunityToolkit.Mvvm.DependencyInjection;
+using Emerald.CoreX;
+using Emerald.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.Storage;
+using Windows.System;
+using Path = System.IO.Path;
+using System.IO;
+using Emerald.UserControls;
+using Emerald.Helpers;
+
+namespace Emerald.Views;
+
+public sealed partial class GamesPage : Page
+{
+ public GamesPageViewModel ViewModel { get; }
+ private ContentDialog? _addGameDialog;
+
+ public GamesPage()
+ {
+ ViewModel = Ioc.Default.GetService();
+ DataContext = ViewModel;
+ this.InitializeComponent();
+ }
+
+ protected override async void OnNavigatedTo(NavigationEventArgs e)
+ {
+ base.OnNavigatedTo(e);
+ await ViewModel.InitializeCommand.ExecuteAsync(null);
+ }
+
+ private async void AddGame_Click(object sender, RoutedEventArgs e)
+ {
+ ViewModel.StartAddGameCommand.Execute(null);
+
+ var wizardControl = new AddGameWizardControl();
+ // The DataContext is inherited from the Page, so the wizard will use our ViewModel
+ wizardControl.DataContext = ViewModel;
+
+ _addGameDialog = wizardControl.ToContentDialog(null,defaultButton: ContentDialogButton.Primary);
+
+ // We manage the dialog state manually here
+ ViewModel.PropertyChanged += OnViewModelPropertyChanged;
+ _addGameDialog.PrimaryButtonClick += OnDialogPrimaryButtonClick;
+ _addGameDialog.SecondaryButtonClick += OnDialogSecondaryButtonClick;
+
+ UpdateAddGameDialogButtons(); // Set initial button state
+ await _addGameDialog.ShowAsync();
+
+ // Clean up handlers to prevent memory leaks
+ ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
+ _addGameDialog.PrimaryButtonClick -= OnDialogPrimaryButtonClick;
+ _addGameDialog.SecondaryButtonClick -= OnDialogSecondaryButtonClick;
+ _addGameDialog = null;
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ // When a property that affects the button's state changes, we update it
+ if (e.PropertyName is nameof(ViewModel.AddGameWizardStep) or nameof(ViewModel.IsPrimaryButtonEnabled))
+ {
+ UpdateAddGameDialogButtons();
+ }
+ }
+
+ private void UpdateAddGameDialogButtons()
+ {
+ if (_addGameDialog is null) return;
+
+ switch (ViewModel.AddGameWizardStep)
+ {
+ case 0: // Version Selection
+ _addGameDialog.Title = "Add New Game (Step 1 of 2)";
+ _addGameDialog.PrimaryButtonText = "Next";
+ _addGameDialog.SecondaryButtonText = "Cancel";
+ break;
+ case 1: // Customize & Name
+ _addGameDialog.Title = "Add New Game (Step 2 of 2)";
+ _addGameDialog.PrimaryButtonText = "Create";
+ _addGameDialog.SecondaryButtonText = "Back";
+ break;
+ }
+ _addGameDialog.IsPrimaryButtonEnabled = ViewModel.IsPrimaryButtonEnabled;
+ }
+
+ private void OnDialogPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ // "Next" or "Create"
+ if (ViewModel.AddGameWizardStep < 1) // If not on the last step
+ {
+ args.Cancel = true; // Keep dialog open
+ ViewModel.GoToNextStepCommand.Execute(null);
+ }
+ else
+ {
+ // Last step, create game and let dialog close
+ ViewModel.CreateGameCommand.Execute(null);
+ }
+ }
+
+ private void OnDialogSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
+ {
+ // "Back" or "Cancel"
+ if (ViewModel.AddGameWizardStep > 0)
+ {
+ args.Cancel = true; // Keep dialog open
+ ViewModel.GoToPreviousStepCommand.Execute(null);
+ }
+ // On step 0, do nothing, which lets the dialog close as "Cancel"
+ }
+
+ // Unchanged methods below...
+ private async void ManageSettings_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuFlyoutItem item && item.Tag is Game game)
+ {
+ var GameSettingsControl = new MinecraftSettingsUC()
+ {
+ ShowMainSettings = false
+ };
+ GameSettingsControl.GameSettings = game.Options;
+ var SettingsDialog = GameSettingsControl.ToContentDialog("Game Settings - " + game.Version.DisplayName, "Close");
+
+ var result = await SettingsDialog.ShowAsync();
+
+ var core = Ioc.Default.GetService();
+ core.SaveGames();
+ }
+ }
+
+
+ private async void OpenFolder_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuFlyoutItem item && item.Tag is Game game)
+ {
+ try
+ {
+ var folderPath = game.Path.BasePath;
+ if (Directory.Exists(folderPath))
+ {
+ await Launcher.LaunchFolderAsync(await StorageFolder.GetFolderFromPathAsync(folderPath));
+ }
+ else
+ {
+ var notification = Ioc.Default.GetService();
+ notification.Warning("FolderNotFound", "Game folder does not exist");
+ }
+ }
+ catch (Exception ex)
+ {
+ this.Log().LogError(ex, "Failed to open game folder");
+ }
+ }
+ }
+
+ private void InstallGame_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is Game game)
+ {
+ _ = ViewModel.InstallGameCommand.ExecuteAsync(game);
+ }
+ }
+
+ private void LaunchGame_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is Game game)
+ {
+ _ = ViewModel.LaunchGameCommand.ExecuteAsync(game);
+ }
+ }
+}
diff --git a/Emerald/Views/Settings/AppearancePage.xaml b/Emerald/Views/Settings/AppearancePage.xaml
index cee492a4..7dfa5ea8 100644
--- a/Emerald/Views/Settings/AppearancePage.xaml
+++ b/Emerald/Views/Settings/AppearancePage.xaml
@@ -7,22 +7,22 @@
+ Content="{helpers:Localize KeyName=SystemDefault}" />
+ Content="{helpers:Localize KeyName=Light}" />
+ Content="{helpers:Localize KeyName=Dark}" />
@@ -33,31 +33,31 @@
+ Content="{helpers:Localize KeyName=Default}" />
+ Content="{helpers:Localize KeyName=Colorful}" />
+ Content="{helpers:Localize KeyName=NoColor}" />
+ Content="{helpers:Localize KeyName=AccentColor}" />
+ Content="{helpers:Localize KeyName=CustomColor}" />
@@ -96,12 +96,12 @@
-
+
+ Content="{helpers:Localize KeyName=Add}" />
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- GB
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ mc:Ignorable="d" xmlns:helpers="using:Emerald.Helpers"
+ xmlns:cn="using:CommunityToolkit.WinUI.Controls" xmlns:uc="using:Emerald.UserControls">
+
+
diff --git a/Emerald/Views/Settings/GeneralPage.xaml.cs b/Emerald/Views/Settings/GeneralPage.xaml.cs
index efbe333c..a8897d2a 100644
--- a/Emerald/Views/Settings/GeneralPage.xaml.cs
+++ b/Emerald/Views/Settings/GeneralPage.xaml.cs
@@ -34,46 +34,6 @@ public GeneralPage()
{
SS = Ioc.Default.GetService();
this.InitializeComponent();
+ MCUC.GameSettings = SS.Settings.GameSettings;
}
- private async void btnChangeMPath_Click(object sender, RoutedEventArgs e)
- {
- this.Log().LogInformation("Choosing MC path");
- string path;
-
- var fop = new FolderPicker
- {
- CommitButtonText = "Select".Localize()
- };
- fop.FileTypeFilter.Add("*");
-
- if(DirectResoucres.Platform == "Windows")
- WinRT.Interop.InitializeWithWindow.Initialize(fop, WinRT.Interop.WindowNative.GetWindowHandle(App.Current.MainWindow));
-
- var f = await fop.PickSingleFolderAsync();
-
- if (f != null)
- path = f.Path;
- else
- {
- this.Log().LogInformation("User did not select a MC path");
- return;
- }
-
- this.Log().LogInformation("New Minecraft path: {path}",path);
- SS.Settings.Minecraft.Path = path;
-
- await Ioc.Default.GetService().InitializeAndRefresh(new(path));
- }
-
- private void btnRamPlus_Click(object sender, RoutedEventArgs e) =>
- SS.Settings.GameSettings.MaximumRamMb = SS.Settings.Minecraft.RAM + (SS.Settings.Minecraft.RAM >= DirectResoucres.MaxRAM ? 0 : (DirectResoucres.MaxRAM - SS.Settings.GameSettings.MaximumRamMb >= 50 ? 50 : DirectResoucres.MaxRAM - SS.Settings.GameSettings.MaximumRamMb));
-
-
- private void btnRamMinus_Click(object sender, RoutedEventArgs e) =>
- SS.Settings.Minecraft.RAM = SS.Settings.GameSettings.MaximumRamMb - (SS.Settings.GameSettings.MaximumRamMb <= DirectResoucres.MinRAM ? 0 : 50);
-
-
- private void btnAutoRAM_Click(object sender, RoutedEventArgs e) =>
- SS.Settings.Minecraft.RAM = DirectResoucres.MaxRAM / 2;
-
}
diff --git a/Emerald/Views/Settings/SettingsPage.xaml b/Emerald/Views/Settings/SettingsPage.xaml
index 4d7b56dc..bb236f0f 100644
--- a/Emerald/Views/Settings/SettingsPage.xaml
+++ b/Emerald/Views/Settings/SettingsPage.xaml
@@ -40,16 +40,16 @@
diff --git a/global.json b/global.json
index 8623b085..456efb61 100644
--- a/global.json
+++ b/global.json
@@ -1,8 +1,8 @@
{
"msbuild-sdks": {
- "Uno.Sdk": "6.0.96"
+ "Uno.Sdk": "6.2.39"
},
"sdk": {
"allowPrerelease": false
}
-}
\ No newline at end of file
+}