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 @@ -