diff --git a/.github/workflows/LANCommander.Cache.yml b/.github/workflows/LANCommander.Cache.yml
index 24739bde..1aab15a7 100644
--- a/.github/workflows/LANCommander.Cache.yml
+++ b/.github/workflows/LANCommander.Cache.yml
@@ -55,9 +55,6 @@ jobs:
with:
node-version: '20'
- - name: Generate PowerShell Completions
- run: dotnet run --project ./LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj -- ./LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletions.g.ts
-
# Parallel UI builds
- name: Build UI Components
run: |
diff --git a/.github/workflows/LANCommander.Debug.yml b/.github/workflows/LANCommander.Debug.yml
index 6973d1a3..bf6150e6 100644
--- a/.github/workflows/LANCommander.Debug.yml
+++ b/.github/workflows/LANCommander.Debug.yml
@@ -56,8 +56,6 @@ jobs:
uses: actions/setup-node@v3.8.1
# UI
- - name: Generate PowerShell Completions
- run: dotnet run --project ./LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj -- ./LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletions.g.ts
- run: cd ./LANCommander.UI; npm install; npm run package
- run: cd ./LANCommander.Server; npm install
diff --git a/.github/workflows/LANCommander.Development.yml b/.github/workflows/LANCommander.Development.yml
index 85f39a3c..42a9c804 100644
--- a/.github/workflows/LANCommander.Development.yml
+++ b/.github/workflows/LANCommander.Development.yml
@@ -124,6 +124,19 @@ jobs:
build_platform: Windows
build_configuration: Debug
+ build_launcher_win_x64:
+ needs: [prep]
+ if: needs.prep.outputs.changed == 'true'
+ uses: ./.github/workflows/LANCommander.Launcher.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-x64
+ build_arch: x64
+ build_platform: Windows
+ build_configuration: Debug
+
# --------------------------------------------------------------------------
# 3) FINALIZE: if changes == 'true', gather artifacts + push Docker:development
# --------------------------------------------------------------------------
@@ -132,6 +145,7 @@ jobs:
- prep
- build_server_linux_x64
- build_server_win_x64
+ - build_launcher_win_x64
if: needs.prep.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
@@ -233,6 +247,12 @@ jobs:
name: LANCommander.Server-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
+ - name: Download Launcher Windows x64
+ uses: actions/download-artifact@v4
+ with:
+ name: LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ path: artifacts
+
- name: Delete existing release assets
uses: dev-drprasad/delete-tag-and-release@v1.1
with:
@@ -250,5 +270,6 @@ jobs:
files: |
artifacts/LANCommander.Server-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Server-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml b/.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml
new file mode 100644
index 00000000..a1549437
--- /dev/null
+++ b/.github/workflows/LANCommander.Launcher.Avalonia.Visual.yml
@@ -0,0 +1,254 @@
+name: Avalonia Launcher Visual Tests
+
+# ---------------------------------------------------------------------------
+# Triggers
+# ---------------------------------------------------------------------------
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - 'LANCommander.Launcher.Avalonia/**'
+ - 'LANCommander.Launcher.Avalonia.Tests/**'
+
+ push:
+ branches: [main]
+ paths:
+ - 'LANCommander.Launcher.Avalonia/**'
+ - 'LANCommander.Launcher.Avalonia.Tests/**'
+
+ # Manual dispatch: re-run tests and optionally commit updated baselines.
+ workflow_dispatch:
+ inputs:
+ update_baselines:
+ description: 'Commit updated baselines back to the branch (use after intentional UI changes)'
+ type: boolean
+ default: false
+
+# ---------------------------------------------------------------------------
+# Permissions
+# ---------------------------------------------------------------------------
+permissions:
+ contents: write # allow committing updated baselines
+ pull-requests: write # allow posting PR comments
+
+# ---------------------------------------------------------------------------
+# Jobs
+# ---------------------------------------------------------------------------
+jobs:
+ visual-tests:
+ # Self-hosted runner required: needs access to your local LANCommander server
+ # so that any future integration tests can connect and capture live screenshots.
+ # Tag your runner with 'lancommander' in GitHub → Settings → Actions → Runners.
+ runs-on: [self-hosted, lancommander]
+
+ env:
+ DOTNET_NOLOGO: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
+
+ # Paths used by ScreenshotHelper — relative to workspace so artifacts are easy to find.
+ VISUAL_SCREENSHOTS_DIR: ${{ github.workspace }}/visual-test-output/screenshots
+ VISUAL_DIFFS_DIR: ${{ github.workspace }}/visual-test-output/diffs
+ # Baselines are loaded from the build output (copied from Baselines/ content items).
+ # Override this var only if you move the Baselines folder.
+
+ steps:
+ # -----------------------------------------------------------------------
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ # Fetch all history so we can commit baseline updates.
+ fetch-depth: 0
+
+ # -----------------------------------------------------------------------
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ # -----------------------------------------------------------------------
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.NUGET_PACKAGES }}
+ key: nuget-${{ runner.os }}-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }}
+ restore-keys: |
+ nuget-${{ runner.os }}-
+
+ # -----------------------------------------------------------------------
+ - name: Restore dependencies
+ run: dotnet restore --locked-mode
+
+ # -----------------------------------------------------------------------
+ - name: Build test project
+ run: |
+ dotnet build LANCommander.Launcher.Avalonia.Tests/ \
+ --no-restore \
+ --configuration Release
+
+ # -----------------------------------------------------------------------
+ - name: Run visual layout tests
+ id: run-tests
+ # continue-on-error so we can still upload artifacts and post a PR comment
+ # even when tests fail. The final step re-raises the failure.
+ continue-on-error: true
+ run: |
+ dotnet test LANCommander.Launcher.Avalonia.Tests/ \
+ --no-build \
+ --configuration Release \
+ --logger "trx;LogFileName=${{ github.workspace }}/visual-test-results.trx" \
+ --logger "console;verbosity=normal"
+
+ # -----------------------------------------------------------------------
+ # Always upload screenshots + diffs so you can review what the UI looked
+ # like during this run, regardless of pass/fail.
+ - name: Upload screenshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: visual-screenshots-${{ github.sha }}
+ path: ${{ env.VISUAL_SCREENSHOTS_DIR }}/
+ if-no-files-found: ignore
+
+ - name: Upload diff images
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: visual-diffs-${{ github.sha }}
+ path: ${{ env.VISUAL_DIFFS_DIR }}/
+ if-no-files-found: ignore
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: visual-test-results-${{ github.sha }}
+ path: visual-test-results.trx
+ if-no-files-found: ignore
+
+ # -----------------------------------------------------------------------
+ # Write a step summary that shows the pass/fail status and links to artifacts.
+ - name: Write step summary
+ if: always()
+ shell: bash
+ run: |
+ STATUS="${{ steps.run-tests.outcome }}"
+ echo "## Avalonia Visual Test Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ if [ "$STATUS" = "success" ]; then
+ echo "✅ All visual tests passed — no regressions detected." >> $GITHUB_STEP_SUMMARY
+ else
+ echo "❌ Visual regressions detected (or new baselines need to be committed)." >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Next steps:**" >> $GITHUB_STEP_SUMMARY
+ echo "1. Download the **visual-screenshots** and **visual-diffs** artifacts to review changes." >> $GITHUB_STEP_SUMMARY
+ echo "2. If the changes are intentional, run the workflow manually with **Update baselines** checked." >> $GITHUB_STEP_SUMMARY
+ echo "3. If the changes are regressions, fix the layout before merging." >> $GITHUB_STEP_SUMMARY
+ fi
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Artifact | Link |" >> $GITHUB_STEP_SUMMARY
+ echo "|---|---|" >> $GITHUB_STEP_SUMMARY
+ echo "| Screenshots | [visual-screenshots-${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> $GITHUB_STEP_SUMMARY
+ echo "| Diff images | [visual-diffs-${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" >> $GITHUB_STEP_SUMMARY
+
+ # -----------------------------------------------------------------------
+ # On pull requests, post a comment summarising the outcome so reviewers
+ # don't have to open the Actions tab to check for visual regressions.
+ - name: Post PR comment
+ if: github.event_name == 'pull_request' && always()
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const outcome = '${{ steps.run-tests.outcome }}';
+ const sha = '${{ github.sha }}'.slice(0, 7);
+ const runUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
+
+ const icon = outcome === 'success' ? '✅' : '❌';
+ const status = outcome === 'success'
+ ? 'No visual regressions detected.'
+ : 'Visual regressions detected (or new baselines need to be committed). Review the diff artifacts in the Actions run.';
+
+ const body = [
+ `## ${icon} Avalonia Visual Tests — \`${sha}\``,
+ '',
+ status,
+ '',
+ `**Artifacts:** [Screenshots & diffs](${runUrl})`,
+ '',
+ outcome !== 'success'
+ ? '_To accept intentional UI changes, trigger the **Avalonia Launcher Visual Tests** workflow manually with **Update baselines** enabled._'
+ : '',
+ ].join('\n');
+
+ // Find and update an existing bot comment, or create a new one.
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const marker = '## ✅ Avalonia Visual Tests';
+ const markerFail = '## ❌ Avalonia Visual Tests';
+ const existing = comments.find(c =>
+ c.user.type === 'Bot' &&
+ (c.body.includes('## ✅ Avalonia Visual Tests') || c.body.includes('## ❌ Avalonia Visual Tests'))
+ );
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body,
+ });
+ }
+
+ # -----------------------------------------------------------------------
+ # Update baselines: copy screenshots → Baselines/ and commit.
+ # Only runs when triggered via workflow_dispatch with update_baselines=true.
+ - name: Update baselines
+ if: inputs.update_baselines == true
+ shell: bash
+ run: |
+ BASELINES_DIR="LANCommander.Launcher.Avalonia.Tests/Baselines"
+ SCREENSHOTS_DIR="${{ env.VISUAL_SCREENSHOTS_DIR }}"
+
+ if [ ! -d "$SCREENSHOTS_DIR" ] || [ -z "$(ls -A "$SCREENSHOTS_DIR")" ]; then
+ echo "No screenshots found at $SCREENSHOTS_DIR — nothing to update."
+ exit 1
+ fi
+
+ mkdir -p "$BASELINES_DIR"
+ cp "$SCREENSHOTS_DIR"/*.png "$BASELINES_DIR/"
+ echo "Copied screenshots:"
+ ls "$BASELINES_DIR"
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add "$BASELINES_DIR"
+
+ if git diff --staged --quiet; then
+ echo "Baselines unchanged — nothing to commit."
+ else
+ git commit -m "chore: update Avalonia visual test baselines [skip ci]
+
+ Updated by workflow run ${{ github.run_id }} on branch ${{ github.ref_name }}."
+ git push
+ echo "Baselines committed and pushed."
+ fi
+
+ # -----------------------------------------------------------------------
+ # Re-raise test failure AFTER artifacts have been uploaded and comments posted.
+ - name: Fail on test regression
+ if: steps.run-tests.outcome == 'failure' && inputs.update_baselines != true
+ run: |
+ echo "Visual tests failed. See artifacts and step summary for details."
+ exit 1
diff --git a/.github/workflows/LANCommander.Launcher.Avalonia.yml b/.github/workflows/LANCommander.Launcher.Avalonia.yml
new file mode 100644
index 00000000..53060138
--- /dev/null
+++ b/.github/workflows/LANCommander.Launcher.Avalonia.yml
@@ -0,0 +1,123 @@
+name: LANCommander Launcher Avalonia Build
+
+on:
+ workflow_dispatch:
+ workflow_call:
+ inputs:
+ version_semver:
+ description: "Semantic Version"
+ required: true
+ type: string
+ version_tag:
+ description: 'Version Tag'
+ required: true
+ type: string
+ build_dotnet_version:
+ description: 'Build .NET Version'
+ required: true
+ type: string
+ build_runtime:
+ description: 'Build Runtime'
+ required: false
+ type: string
+ default: 'win-x64'
+ build_arch:
+ description: 'Build Architecture'
+ required: false
+ type: string
+ default: 'x64'
+ build_platform:
+ description: 'Build Platform'
+ required: false
+ type: string
+ default: 'Windows'
+ build_configuration:
+ description: 'Build Configuration (Debug/Release)'
+ required: false
+ type: string
+ default: 'Release'
+
+permissions:
+ contents: write
+
+env:
+ NUGET_PACKAGES: ${{ github.workspace }}/.nuget/package
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ steps:
+ # Checkout code
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ # .NET Setup
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ inputs.build_dotnet_version }}
+
+ - name: Restore dependencies
+ run: dotnet restore --locked-mode
+
+ - name: Publish Avalonia Launcher
+ run: |
+ # Strip leading 'v' if present
+ RAW_VERSION="${{ inputs.version_tag }}" # e.g. v2.0.0-rc1
+ SEMVER="${RAW_VERSION#v}" # 2.0.0-rc1
+
+ # Numeric part only for Assembly/FileVersion
+ NUMERIC="${SEMVER%%-*}" # 2.0.0
+ ASSEMBLY_VERSION="${NUMERIC}.0" # 2.0.0.0
+
+ echo "SEMVER=$SEMVER"
+ echo "ASSEMBLY_VERSION=$ASSEMBLY_VERSION"
+
+ dotnet publish "./LANCommander.Launcher.Avalonia/LANCommander.Launcher.Avalonia.csproj" \
+ -c "${{ inputs.build_configuration }}" \
+ --self-contained \
+ --runtime "${{ inputs.build_runtime }}" \
+ -p:Version="$SEMVER" \
+ -p:AssemblyVersion="$ASSEMBLY_VERSION" \
+ -p:FileVersion="$ASSEMBLY_VERSION" \
+ -p:InformationalVersion="$SEMVER" \
+ -p:PublishSingleFile=true \
+ -p:IncludeNativeLibrariesForSelfExtract=true \
+ -p:IncludeAllContentForSelfExtract=true \
+ -p:EnableCompressionInSingleFile=true \
+ -p:DebugType=embedded
+
+ - name: Bundle and Clean
+ shell: pwsh
+ run: |
+ $BasePath = "LANCommander.Launcher.Avalonia/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish"
+
+ # Remove unnecessary files
+ $PathsToRemove = @(
+ '*.pdb'
+ )
+
+ foreach ($path in $PathsToRemove) {
+ Remove-Item -Recurse -Force -ErrorAction Continue "$BasePath/$path"
+ }
+
+ - name: Compress Build Output
+ shell: pwsh
+ run: |
+ $compress = @{
+ Path = "LANCommander.Launcher.Avalonia/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish/*"
+ DestinationPath = "LANCommander.Launcher.Avalonia-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip"
+ CompressionLevel = "Fastest"
+ }
+ Compress-Archive @compress
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ path: LANCommander.Launcher.Avalonia-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip
+ name: LANCommander.Launcher.Avalonia-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip
diff --git a/.github/workflows/LANCommander.Launcher.Legacy.yml b/.github/workflows/LANCommander.Launcher.Legacy.yml
deleted file mode 100644
index f1102908..00000000
--- a/.github/workflows/LANCommander.Launcher.Legacy.yml
+++ /dev/null
@@ -1,125 +0,0 @@
-name: LANCommander.Launcher.Legacy
-
-on:
- push:
- branches: [main, feature/legacy-launcher]
- paths:
- - 'LANCommander.Launcher.Legacy/**'
- - 'LANCommander.SDK.Cpp/**'
- - '.github/workflows/LANCommander.Launcher.Legacy.yml'
- pull_request:
- branches: [main]
- paths:
- - 'LANCommander.Launcher.Legacy/**'
- - 'LANCommander.SDK.Cpp/**'
- - '.github/workflows/LANCommander.Launcher.Legacy.yml'
- workflow_dispatch:
-
-jobs:
- build-win9x:
- name: Build (Win9x / MinGW-w64 i686)
- runs-on: windows-latest
-
- defaults:
- run:
- shell: msys2 {0}
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- # ---------------------------------------------------------------
- # MSYS2 with MinGW32 (i686) toolchain
- # ---------------------------------------------------------------
- - name: Setup MSYS2
- uses: msys2/setup-msys2@v2
- with:
- msystem: MINGW32
- update: false
- install: >-
- mingw-w64-i686-gcc
- mingw-w64-i686-cmake
- mingw-w64-i686-make
- make
-
- # ---------------------------------------------------------------
- # Vendor dependencies (cJSON, miniz, Allegro 4 source)
- # ---------------------------------------------------------------
- - name: Download vendor dependencies
- shell: pwsh
- run: ./setup-vendor.ps1
-
- # ---------------------------------------------------------------
- # Build Allegro 4 from source (static, no addons)
- # ---------------------------------------------------------------
- - name: Build Allegro 4
- run: |
- ALLEGRO_SRC="LANCommander.Launcher.Legacy/vendor/allegro4/allegro5-4.4.3.1"
- ALLEGRO_BUILD="LANCommander.Launcher.Legacy/build-allegro-win9x"
- ALLEGRO_PREFIX="$(pwd)/LANCommander.Launcher.Legacy/allegro4-win9x"
-
- cmake -S "$ALLEGRO_SRC" -B "$ALLEGRO_BUILD" \
- -G "MinGW Makefiles" \
- -DCMAKE_BUILD_TYPE=Release \
- -DCMAKE_INSTALL_PREFIX="$ALLEGRO_PREFIX" \
- -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
- -DSHARED=OFF \
- -DWANT_EXAMPLES=OFF \
- -DWANT_TOOLS=OFF \
- -DWANT_TESTS=OFF \
- -DWANT_ALLEGROGL=OFF \
- -DWANT_LOADPNG=OFF \
- -DWANT_LOGG=OFF \
- -DWANT_JPGALLEG=OFF \
- -DWANT_FRAMEWORKS=OFF
-
- mingw32-make -C "$ALLEGRO_BUILD" -j$(nproc)
- mingw32-make -C "$ALLEGRO_BUILD" install
-
- # ---------------------------------------------------------------
- # Build the launcher
- # ---------------------------------------------------------------
- - name: Build launcher
- run: |
- LAUNCHER_DIR="LANCommander.Launcher.Legacy"
- LAUNCHER_BUILD="$LAUNCHER_DIR/build-win9x"
- ALLEGRO_PREFIX="$(pwd)/$LAUNCHER_DIR/allegro4-win9x"
-
- cmake -S "$LAUNCHER_DIR" -B "$LAUNCHER_BUILD" \
- -G "MinGW Makefiles" \
- -DCMAKE_BUILD_TYPE=Release \
- -DALLEGRO_STATIC=ON \
- -DTARGET_WIN9X=ON \
- -DALLEGRO_ROOT="$ALLEGRO_PREFIX"
-
- mingw32-make -C "$LAUNCHER_BUILD" -j$(nproc)
-
- # ---------------------------------------------------------------
- # Package
- # ---------------------------------------------------------------
- - name: Package artifacts
- run: |
- mkdir -p out-win9x
-
- LAUNCHER_EXE=$(find LANCommander.Launcher.Legacy/build-win9x -name "launcher.exe" | head -1)
- cp "$LAUNCHER_EXE" out-win9x/LANCommander.exe
- strip out-win9x/LANCommander.exe
-
- # Bundle GDI+ redistributable (MinGW CRT is statically linked)
- if [ -f "$MINGW_PREFIX/bin/gdiplus.dll" ]; then
- cp "$MINGW_PREFIX/bin/gdiplus.dll" out-win9x/
- echo "Bundled: gdiplus.dll"
- fi
-
- # Show PE info for verification
- objdump -p out-win9x/LANCommander.exe | grep -i "Version\|Subsystem" || true
-
- echo ""
- ls -lh out-win9x/
-
- - name: Upload artifact
- uses: actions/upload-artifact@v4
- with:
- name: LANCommander-Legacy-Win9x
- path: out-win9x/
- if-no-files-found: error
diff --git a/.github/workflows/LANCommander.Launcher.Tests.PRComment.yml b/.github/workflows/LANCommander.Launcher.Tests.PRComment.yml
deleted file mode 100644
index 225074fe..00000000
--- a/.github/workflows/LANCommander.Launcher.Tests.PRComment.yml
+++ /dev/null
@@ -1,127 +0,0 @@
-name: LANCommander Launcher Tests — PR Visual Diff Comment
-
-on:
- workflow_call:
- inputs:
- pr_number:
- description: 'Pull request number to comment on'
- required: true
- type: string
- artifact_run_id:
- description: 'Workflow run ID that produced the launcher-visual-artifacts artifact'
- required: true
- type: string
-
-permissions:
- contents: write # push diff PNGs to the gh-visual-diffs branch
- pull-requests: write # post / update the comment
-
-jobs:
- comment:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout for branch push
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Download visual artifacts
- uses: actions/download-artifact@v4
- with:
- name: launcher-visual-artifacts
- path: visual
- run-id: ${{ inputs.artifact_run_id }}
- github-token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Read regression manifest
- id: manifest
- run: |
- if [ ! -f visual/regressions.json ]; then
- echo "no manifest — assuming no visual run"
- echo "count=0" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- count=$(jq '.regressed_count' visual/regressions.json)
- echo "count=$count" >> "$GITHUB_OUTPUT"
-
- - name: Skip when no regressions
- if: steps.manifest.outputs.count == '0'
- run: echo "No visual regressions; nothing to comment."
-
- # Push diff/actual/baseline PNGs to an orphan branch so raw.githubusercontent.com
- # URLs render inline in the PR comment. One folder per (PR, run).
- - name: Push diffs to gh-visual-diffs branch
- if: steps.manifest.outputs.count != '0'
- env:
- PR: ${{ inputs.pr_number }}
- RUN: ${{ inputs.artifact_run_id }}
- run: |
- set -euo pipefail
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-
- # Worktree off an orphan branch so we never touch the source tree.
- if git ls-remote --exit-code --heads origin gh-visual-diffs >/dev/null; then
- git fetch origin gh-visual-diffs
- git worktree add /tmp/visual gh-visual-diffs
- else
- git worktree add --detach /tmp/visual
- cd /tmp/visual
- git checkout --orphan gh-visual-diffs
- git rm -rf . 2>/dev/null || true
- cd -
- fi
-
- DEST="/tmp/visual/pr-${PR}/run-${RUN}"
- mkdir -p "$DEST"
- cp -r visual/diffs "$DEST/" 2>/dev/null || true
- cp -r visual/screenshots "$DEST/" 2>/dev/null || true
- cp -r visual/baselines "$DEST/" 2>/dev/null || true
-
- cd /tmp/visual
- git add -A
- git -c user.name=github-actions -c user.email=github-actions@github.com \
- commit -m "Visual diffs for PR #${PR} run ${RUN}"
- git push origin gh-visual-diffs
-
- - name: Post / update PR comment
- if: steps.manifest.outputs.count != '0'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PR: ${{ inputs.pr_number }}
- RUN: ${{ inputs.artifact_run_id }}
- REPO: ${{ github.repository }}
- MARKER: ''
- run: |
- set -euo pipefail
- base="https://raw.githubusercontent.com/${REPO}/gh-visual-diffs/pr-${PR}/run-${RUN}"
-
- body=$(mktemp)
- {
- echo "$MARKER"
- echo "## :art: Launcher visual regressions"
- echo
- echo "$(jq '.regressed_count' visual/regressions.json) baseline(s) drifted in [run ${RUN}](https://github.com/${REPO}/actions/runs/${RUN})."
- echo
- jq -r '.regressions[] | .name' visual/regressions.json | while read -r name; do
- echo "${name}
"
- echo
- echo "| Baseline | Actual | Diff |"
- echo "| --- | --- | --- |"
- echo "|  |  |  |"
- echo
- echo " "
- echo
- done
- echo "_If these changes are intentional, run the **Update Visual Baselines** workflow to refresh._"
- } >> "$body"
-
- # Find an existing comment with our marker; update it if present.
- existing=$(gh api repos/${REPO}/issues/${PR}/comments --paginate \
- --jq ".[] | select(.body | startswith(\"${MARKER}\")) | .id" | head -1)
-
- if [ -n "$existing" ]; then
- gh api -X PATCH "repos/${REPO}/issues/comments/${existing}" --field "body=@${body}"
- else
- gh api -X POST "repos/${REPO}/issues/${PR}/comments" --field "body=@${body}"
- fi
diff --git a/.github/workflows/LANCommander.Launcher.Tests.UpdateBaselines.yml b/.github/workflows/LANCommander.Launcher.Tests.UpdateBaselines.yml
deleted file mode 100644
index 8fd35320..00000000
--- a/.github/workflows/LANCommander.Launcher.Tests.UpdateBaselines.yml
+++ /dev/null
@@ -1,91 +0,0 @@
-name: LANCommander Launcher Tests — Update Visual Baselines
-
-# Operator-triggered: re-renders every visual test on the CI host and commits the
-# new screenshots over the committed Baselines/. Only runs when a human dispatches
-# it from the Actions tab — never on push or PR — so accidental drift can't slip in.
-
-on:
- workflow_dispatch:
- inputs:
- branch:
- description: 'Branch to commit refreshed baselines to'
- required: true
- type: string
- default: 'main'
- build_dotnet_version:
- description: 'Build .NET Version'
- required: false
- type: string
- default: '9.0.102'
-
-permissions:
- contents: write
-
-jobs:
- refresh:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ inputs.branch }}
- submodules: true
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: ${{ inputs.build_dotnet_version }}
-
- - name: Install rendering prereqs
- run: |
- sudo apt-get update -qq
- sudo apt-get install -y --no-install-recommends \
- libfontconfig1 libfreetype6 libice6 libsm6 libx11-6 libxcb1 libxext6
-
- - name: Restore + build visual test project
- run: |
- dotnet restore LANCommander.Launcher.Tests/LANCommander.Launcher.Tests.csproj
- dotnet build LANCommander.Launcher.Tests/LANCommander.Launcher.Tests.csproj -c Debug --no-restore
-
- # Run visual tests, ignore failures (regressions are expected here — that's
- # the whole point of refreshing). Then promote every captured screenshot
- # to be the new baseline.
- - name: Capture fresh screenshots
- continue-on-error: true
- run: |
- dotnet test LANCommander.Launcher.Tests/LANCommander.Launcher.Tests.csproj \
- -c Debug --no-build --no-restore \
- --logger "console;verbosity=normal"
-
- - name: Promote captures to baselines
- run: |
- set -euo pipefail
- src=LANCommander.Launcher.Tests/bin/Debug/net10.0/Screenshots
- dst=LANCommander.Launcher.Tests/Baselines
-
- if [ ! -d "$src" ]; then
- echo "::error::No screenshots captured at $src — visual tests didn't run."
- exit 1
- fi
-
- mkdir -p "$dst"
- cp -v "$src"/*.png "$dst"/
-
- echo "## Updated baselines" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- ls -1 "$dst"/*.png | xargs -n1 basename | sed 's/^/- /' >> "$GITHUB_STEP_SUMMARY"
-
- - name: Commit + push refreshed baselines
- run: |
- set -euo pipefail
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-
- if git diff --quiet -- LANCommander.Launcher.Tests/Baselines; then
- echo "Baselines unchanged — nothing to commit."
- exit 0
- fi
-
- git add LANCommander.Launcher.Tests/Baselines
- git commit -m "Refresh launcher visual baselines from CI run ${GITHUB_RUN_ID}"
- git push origin HEAD:${{ inputs.branch }}
diff --git a/.github/workflows/LANCommander.Launcher.Tests.yml b/.github/workflows/LANCommander.Launcher.Tests.yml
deleted file mode 100644
index d7fd3892..00000000
--- a/.github/workflows/LANCommander.Launcher.Tests.yml
+++ /dev/null
@@ -1,170 +0,0 @@
-name: LANCommander Launcher Tests
-
-on:
- workflow_call:
- inputs:
- build_dotnet_version:
- description: 'Build .NET Version'
- required: true
- type: string
- outputs:
- visual_diff_count:
- description: 'Number of visual baselines that regressed in this run'
- value: ${{ jobs.test.outputs.visual_diff_count }}
- workflow_dispatch:
- inputs:
- build_dotnet_version:
- description: 'Build .NET Version'
- required: false
- type: string
- default: '9.0.102'
-
-permissions:
- contents: read
-
-jobs:
- test:
- name: Run Launcher Tests
- runs-on: ubuntu-latest
-
- outputs:
- visual_diff_count: ${{ steps.summarize.outputs.visual_diff_count }}
-
- steps:
- - uses: actions/checkout@v4
- with:
- submodules: true
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: ${{ inputs.build_dotnet_version }}
-
- # Avalonia headless rendering uses Skia + the Inter font. Install
- # font + GPU prereqs so screenshots render consistently.
- - name: Install rendering prereqs
- run: |
- sudo apt-get update -qq
- sudo apt-get install -y --no-install-recommends \
- libfontconfig1 libfreetype6 libice6 libsm6 libx11-6 libxcb1 libxext6
-
- - name: Restore
- run: dotnet restore
-
- # ---- Unit + integration test projects ---------------------------------
- # Services.Tests and IntegrationTests build cleanly without the
- # Server/UI npm+pwsh chain. Launcher.Tests likewise just needs Skia.
-
- - name: Build test projects
- run: |
- dotnet build LANCommander.Launcher.Services.Tests/LANCommander.Launcher.Services.Tests.csproj -c Debug --no-restore
- dotnet build LANCommander.Launcher.IntegrationTests/LANCommander.Launcher.IntegrationTests.csproj -c Debug --no-restore
- dotnet build LANCommander.Launcher.Tests/LANCommander.Launcher.Tests.csproj -c Debug --no-restore
-
- - name: Run Services unit tests
- run: |
- dotnet test LANCommander.Launcher.Services.Tests/LANCommander.Launcher.Services.Tests.csproj \
- -c Debug --no-build --no-restore \
- --logger "trx;LogFileName=services.trx" \
- --logger "console;verbosity=normal" \
- --results-directory test-results \
- --collect:"XPlat Code Coverage"
-
- - name: Run integration tests
- run: |
- dotnet test LANCommander.Launcher.IntegrationTests/LANCommander.Launcher.IntegrationTests.csproj \
- -c Debug --no-build --no-restore \
- --logger "trx;LogFileName=integration.trx" \
- --logger "console;verbosity=normal" \
- --results-directory test-results \
- --collect:"XPlat Code Coverage"
-
- # Visual tests are continued-on-error so a baseline regression doesn't
- # mask any earlier failure and so we always reach the artifact-upload
- # + summary steps below. The summary step re-asserts the failure for CI.
- - name: Run visual tests
- id: visual_tests
- continue-on-error: true
- run: |
- dotnet test LANCommander.Launcher.Tests/LANCommander.Launcher.Tests.csproj \
- -c Debug --no-build --no-restore \
- --logger "trx;LogFileName=visual.trx" \
- --logger "console;verbosity=normal" \
- --results-directory test-results
-
- # ---- Visual artifact collection --------------------------------------
- # Aggregate every screenshot + diff PNG into top-level folders so the
- # PR-comment job downstream can find them by predictable name.
-
- - name: Collect visual artifacts
- if: always()
- run: |
- mkdir -p visual/screenshots visual/diffs visual/baselines
- cp -r LANCommander.Launcher.Tests/bin/Debug/net10.0/Screenshots/. visual/screenshots/ 2>/dev/null || true
- cp -r LANCommander.Launcher.Tests/bin/Debug/net10.0/Diffs/. visual/diffs/ 2>/dev/null || true
- cp -r LANCommander.Launcher.Tests/Baselines/. visual/baselines/ 2>/dev/null || true
-
- - name: Summarize visual diffs
- id: summarize
- if: always()
- run: |
- shopt -s nullglob
- diffs=(visual/diffs/*.diff.png)
- count=${#diffs[@]}
-
- echo "visual_diff_count=$count" >> "$GITHUB_OUTPUT"
- echo "## Visual test summary" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "$count baseline(s) regressed." >> "$GITHUB_STEP_SUMMARY"
-
- # JSON manifest the PR-comment job consumes
- python3 - "$count" <<'PY' > visual/regressions.json
- import json, os, sys
- count = int(sys.argv[1])
- diffs = sorted(os.listdir("visual/diffs")) if os.path.isdir("visual/diffs") else []
- data = {
- "regressed_count": count,
- "regressions": [
- {
- "name": d.replace(".diff.png", ""),
- "diff": f"visual/diffs/{d}",
- "actual": f"visual/screenshots/{d.replace('.diff.png', '.png')}",
- "baseline": f"visual/baselines/{d.replace('.diff.png', '.png')}",
- }
- for d in diffs
- ],
- }
- print(json.dumps(data, indent=2))
- PY
-
- cat visual/regressions.json
-
- - name: Upload visual artifacts
- if: always()
- uses: actions/upload-artifact@v4
- with:
- name: launcher-visual-artifacts
- path: |
- visual/screenshots/**
- visual/diffs/**
- visual/baselines/**
- visual/regressions.json
- if-no-files-found: warn
- retention-days: 14
-
- - name: Upload TRX + coverage
- if: always()
- uses: actions/upload-artifact@v4
- with:
- name: launcher-test-results
- path: test-results/**
- if-no-files-found: warn
- retention-days: 14
-
- # Re-assert the visual test outcome so CI fails when baselines diverge,
- # after artifacts are guaranteed uploaded.
- - name: Fail if visual tests regressed
- if: always() && steps.visual_tests.outcome == 'failure'
- run: |
- echo "::error::Visual tests reported regressions. See the launcher-visual-artifacts artifact."
- exit 1
diff --git a/.github/workflows/LANCommander.Launcher.yml b/.github/workflows/LANCommander.Launcher.yml
index a5764641..864d3b51 100644
--- a/.github/workflows/LANCommander.Launcher.yml
+++ b/.github/workflows/LANCommander.Launcher.yml
@@ -45,18 +45,27 @@ env:
jobs:
build:
- runs-on: ${{ inputs.build_platform == 'Linux' && inputs.build_arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
+ runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
+ - uses: frabert/replace-string-action@v2
+ name: Swap Path Backslashes
+ id: swap_path_backslashes
+ with:
+ string: '${{ github.workspace }}'
+ pattern: '\\'
+ replace-with: '/'
+ flags: g
+
# Checkout code
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v3
with:
submodules: true
- # .NET Setup
+ # .NET Setup and Caching
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
@@ -65,7 +74,23 @@ jobs:
- name: Restore dependencies
run: dotnet restore --locked-mode
- - name: Publish Launcher
+ # Node.js Setup and Caching
+ - name: Setup Node.js
+ uses: actions/setup-node@v3.8.1
+ with:
+ node-version: '20'
+
+ - name: Install Node Packages
+ run: |
+ npm install --prefix ./LANCommander.UI
+ npm install --prefix ./LANCommander.Launcher
+
+ - name: Package Frontend
+ run: |
+ npm run package --prefix ./LANCommander.UI
+ npm run package --prefix ./LANCommander.Launcher
+
+ - name: Publish Updater and Launcher
run: |
# Strip leading 'v' if present
RAW_VERSION="${{ inputs.version_tag }}" # e.g. v2.0.0-rc1
@@ -78,6 +103,15 @@ jobs:
echo "SEMVER=$SEMVER"
echo "ASSEMBLY_VERSION=$ASSEMBLY_VERSION"
+ dotnet publish "./LANCommander.AutoUpdater/LANCommander.AutoUpdater.csproj" \
+ -c "${{ inputs.build_configuration }}" \
+ --self-contained \
+ --runtime "${{ inputs.build_runtime }}" \
+ -p:Version="$SEMVER" \
+ -p:AssemblyVersion="$ASSEMBLY_VERSION" \
+ -p:FileVersion="$ASSEMBLY_VERSION" \
+ -p:InformationalVersion="$SEMVER"
+
dotnet publish "./LANCommander.Launcher/LANCommander.Launcher.csproj" \
-c "${{ inputs.build_configuration }}" \
--self-contained \
@@ -85,219 +119,48 @@ jobs:
-p:Version="$SEMVER" \
-p:AssemblyVersion="$ASSEMBLY_VERSION" \
-p:FileVersion="$ASSEMBLY_VERSION" \
- -p:InformationalVersion="$SEMVER" \
- -p:PublishSingleFile=true \
- -p:IncludeNativeLibrariesForSelfExtract=true \
- -p:IncludeAllContentForSelfExtract=true \
- -p:EnableCompressionInSingleFile=true \
- -p:DebugType=embedded
+ -p:InformationalVersion="$SEMVER"
- - name: Bundle libvlc (Linux)
- if: inputs.build_platform == 'Linux'
- shell: bash
- run: |
- PUBLISH_DIR="LANCommander.Launcher/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish"
- VLC_DIR="$PUBLISH_DIR/libvlc/${{ inputs.build_runtime }}"
- mkdir -p "$VLC_DIR"
-
- if [ "${{ inputs.build_arch }}" = "arm64" ]; then
- # Enable arm64 multiarch and add Ubuntu Ports repository so apt can
- # download arm64 packages on this x64 runner.
- sudo dpkg --add-architecture arm64
- CODENAME=$(lsb_release -cs)
- echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${CODENAME} main restricted universe" \
- | sudo tee /etc/apt/sources.list.d/ubuntu-ports-arm64.list > /dev/null
- echo "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${CODENAME}-updates main restricted universe" \
- | sudo tee -a /etc/apt/sources.list.d/ubuntu-ports-arm64.list > /dev/null
- sudo apt-get update -qq
-
- sudo apt-get download libvlc5:arm64 libvlccore9:arm64 vlc-plugin-base:arm64
-
- mkdir -p vlc-extracted
- for deb in libvlc5_*arm64.deb libvlccore9_*arm64.deb vlc-plugin-base_*arm64.deb; do
- dpkg -x "$deb" vlc-extracted/
- done
-
- LIB_DIR="vlc-extracted/usr/lib/aarch64-linux-gnu"
- PLUGINS_SRC="$LIB_DIR/vlc/plugins"
- else
- sudo apt-get install -y --no-install-recommends libvlc5 libvlccore9 vlc-plugin-base
- LIB_DIR="/usr/lib/x86_64-linux-gnu"
- PLUGINS_SRC="$LIB_DIR/vlc/plugins"
- fi
-
- # Copy the two core shared libraries, dereferencing symlinks so their
- # content is preserved correctly when zipped.
- for lib in libvlc.so.5 libvlccore.so.9; do
- src=$(find "$LIB_DIR" -maxdepth 1 -name "${lib}*" | sort | head -1)
- if [ -n "$src" ]; then
- cp -L "$src" "$VLC_DIR/$lib"
- echo "Bundled: $lib"
- else
- echo "WARNING: $lib not found in $LIB_DIR" >&2
- fi
- done
-
- # Copy VLC plugins (vlc-plugin-base covers common codecs and demuxers)
- if [ -d "$PLUGINS_SRC" ]; then
- mkdir -p "$VLC_DIR/plugins"
- cp -rL "$PLUGINS_SRC/." "$VLC_DIR/plugins/"
- echo "Bundled $(find "$VLC_DIR/plugins" -name "*.so" | wc -l) plugin(s)"
- fi
+ dotnet publish "./LANCommander.Launcher.CLI/LANCommander.Launcher.CLI.csproj" \
+ -c "${{ inputs.build_configuration }}" \
+ --self-contained \
+ --runtime "${{ inputs.build_runtime }}" \
+ -p:Version="$SEMVER" \
+ -p:AssemblyVersion="$ASSEMBLY_VERSION" \
+ -p:FileVersion="$ASSEMBLY_VERSION" \
+ -p:InformationalVersion="$SEMVER"
- name: Bundle and Clean
shell: pwsh
run: |
- $BasePath = "LANCommander.Launcher/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish"
-
- # Remove unnecessary files
+ Copy-Item -Force -Recurse -Verbose LANCommander.AutoUpdater/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish/* LANCommander.Launcher/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish/
+ Copy-Item -Force -Recurse -Verbose LANCommander.Launcher.CLI/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish/* LANCommander.Launcher/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish/
+
+ # Remove unnecessary files in a single operation
$PathsToRemove = @(
- '*.pdb'
+ 'wwwroot/_content/BootstrapBlazor.PdfReader/compat',
+ 'wwwroot/_content/BootstrapBlazor.PdfReader/2.*',
+ 'wwwroot/_content/BootstrapBlazor.PdfReader/build/pdf.sandbox.js',
+ 'wwwroot/_content/BootstrapBlazor.PdfReader/build/*.map',
+ 'wwwroot/_content/BootstrapBlazor.PdfReader/web/*.map',
+ 'wwwroot/_content/AntDesign/less',
+ 'wwwroot/_content/BlazorMonaco/lib/monaco-editor/min-maps',
+ 'wwwroot/Identity/lib/bootstrap',
+ 'LANCommander.ico',
+ 'LANCommanderDark.ico',
+ 'package-lock.json',
+ 'package.json',
+ '*.pdb',
+ 'hostfxr.dll.bak',
+ 'Libraries/locales'
)
-
+
+ $BasePath = "LANCommander.Launcher/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish"
foreach ($path in $PathsToRemove) {
Remove-Item -Recurse -Force -ErrorAction Continue "$BasePath/$path"
}
- - name: Bundle macOS .app
- if: inputs.build_platform == 'macOS'
- shell: bash
- run: |
- set -euo pipefail
-
- # Strip leading 'v' and reduce to numeric x.y.z for the bundle version keys.
- RAW_VERSION="${{ inputs.version_tag }}"
- SEMVER="${RAW_VERSION#v}"
- NUMERIC="${SEMVER%%-*}"
-
- APP_NAME="LANCommander Launcher"
- EXECUTABLE="LANCommander.Launcher"
- BUNDLE_ID="app.lancommander.launcher"
- ICON_SRC="LANCommander.Launcher/Assets/icon.icns"
-
- PUBLISH_DIR="LANCommander.Launcher/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish"
- APP_DIR="${EXECUTABLE}.app"
-
- # Lay out the bundle skeleton.
- mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
- cp -a "$PUBLISH_DIR/." "$APP_DIR/Contents/MacOS/"
- cp "$ICON_SRC" "$APP_DIR/Contents/Resources/AppIcon.icns"
-
- # Write Info.plist.
- cat > "$APP_DIR/Contents/Info.plist" <
-
-
-
- CFBundleName
- ${APP_NAME}
- CFBundleDisplayName
- ${APP_NAME}
- CFBundleIdentifier
- ${BUNDLE_ID}
- CFBundleExecutable
- ${EXECUTABLE}
- CFBundleIconFile
- AppIcon
- CFBundlePackageType
- APPL
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleShortVersionString
- ${NUMERIC}
- CFBundleVersion
- ${SEMVER}
- LSMinimumSystemVersion
- 11.0
- NSHighResolutionCapable
-
-
-
- PLIST
-
- # Ensure the entrypoint is executable, then zip while preserving the
- # permission bits (Compress-Archive drops the exec bit).
- chmod +x "$APP_DIR/Contents/MacOS/${EXECUTABLE}"
- zip -ry "LANCommander.Launcher-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip" "$APP_DIR"
-
- - name: Build AppImage (Linux)
- if: inputs.build_platform == 'Linux'
- shell: bash
- run: |
- set -euo pipefail
-
- PUBLISH_DIR="LANCommander.Launcher/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish"
- APP_NAME="LANCommander.Launcher"
- APPDIR="AppDir"
-
- # appimagetool / runtime architecture identifiers
- if [ "${{ inputs.build_arch }}" = "arm64" ]; then
- AI_ARCH="aarch64"
- else
- AI_ARCH="x86_64"
- fi
- export ARCH="$AI_ARCH"
-
- # --- Assemble the AppDir ---------------------------------------------
- rm -rf "$APPDIR"
- mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/share/applications" \
- "$APPDIR/usr/share/icons/hicolor/scalable/apps"
-
- cp -r "$PUBLISH_DIR/." "$APPDIR/usr/bin/"
- chmod +x "$APPDIR/usr/bin/$APP_NAME"
-
- # Icon (use the prebuilt 256x256 project icon)
- ICON_SRC="LANCommander.Launcher/Assets/icon.png"
- ICON_DIR="$APPDIR/usr/share/icons/hicolor/256x256/apps"
- mkdir -p "$ICON_DIR"
- cp "$ICON_SRC" "$ICON_DIR/lancommander.png"
-
- cp "$ICON_DIR/lancommander.png" "$APPDIR/lancommander.png"
- ln -sf lancommander.png "$APPDIR/.DirIcon"
-
- # Desktop entry
- cat > "$APPDIR/usr/share/applications/lancommander.desktop" <<'EOF'
- [Desktop Entry]
- Type=Application
- Name=LANCommander Launcher
- Comment=LANCommander Launcher
- Exec=LANCommander.Launcher
- Icon=lancommander
- Categories=Game;
- Terminal=false
- EOF
- cp "$APPDIR/usr/share/applications/lancommander.desktop" "$APPDIR/lancommander.desktop"
-
- # AppRun entry point
- cat > "$APPDIR/AppRun" <<'EOF'
- #!/bin/bash
- HERE="$(dirname "$(readlink -f "${0}")")"
- export PATH="${HERE}/usr/bin:${PATH}"
- export LD_LIBRARY_PATH="${HERE}/usr/bin:${LD_LIBRARY_PATH:-}"
- exec "${HERE}/usr/bin/LANCommander.Launcher" "$@"
- EOF
- chmod +x "$APPDIR/AppRun"
-
- # --- Fetch appimagetool ----------------------------------------------
- TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${AI_ARCH}.AppImage"
- curl -L -o appimagetool "$TOOL_URL"
- chmod +x appimagetool
-
- # --- Build the AppImage ----------------------------------------------
- OUT="LANCommander.Launcher-Linux-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.AppImage"
- ./appimagetool --appimage-extract-and-run "$APPDIR" "$OUT"
-
- echo "Produced $OUT"
-
- - name: Upload AppImage Artifact (Linux)
- if: inputs.build_platform == 'Linux'
- uses: actions/upload-artifact@v4
- with:
- path: LANCommander.Launcher-Linux-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.AppImage
- name: LANCommander.Launcher-Linux-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.AppImage
-
- name: Compress Build Output
- if: inputs.build_platform != 'macOS'
shell: pwsh
run: |
$compress = @{
@@ -311,4 +174,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
path: LANCommander.Launcher-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip
- name: LANCommander.Launcher-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip
+ name: LANCommander.Launcher-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip
\ No newline at end of file
diff --git a/.github/workflows/LANCommander.Nightly.yml b/.github/workflows/LANCommander.Nightly.yml
index f3de67d9..8c318319 100644
--- a/.github/workflows/LANCommander.Nightly.yml
+++ b/.github/workflows/LANCommander.Nightly.yml
@@ -255,6 +255,84 @@ jobs:
build_platform: Windows
build_configuration: Debug
+ build_launcher_avalonia_linux_arm64:
+ needs: [prep]
+ if: needs.prep.outputs.changed == 'true' && github.repository_owner == 'LANCommander'
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-arm64
+ build_arch: arm64
+ build_platform: Linux
+ build_configuration: Debug
+
+ build_launcher_avalonia_linux_x64:
+ needs: [prep]
+ if: needs.prep.outputs.changed == 'true' && github.repository_owner == 'LANCommander'
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-x64
+ build_arch: x64
+ build_platform: Linux
+ build_configuration: Debug
+
+ build_launcher_avalonia_osx_arm64:
+ needs: [prep]
+ if: needs.prep.outputs.changed == 'true' && github.repository_owner == 'LANCommander'
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-arm64
+ build_arch: arm64
+ build_platform: macOS
+ build_configuration: Debug
+
+ build_launcher_avalonia_osx_x64:
+ needs: [prep]
+ if: needs.prep.outputs.changed == 'true' && github.repository_owner == 'LANCommander'
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-x64
+ build_arch: x64
+ build_platform: macOS
+ build_configuration: Debug
+
+ build_launcher_avalonia_win_arm64:
+ needs: [prep]
+ if: needs.prep.outputs.changed == 'true' && github.repository_owner == 'LANCommander'
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-arm64
+ build_arch: arm64
+ build_platform: Windows
+ build_configuration: Debug
+
+ build_launcher_avalonia_win_x64:
+ needs: [prep]
+ if: needs.prep.outputs.changed == 'true' && github.repository_owner == 'LANCommander'
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-x64
+ build_arch: x64
+ build_platform: Windows
+ build_configuration: Debug
+
# --------------------------------------------------------------------------
# 3) FINALIZE: if changes == 'true', gather artifacts + push Docker:nightly
# --------------------------------------------------------------------------
@@ -267,6 +345,12 @@ jobs:
- build_server_osx_x64
- build_server_win_arm64
- build_server_win_x64
+ - build_launcher_linux_arm64
+ - build_launcher_linux_x64
+ - build_launcher_osx_arm64
+ - build_launcher_osx_x64
+ - build_launcher_win_arm64
+ - build_launcher_win_x64
if: needs.prep.outputs.changed == 'true' && github.repository_owner == 'LANCommander'
runs-on: ubuntu-latest
steps:
@@ -359,16 +443,13 @@ jobs:
needs:
- prep
- publish_docker_image
- - build_launcher_linux_arm64
- - build_launcher_linux_x64
- - build_launcher_osx_arm64
- - build_launcher_osx_x64
- - build_launcher_win_arm64
- - build_launcher_win_x64
+ - build_launcher_avalonia_linux_arm64
+ - build_launcher_avalonia_linux_x64
+ - build_launcher_avalonia_osx_arm64
+ - build_launcher_avalonia_osx_x64
+ - build_launcher_avalonia_win_arm64
+ - build_launcher_avalonia_win_x64
steps:
- - name: Check out code
- uses: actions/checkout@v4
-
- name: Create Temp Directory
run: mkdir -p artifacts
@@ -444,39 +525,74 @@ jobs:
name: LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- - name: Download Launcher Linux ARM64 AppImage
+ - name: Download Launcher Avalonia Linux ARM64
uses: actions/download-artifact@v4
with:
- name: LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.AppImage
+ name: LANCommander.Launcher.Avalonia-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- - name: Download Launcher Linux x64 AppImage
+ - name: Download Launcher Avalonia Linux x64
uses: actions/download-artifact@v4
with:
- name: LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.AppImage
+ name: LANCommander.Launcher.Avalonia-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- - name: Create or update nightly release
+ - name: Download Launcher Avalonia macOS ARM64
+ uses: actions/download-artifact@v4
+ with:
+ name: LANCommander.Launcher.Avalonia-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ path: artifacts
+
+ - name: Download Launcher Avalonia macOS x64
+ uses: actions/download-artifact@v4
+ with:
+ name: LANCommander.Launcher.Avalonia-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ path: artifacts
+
+ - name: Download Launcher Avalonia Windows ARM64
+ uses: actions/download-artifact@v4
+ with:
+ name: LANCommander.Launcher.Avalonia-Windows-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ path: artifacts
+
+ - name: Download Launcher Avalonia Windows x64
+ uses: actions/download-artifact@v4
+ with:
+ name: LANCommander.Launcher.Avalonia-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ path: artifacts
+
+ - name: Delete existing release assets
+ uses: dev-drprasad/delete-tag-and-release@v1.1
+ with:
+ tag_name: nightly
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Create nightly release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: nightly
+ name: Nightly Build v${{ needs.prep.outputs.version_tag }}
+ draft: false
+ prerelease: true
+ generate_release_notes: true
+ body: This is the latest nightly build. These builds are generated automatically and should be considered unstable.
+ files: |
+ artifacts/LANCommander.Server-Windows-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Server-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Server-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Server-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Server-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Server-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-Windows-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-Windows-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- # Create the release if it doesn't exist, otherwise update its metadata
- if gh release view nightly > /dev/null 2>&1; then
- # Remove all existing assets so only this nightly's files remain
- gh release view nightly --json assets -q '.assets[].name' | while read -r asset; do
- gh release delete-asset nightly "$asset" -y
- done
-
- gh release edit nightly \
- --prerelease \
- --title "Nightly Build v${{ needs.prep.outputs.version_tag }}" \
- --notes "This is the latest nightly build. These builds are generated automatically and should be considered unstable."
- else
- gh release create nightly \
- --prerelease \
- --title "Nightly Build v${{ needs.prep.outputs.version_tag }}" \
- --notes "This is the latest nightly build. These builds are generated automatically and should be considered unstable."
- fi
-
- # Upload new artifacts
- gh release upload nightly artifacts/*
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/LANCommander.PR.yml b/.github/workflows/LANCommander.PR.yml
index 72c8157d..34523df2 100644
--- a/.github/workflows/LANCommander.PR.yml
+++ b/.github/workflows/LANCommander.PR.yml
@@ -1,229 +1,225 @@
-name: LANCommander Pull Request
-
-on:
- pull_request:
- types:
- - opened
- - synchronize
- - reopened
- - ready_for_review
- workflow_dispatch:
-
-permissions:
- contents: write
- packages: read
- checks: write
- pull-requests: write
-
-jobs:
- prep:
- runs-on: ubuntu-latest
- outputs:
- version_semver: ${{ steps.set_version.outputs.version_semver }}
- version_tag: ${{ steps.set_version.outputs.version_tag }}
- build_dotnet_version: 9.0.102
- steps:
- - name: Check out code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Determine build metadata
- id: set_version
- shell: bash
- env:
- PR_NUMBER: ${{ github.event.pull_request.number }}
- RUN_NUMBER: ${{ github.run_number }}
- run: |
- TIMESTAMP=$(date -u +"%Y%m%d%H%M")
- TIME_COMPONENT=$(date -u +"%H%M")
- TIME_COMPONENT=$((10#$TIME_COMPONENT))
-
- if [ -n "$PR_NUMBER" ]; then
- BUILD_COMPONENT=$((PR_NUMBER % 65535))
- if [ "$BUILD_COMPONENT" -eq 0 ]; then
- BUILD_COMPONENT=1
- fi
- VERSION_SEMVER="0.0.${BUILD_COMPONENT}.${TIME_COMPONENT}"
- VERSION_TAG="0.0.${BUILD_COMPONENT}-pr.${PR_NUMBER}.${TIMESTAMP}"
- else
- BUILD_COMPONENT=$((RUN_NUMBER % 65535))
- if [ "$BUILD_COMPONENT" -eq 0 ]; then
- BUILD_COMPONENT=1
- fi
- VERSION_SEMVER="0.0.${BUILD_COMPONENT}.${TIME_COMPONENT}"
- VERSION_TAG="0.0.${BUILD_COMPONENT}-ci.${RUN_NUMBER}.${TIMESTAMP}"
- fi
-
- echo "version_semver=$VERSION_SEMVER" >> $GITHUB_OUTPUT
- echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
-
- ui_tests:
- needs: [prep]
- runs-on: ubuntu-latest
- steps:
- - name: Check out code
- uses: actions/checkout@v4
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: ${{ needs.prep.outputs.build_dotnet_version }}
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
-
- - name: Install Node packages
- run: |
- npm install --prefix ./LANCommander.UI
- npm install --prefix ./LANCommander.Server
-
- # The Monaco editor's PowerShell completions are generated (gitignored) and
- # required by the frontend webpack build. The in-build MSBuild target uses
- # Windows-style paths, so generate explicitly here for the Linux runner.
- - name: Generate PowerShell Completions
- run: dotnet run --project ./LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj -- ./LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletions.g.ts
-
- - name: Restore dependencies
- run: dotnet restore LANCommander.Server.UI.Tests
-
- - name: Build test project
- run: dotnet build LANCommander.Server.UI.Tests --no-restore --configuration Release
-
- - name: Install Playwright browsers
- run: pwsh LANCommander.Server.UI.Tests/bin/Release/net10.0/playwright.ps1 install --with-deps chromium
-
- - name: Run UI tests
- run: dotnet test LANCommander.Server.UI.Tests --no-build --configuration Release --logger "trx;LogFileName=ui-test-results.trx" --results-directory ./TestResults
- env:
- SCREENSHOT_DIR: ${{ github.workspace }}/TestResults/Screenshots
-
- - name: Test report
- if: always()
- uses: dorny/test-reporter@v1
- with:
- name: UI Test Results
- path: ./TestResults/ui-test-results.trx
- reporter: dotnet-trx
-
- - name: Upload test results
- if: always()
- uses: actions/upload-artifact@v4
- with:
- name: ui-test-results
- path: ./TestResults
- retention-days: 7
-
- build_server_linux_arm64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Server.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: linux-arm64
- build_arch: arm64
- build_platform: Linux
- build_configuration: Debug
-
- build_server_linux_x64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Server.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: linux-x64
- build_arch: x64
- build_platform: Linux
- build_configuration: Debug
-
- build_server_osx_arm64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Server.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: osx-arm64
- build_arch: arm64
- build_platform: macOS
- build_configuration: Debug
-
- build_server_osx_x64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Server.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: osx-x64
- build_arch: x64
- build_platform: macOS
- build_configuration: Debug
-
- build_server_win_arm64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Server.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: win-arm64
- build_arch: arm64
- build_platform: Windows
- build_configuration: Debug
-
- build_server_win_x64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Server.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: win-x64
- build_arch: x64
- build_platform: Windows
- build_configuration: Debug
-
- build_launcher_linux_x64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Launcher.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: linux-x64
- build_arch: x64
- build_platform: Linux
- build_configuration: Debug
-
- build_launcher_win_x64:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Launcher.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
- version_semver: ${{ needs.prep.outputs.version_semver }}
- version_tag: ${{ needs.prep.outputs.version_tag }}
- build_runtime: win-x64
- build_arch: x64
- build_platform: Windows
- build_configuration: Debug
-
- launcher_tests:
- needs: [prep]
- uses: ./.github/workflows/LANCommander.Launcher.Tests.yml
- with:
- build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
-
- launcher_tests_pr_comment:
- # Always run after the test job — even on failure — so visual regressions
- # show up as a PR comment instead of being buried in the artifact.
- needs: [launcher_tests]
- if: always() && needs.launcher_tests.result != 'cancelled' && github.event.pull_request.number != null
- uses: ./.github/workflows/LANCommander.Launcher.Tests.PRComment.yml
- with:
- pr_number: ${{ github.event.pull_request.number }}
- artifact_run_id: ${{ github.run_id }}
+name: LANCommander Pull Request
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ packages: read
+
+jobs:
+ prep:
+ runs-on: ubuntu-latest
+ outputs:
+ version_semver: ${{ steps.set_version.outputs.version_semver }}
+ version_tag: ${{ steps.set_version.outputs.version_tag }}
+ build_dotnet_version: 9.0.102
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Determine build metadata
+ id: set_version
+ shell: bash
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ RUN_NUMBER: ${{ github.run_number }}
+ run: |
+ TIMESTAMP=$(date -u +"%Y%m%d%H%M")
+ TIME_COMPONENT=$(date -u +"%H%M")
+ TIME_COMPONENT=$((10#$TIME_COMPONENT))
+
+ if [ -n "$PR_NUMBER" ]; then
+ BUILD_COMPONENT=$((PR_NUMBER % 65535))
+ if [ "$BUILD_COMPONENT" -eq 0 ]; then
+ BUILD_COMPONENT=1
+ fi
+ VERSION_SEMVER="0.0.${BUILD_COMPONENT}.${TIME_COMPONENT}"
+ VERSION_TAG="0.0.${BUILD_COMPONENT}-pr.${PR_NUMBER}.${TIMESTAMP}"
+ else
+ BUILD_COMPONENT=$((RUN_NUMBER % 65535))
+ if [ "$BUILD_COMPONENT" -eq 0 ]; then
+ BUILD_COMPONENT=1
+ fi
+ VERSION_SEMVER="0.0.${BUILD_COMPONENT}.${TIME_COMPONENT}"
+ VERSION_TAG="0.0.${BUILD_COMPONENT}-ci.${RUN_NUMBER}.${TIMESTAMP}"
+ fi
+
+ echo "version_semver=$VERSION_SEMVER" >> $GITHUB_OUTPUT
+ echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
+
+ build_server_linux_arm64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Server.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-arm64
+ build_arch: arm64
+ build_platform: Linux
+ build_configuration: Debug
+
+ build_server_linux_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Server.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-x64
+ build_arch: x64
+ build_platform: Linux
+ build_configuration: Debug
+
+ build_server_osx_arm64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Server.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-arm64
+ build_arch: arm64
+ build_platform: macOS
+ build_configuration: Debug
+
+ build_server_osx_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Server.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-x64
+ build_arch: x64
+ build_platform: macOS
+ build_configuration: Debug
+
+ build_server_win_arm64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Server.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-arm64
+ build_arch: arm64
+ build_platform: Windows
+ build_configuration: Debug
+
+ build_server_win_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Server.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-x64
+ build_arch: x64
+ build_platform: Windows
+ build_configuration: Debug
+
+ build_launcher_linux_arm64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-arm64
+ build_arch: arm64
+ build_platform: Linux
+ build_configuration: Debug
+
+ build_launcher_linux_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-x64
+ build_arch: x64
+ build_platform: Linux
+ build_configuration: Debug
+
+ build_launcher_osx_arm64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-arm64
+ build_arch: arm64
+ build_platform: macOS
+ build_configuration: Debug
+
+ build_launcher_osx_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-x64
+ build_arch: x64
+ build_platform: macOS
+ build_configuration: Debug
+
+ build_launcher_win_arm64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-arm64
+ build_arch: arm64
+ build_platform: Windows
+ build_configuration: Debug
+
+ build_launcher_win_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-x64
+ build_arch: x64
+ build_platform: Windows
+ build_configuration: Debug
+
+ build_launcher_avalonia_linux_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-x64
+ build_arch: x64
+ build_platform: Linux
+ build_configuration: Debug
+
+ build_launcher_avalonia_win_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-x64
+ build_arch: x64
+ build_platform: Windows
+ build_configuration: Debug
diff --git a/.github/workflows/LANCommander.Packager.yml b/.github/workflows/LANCommander.Packager.yml
deleted file mode 100644
index 570eaab3..00000000
--- a/.github/workflows/LANCommander.Packager.yml
+++ /dev/null
@@ -1,100 +0,0 @@
-name: LANCommander Packager Build
-
-on:
- workflow_dispatch:
- workflow_call:
- inputs:
- version_semver:
- description: "Semantic Version"
- required: true
- type: string
- version_tag:
- description: 'Version Tag'
- required: true
- type: string
- build_dotnet_version:
- description: 'Build .NET Version'
- required: false
- type: string
- default: '10.0.x'
- build_configuration:
- description: 'Build Configuration (Debug/Release)'
- required: false
- type: string
- default: 'Release'
-
-permissions:
- contents: write
-
-env:
- NUGET_PACKAGES: ${{ github.workspace }}/.nuget/package
-
-jobs:
- build:
- runs-on: windows-latest
-
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- steps:
- - uses: actions/checkout@v4
- with:
- submodules: true
-
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: ${{ inputs.build_dotnet_version }}
-
- - name: Restore dependencies
- run: dotnet restore
-
- - name: Publish Packager
- shell: pwsh
- run: |
- # Strip leading 'v' if present
- $RawVersion = "${{ inputs.version_tag }}"
- $Semver = $RawVersion -replace '^v', ''
-
- # Numeric part only for Assembly/FileVersion
- $Numeric = ($Semver -split '-')[0]
- $AssemblyVersion = "$Numeric.0"
-
- Write-Host "SEMVER=$Semver"
- Write-Host "ASSEMBLY_VERSION=$AssemblyVersion"
-
- dotnet publish "./LANCommander.Packager/LANCommander.Packager.csproj" `
- -c "${{ inputs.build_configuration }}" `
- --self-contained `
- --runtime win-x86 `
- -p:Version="$Semver" `
- -p:AssemblyVersion="$AssemblyVersion" `
- -p:FileVersion="$AssemblyVersion" `
- -p:InformationalVersion="$Semver" `
- -p:PublishSingleFile=true `
- -p:IncludeNativeLibrariesForSelfExtract=true `
- -p:IncludeAllContentForSelfExtract=true `
- -p:EnableCompressionInSingleFile=true `
- -p:DebugType=embedded
-
- - name: Clean
- shell: pwsh
- run: |
- $BasePath = "LANCommander.Packager/bin/${{ inputs.build_configuration }}/net10.0/win-x86/publish"
- Remove-Item -Recurse -Force -ErrorAction Continue "$BasePath/*.pdb"
-
- - name: Compress Build Output
- shell: pwsh
- run: |
- $compress = @{
- Path = "LANCommander.Packager/bin/${{ inputs.build_configuration }}/net10.0/win-x86/publish/*"
- DestinationPath = "LANCommander.Packager-Windows-x86-v${{ inputs.version_tag }}.zip"
- CompressionLevel = "Fastest"
- }
- Compress-Archive @compress
-
- - name: Upload Artifact
- uses: actions/upload-artifact@v4
- with:
- path: LANCommander.Packager-Windows-x86-v${{ inputs.version_tag }}.zip
- name: LANCommander.Packager-Windows-x86-v${{ inputs.version_tag }}.zip
diff --git a/.github/workflows/LANCommander.Release.yml b/.github/workflows/LANCommander.Release.yml
index 8b8d3ba2..6f878a60 100644
--- a/.github/workflows/LANCommander.Release.yml
+++ b/.github/workflows/LANCommander.Release.yml
@@ -27,7 +27,6 @@ jobs:
outputs:
version_tag: ${{ steps.trim_tag_ref.outputs.replaced }}
version_semver: ${{ steps.trim_tag_ref.outputs.replaced }}
- is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
build_dotnet_version: 9.0.102
steps:
- name: Check out code
@@ -44,16 +43,6 @@ jobs:
pattern: 'refs/tags/v'
replace-with: ''
- - name: Check if pre-release
- id: check_prerelease
- run: |
- VERSION="${{ steps.trim_tag_ref.outputs.replaced }}"
- if [[ "$VERSION" == *-* ]]; then
- echo "is_prerelease=true" >> $GITHUB_OUTPUT
- else
- echo "is_prerelease=false" >> $GITHUB_OUTPUT
- fi
-
# Server
build_server_linux_arm64:
needs: [prep]
@@ -200,14 +189,53 @@ jobs:
build_platform: Windows
build_configuration: Release
- # Packager (Windows x86 only)
- build_packager:
+ # Avalonia Launcher
+ build_launcher_avalonia_linux_x64:
needs: [prep]
- uses: ./.github/workflows/LANCommander.Packager.yml
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
with:
- build_dotnet_version: '10.0.x'
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
version_semver: ${{ needs.prep.outputs.version_semver }}
version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: linux-x64
+ build_arch: x64
+ build_platform: Linux
+ build_configuration: Release
+
+ build_launcher_avalonia_osx_arm64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-arm64
+ build_arch: arm64
+ build_platform: macOS
+ build_configuration: Release
+
+ build_launcher_avalonia_osx_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: osx-x64
+ build_arch: x64
+ build_platform: macOS
+ build_configuration: Release
+
+ build_launcher_avalonia_win_x64:
+ needs: [prep]
+ uses: ./.github/workflows/LANCommander.Launcher.Avalonia.yml
+ with:
+ build_dotnet_version: ${{ needs.prep.outputs.build_dotnet_version }}
+ version_semver: ${{ needs.prep.outputs.version_semver }}
+ version_tag: ${{ needs.prep.outputs.version_tag }}
+ build_runtime: win-x64
+ build_arch: x64
+ build_platform: Windows
build_configuration: Release
build_release:
@@ -226,7 +254,10 @@ jobs:
- build_launcher_osx_x64
- build_launcher_win_arm64
- build_launcher_win_x64
- - build_packager
+ - build_launcher_avalonia_linux_x64
+ - build_launcher_avalonia_osx_arm64
+ - build_launcher_avalonia_osx_x64
+ - build_launcher_avalonia_win_x64
steps:
- name: Create Temp Directory
@@ -268,7 +299,6 @@ jobs:
name: LANCommander.Server-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- # Launcher artifacts
- name: Download Launcher Linux ARM64
uses: actions/download-artifact@v4
with:
@@ -305,22 +335,29 @@ jobs:
name: LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- - name: Download Launcher Linux ARM64 AppImage
+ # Avalonia Launcher artifacts
+ - name: Download Avalonia Launcher Linux x64
uses: actions/download-artifact@v4
with:
- name: LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.AppImage
+ name: LANCommander.Launcher.Avalonia-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- - name: Download Launcher Linux x64 AppImage
+ - name: Download Avalonia Launcher macOS ARM64
uses: actions/download-artifact@v4
with:
- name: LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.AppImage
+ name: LANCommander.Launcher.Avalonia-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- - name: Download Packager Windows x86
+ - name: Download Avalonia Launcher macOS x64
uses: actions/download-artifact@v4
with:
- name: LANCommander.Packager-Windows-x86-v${{ needs.prep.outputs.version_tag }}.zip
+ name: LANCommander.Launcher.Avalonia-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ path: artifacts
+
+ - name: Download Avalonia Launcher Windows x64
+ uses: actions/download-artifact@v4
+ with:
+ name: LANCommander.Launcher.Avalonia-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
path: artifacts
- name: Debug - List Artifact Files
@@ -334,7 +371,6 @@ jobs:
name: v${{ needs.prep.outputs.version_tag }}
generate_release_notes: true
draft: true
- prerelease: ${{ needs.prep.outputs.is_prerelease == 'true' }}
files: |
artifacts/LANCommander.Server-Windows-arm64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Server-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
@@ -342,15 +378,16 @@ jobs:
artifacts/LANCommander.Server-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Server-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Server-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
- artifacts/LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.zip
- artifacts/LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
- artifacts/LANCommander.Launcher-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
- artifacts/LANCommander.Launcher-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Launcher-Windows-arm64-v${{ needs.prep.outputs.version_tag }}.zip
artifacts/LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
- artifacts/LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.AppImage
- artifacts/LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.AppImage
- artifacts/LANCommander.Packager-Windows-x86-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-Linux-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-macOS-arm64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-macOS-x64-v${{ needs.prep.outputs.version_tag }}.zip
+ artifacts/LANCommander.Launcher.Avalonia-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip
- name: Checkout Repo for Docker build
uses: actions/checkout@v4
@@ -420,9 +457,10 @@ jobs:
file: ./LANCommander.Server/Dockerfile
push: true
platforms: linux/amd64
+ # ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}:latest
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}:v${{ needs.prep.outputs.version_tag }}
- ${{ needs.prep.outputs.is_prerelease == 'true' && format('{0}/{1}:prerelease', env.REGISTRY, env.IMAGE_NAME) || format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) }}
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}:latest
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
diff --git a/.github/workflows/LANCommander.SDK.Release.yml b/.github/workflows/LANCommander.SDK.Release.yml
index 900a402b..41ce3dc5 100644
--- a/.github/workflows/LANCommander.SDK.Release.yml
+++ b/.github/workflows/LANCommander.SDK.Release.yml
@@ -1,8 +1,9 @@
name: LANCommander SDK Release
on:
- release:
- types: [published]
+ push:
+ tags:
+ - 'v[0-9]+.[0-9]+.[0-9]+'
permissions:
contents: write
@@ -12,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version_tag: ${{ steps.trim_tag_ref.outputs.replaced }}
- version_semver: ${{ steps.extract_semver.outputs.replaced }}
+ version_semver: ${{ steps.trim_tag_ref.outputs.replaced }}
steps:
- uses: frabert/replace-string-action@v2
name: Trim Tag Ref
@@ -22,14 +23,6 @@ jobs:
pattern: 'refs/tags/v'
replace-with: ''
- - uses: frabert/replace-string-action@v2
- name: Extract SemVer (strip prerelease suffix for AssemblyVersion)
- id: extract_semver
- with:
- string: '${{ steps.trim_tag_ref.outputs.replaced }}'
- pattern: '-.*$'
- replace-with: ''
-
publish:
needs: prep
runs-on: ubuntu-latest
diff --git a/.github/workflows/LANCommander.SDK.Tests.yml b/.github/workflows/LANCommander.SDK.Tests.yml
new file mode 100644
index 00000000..0d047a40
--- /dev/null
+++ b/.github/workflows/LANCommander.SDK.Tests.yml
@@ -0,0 +1,53 @@
+name: LANCommander SDK Tests
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'LANCommander.SDK/**'
+ - 'LANCommander.SDK.Tests/**'
+ - 'LANCommander.Steam/**'
+ pull_request:
+ paths:
+ - 'LANCommander.SDK/**'
+ - 'LANCommander.SDK.Tests/**'
+ - 'LANCommander.Steam/**'
+ workflow_dispatch:
+
+jobs:
+ test:
+ name: Run SDK Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
+
+ - name: Run tests
+ run: >
+ dotnet test
+ --no-build
+ --configuration Release
+ --verbosity normal
+ --logger "trx;LogFileName=sdk-tests.trx"
+ LANCommander.SDK.Tests/LANCommander.SDK.Tests.csproj
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: sdk-test-results
+ path: '**/*.trx'
+ retention-days: 30
diff --git a/.github/workflows/LANCommander.Server.yml b/.github/workflows/LANCommander.Server.yml
index 19755307..ee89b1ed 100644
--- a/.github/workflows/LANCommander.Server.yml
+++ b/.github/workflows/LANCommander.Server.yml
@@ -45,7 +45,7 @@ env:
jobs:
build:
- runs-on: ${{ inputs.build_platform == 'Linux' && inputs.build_arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
+ runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -85,9 +85,6 @@ jobs:
npm install --prefix ./LANCommander.UI
npm install --prefix ./LANCommander.Server
- - name: Generate PowerShell Completions
- run: dotnet run --project ./LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj -- ./LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletions.g.ts
-
- name: Package Frontend
run: |
npm run package --prefix ./LANCommander.UI
@@ -116,12 +113,6 @@ jobs:
-p:FileVersion="$ASSEMBLY_VERSION" \
-p:InformationalVersion="$SEMVER"
- # Disable NetBeauty on ARM64 runners — nbeauty2 only ships x64 native binaries
- DISABLE_BEAUTY="False"
- if [ "${{ inputs.build_arch }}" = "arm64" ] && [ "${{ inputs.build_platform }}" = "Linux" ]; then
- DISABLE_BEAUTY="True"
- fi
-
dotnet publish "./LANCommander.Server/LANCommander.Server.csproj" \
-c "${{ inputs.build_configuration }}" \
--self-contained \
@@ -129,8 +120,7 @@ jobs:
-p:Version="$SEMVER" \
-p:AssemblyVersion="$ASSEMBLY_VERSION" \
-p:FileVersion="$ASSEMBLY_VERSION" \
- -p:InformationalVersion="$SEMVER" \
- -p:DisableBeauty="$DISABLE_BEAUTY"
+ -p:InformationalVersion="$SEMVER"
- name: Bundle and Clean
@@ -162,69 +152,7 @@ jobs:
Remove-Item -Recurse -Force -ErrorAction Continue "$BasePath/$path"
}
- - name: Bundle macOS .app
- if: inputs.build_platform == 'macOS'
- shell: bash
- run: |
- set -euo pipefail
-
- # Strip leading 'v' and reduce to numeric x.y.z for the bundle version keys.
- RAW_VERSION="${{ inputs.version_tag }}"
- SEMVER="${RAW_VERSION#v}"
- NUMERIC="${SEMVER%%-*}"
-
- APP_NAME="LANCommander Server"
- EXECUTABLE="LANCommander.Server"
- BUNDLE_ID="app.lancommander.server"
- ICON_SRC="LANCommander.Server/icon.icns"
-
- PUBLISH_DIR="LANCommander.Server/bin/${{ inputs.build_configuration }}/net10.0/${{ inputs.build_runtime }}/publish"
- APP_DIR="${EXECUTABLE}.app"
-
- # Lay out the bundle skeleton.
- mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
- cp -a "$PUBLISH_DIR/." "$APP_DIR/Contents/MacOS/"
- cp "$ICON_SRC" "$APP_DIR/Contents/Resources/AppIcon.icns"
-
- # Write Info.plist.
- cat > "$APP_DIR/Contents/Info.plist" <
-
-
-
- CFBundleName
- ${APP_NAME}
- CFBundleDisplayName
- ${APP_NAME}
- CFBundleIdentifier
- ${BUNDLE_ID}
- CFBundleExecutable
- ${EXECUTABLE}
- CFBundleIconFile
- AppIcon
- CFBundlePackageType
- APPL
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleShortVersionString
- ${NUMERIC}
- CFBundleVersion
- ${SEMVER}
- LSMinimumSystemVersion
- 11.0
- NSHighResolutionCapable
-
-
-
- PLIST
-
- # Ensure the entrypoint is executable, then zip while preserving the
- # permission bits (Compress-Archive drops the exec bit).
- chmod +x "$APP_DIR/Contents/MacOS/${EXECUTABLE}"
- zip -ry "LANCommander.Server-${{ inputs.build_platform }}-${{ inputs.build_arch }}-v${{ inputs.version_tag }}.zip" "$APP_DIR"
-
- name: Compress Build Output
- if: inputs.build_platform != 'macOS'
shell: pwsh
run: |
$compress = @{
diff --git a/.github/workflows/LANCommander.WinGet.Release.yml b/.github/workflows/LANCommander.WinGet.Release.yml
index 2e5fceec..e84c2542 100644
--- a/.github/workflows/LANCommander.WinGet.Release.yml
+++ b/.github/workflows/LANCommander.WinGet.Release.yml
@@ -12,7 +12,7 @@ jobs:
arch: ['x64', 'arm64']
steps:
- uses: actions/checkout@v4
-
+
- name: Get version
id: get_version
shell: pwsh
@@ -33,19 +33,13 @@ jobs:
# Install Inno Setup
- name: Install Inno Setup
run: |
- curl -L -o innosetup.exe https://github.com/jrsoftware/issrc/releases/download/is-6_7_3/innosetup-6.7.3.exe
+ curl -L -o innosetup.exe https://files.jrsoftware.org/is/6/innosetup-6.2.2.exe
.\innosetup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
shell: cmd
# Create Inno Setup script
- name: Create installer script
run: |
- $appId = if ('${{ matrix.app }}' -eq 'Server') {
- '2C58E237-1D69-42A0-B702-F995B75B8A5E'
- } else {
- 'A3D4F8E1-7B2C-4E5D-9F1A-6C8B3D7E2F4A'
- }
-
@"
#define MyAppName "LANCommander ${{ matrix.app }}"
#define MyAppVersion "${{ env.VERSION }}"
@@ -55,7 +49,7 @@ jobs:
#define Architecture "${{ matrix.arch }}"
[Setup]
- AppId={{$appId}
+ AppId={{$(New-Guid)}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
@@ -100,6 +94,7 @@ jobs:
strategy:
matrix:
app: ['Server', 'Launcher']
+ arch: ['x64', 'arm64']
steps:
- name: Get version
shell: pwsh
@@ -108,14 +103,8 @@ jobs:
$version = $tag.TrimStart('v')
echo "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append
- - name: Install wingetcreate
- shell: pwsh
- run: |
- Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe
-
- name: Submit package to Windows Package Manager Community Repository
run: |
- $x64Url = "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/LANCommander.${{ matrix.app }}-${env:VERSION}-x64-Setup.exe"
- $arm64Url = "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/LANCommander.${{ matrix.app }}-${env:VERSION}-arm64-Setup.exe"
- .\wingetcreate.exe update --submit --token "${{ secrets.WINGET_TOKEN }}" --urls $x64Url $arm64Url --version ${env:VERSION} LANCommander.${{ matrix.app }}
- shell: pwsh
+ $installerUrl = "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/LANCommander.${{ matrix.app }}-${env:VERSION}-${{ matrix.arch }}-Setup.exe"
+ wingetcreate submit --token ${{ secrets.GITHUB_TOKEN }} --urls "$installerUrl" --version ${env:VERSION} LANCommander.LANCommander.${{ matrix.app }}.${{ matrix.arch }}
+ shell: pwsh
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 8394ce72..7324d9e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -370,7 +370,6 @@ LANCommander.Server/wwwroot/css/main.js
.aspire
LANCommander.Server/config/
LANCommander.UI/wwwroot/
-LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletions.g.ts
LANCommander.Server/Data/
LANCommander.Launcher.Avalonia/Data/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index e5641b44..00000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,94 +0,0 @@
-# Contributing to LANCommander
-
-Thanks for your interest in contributing to LANCommander! This project is primarily developed by a single developer, so community contributions are greatly appreciated.
-
-## Getting Started
-
-### Prerequisites
-
-- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
-- [Node.js](https://nodejs.org/) (for the server UI's TypeScript/SCSS assets)
-- A code editor such as [Visual Studio](https://visualstudio.microsoft.com/), [Rider](https://www.jetbrains.com/rider/), or [VS Code](https://code.visualstudio.com/)
-
-### Building the Project
-
-1. Clone the repository:
- ```bash
- git clone https://github.com/LANCommander/LANCommander.git
- cd LANCommander
- ```
-
-2. Restore dependencies:
- ```bash
- dotnet restore
- ```
-
-3. Build the server:
- ```bash
- dotnet build LANCommander.Server
- ```
-
-4. Build the launcher:
- ```bash
- dotnet build LANCommander.Launcher
- ```
-
-### Running Locally
-
-To run the server in development mode:
-```bash
-dotnet run --project LANCommander.Server
-```
-
-The server will be available at `http://localhost:1337` by default.
-
-## How to Contribute
-
-### Reporting Bugs
-
-Use the [GitHub Issues](https://github.com/LANCommander/LANCommander/issues) page with the bug report template. Include:
-- Steps to reproduce the issue
-- Expected vs. actual behavior
-- Your OS and LANCommander version
-- Relevant logs or screenshots
-
-### Submitting Changes
-
-1. Fork the repository
-2. Create a feature branch from `main` (`git checkout -b my-feature`)
-3. Make your changes
-4. Test your changes locally
-5. Commit with a clear, descriptive message
-6. Push to your fork and open a Pull Request
-
-### What to Work On
-
-- Check [open issues](https://github.com/LANCommander/LANCommander/issues) for bugs or feature requests
-- Documentation improvements are always welcome at our [documentation site](https://docs.lancommander.app/)
-- Game packaging scripts and guides for the community
-
-### Code Guidelines
-
-- Follow existing code style and conventions in the project
-- Keep PRs focused, one feature or fix per PR when possible
-- Include screenshots in your PR if you're changing UI
-
-## Project Structure
-
-| Directory | Description |
-|-----------|-------------|
-| `LANCommander.Server` | ASP.NET Blazor web application (server/admin) |
-| `LANCommander.Launcher` | Avalonia desktop client (launcher) |
-| `LANCommander.Packager` | Game packaging tool |
-| `LANCommander.SDK` | .NET SDK for building custom clients |
-| `LANCommander.Server.Data` | Entity Framework data models and migrations |
-| `LANCommander.Server.Services` | Server business logic |
-| `LANCommander.Documentation` | Docusaurus documentation site |
-
-## Community
-
-- [Discord](https://discord.gg/vDEEWVt8EM): Best place for discussion, help, and sharing game packages
-
-## License
-
-By contributing to LANCommander, you agree that your contributions will be licensed under the [MIT License](LICENSE).
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 085b603b..2ee9f171 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,15 +5,11 @@
-
-
-
-
-
+
@@ -37,8 +33,8 @@
+
-
@@ -69,9 +65,7 @@
-
-
@@ -87,19 +81,17 @@
-
-
-
+
@@ -124,7 +116,7 @@
-
+
@@ -147,15 +139,6 @@
-
-
-
-
-
-
-
-
-
@@ -182,10 +165,4 @@
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj b/LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj
deleted file mode 100644
index ca094c8a..00000000
--- a/LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Exe
- net10.0
- enable
- enable
-
-
-
-
-
-
-
diff --git a/LANCommander.CompletionGenerator/Program.cs b/LANCommander.CompletionGenerator/Program.cs
deleted file mode 100644
index 6427b261..00000000
--- a/LANCommander.CompletionGenerator/Program.cs
+++ /dev/null
@@ -1,512 +0,0 @@
-using System.Management.Automation;
-using System.Reflection;
-using System.Text;
-using System.Text.Json;
-using LANCommander.SDK.PowerShell.Extensions;
-
-if (args.Length == 0)
-{
- Console.Error.WriteLine("Usage: LANCommander.CompletionGenerator ");
- return 1;
-}
-
-var outputPath = args[0];
-
-var assembly = typeof(InitialSessionStateExtensions).Assembly;
-
-var cmdletTypes = assembly.GetTypes()
- .Where(t => t.GetCustomAttribute() != null)
- .OrderBy(t => t.GetCustomAttribute()!.VerbName + "-" + t.GetCustomAttribute()!.NounName);
-
-var sb = new StringBuilder();
-sb.AppendLine("// Auto-generated by LANCommander.CompletionGenerator — do not edit manually");
-sb.AppendLine();
-sb.AppendLine("export interface CmdletParameter {");
-sb.AppendLine(" name: string;");
-sb.AppendLine(" type: string;");
-sb.AppendLine(" mandatory: boolean;");
-sb.AppendLine(" position: number | null;");
-sb.AppendLine(" helpMessage: string | null;");
-sb.AppendLine(" aliases: string[];");
-sb.AppendLine("}");
-sb.AppendLine();
-sb.AppendLine("export interface CmdletDefinition {");
-sb.AppendLine(" name: string;");
-sb.AppendLine(" description: string | null;");
-sb.AppendLine(" outputType: string | null;");
-sb.AppendLine(" parameters: CmdletParameter[];");
-sb.AppendLine("}");
-sb.AppendLine();
-sb.AppendLine("export const cmdlets: CmdletDefinition[] = [");
-
-foreach (var type in cmdletTypes)
-{
- var cmdletAttr = type.GetCustomAttribute()!;
- var name = $"{cmdletAttr.VerbName}-{cmdletAttr.NounName}";
-
- var outputTypeAttr = type.GetCustomAttribute();
- string? outputType = null;
- if (outputTypeAttr?.Type?.Length > 0)
- outputType = GetFriendlyTypeName(outputTypeAttr.Type[0].Type);
-
- // Use XML doc summary if available via the Summary property pattern, otherwise use null
- var descriptionLines = type.GetCustomAttributes()
- .Where(a => a.GetType().Name == "DescriptionAttribute")
- .Select(a => a.GetType().GetProperty("Description")?.GetValue(a)?.ToString())
- .FirstOrDefault();
-
- // Fall back to XML summary comment — not available via reflection, use class summary convention
- string? description = descriptionLines;
-
- sb.AppendLine(" {");
- sb.AppendLine($" name: {JsonEncode(name)},");
- sb.AppendLine($" description: {JsonEncode(description)},");
- sb.AppendLine($" outputType: {JsonEncode(outputType)},");
- sb.AppendLine(" parameters: [");
-
- var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .Where(p => p.GetCustomAttribute() != null)
- .OrderBy(p =>
- {
- var pa = p.GetCustomAttribute()!;
- return pa.Position == int.MinValue ? int.MaxValue : pa.Position;
- })
- .ThenBy(p => p.Name);
-
- foreach (var prop in properties)
- {
- var paramAttr = prop.GetCustomAttribute()!;
- var aliasAttr = prop.GetCustomAttribute();
- var aliases = aliasAttr?.AliasNames?.ToArray() ?? Array.Empty();
- var position = paramAttr.Position == int.MinValue ? (int?)null : paramAttr.Position;
-
- sb.AppendLine(" {");
- sb.AppendLine($" name: {JsonEncode(prop.Name)},");
- sb.AppendLine($" type: {JsonEncode(GetFriendlyTypeName(prop.PropertyType))},");
- sb.AppendLine($" mandatory: {(paramAttr.Mandatory ? "true" : "false")},");
- sb.AppendLine($" position: {(position.HasValue ? position.Value.ToString() : "null")},");
- sb.AppendLine($" helpMessage: {JsonEncode(paramAttr.HelpMessage)},");
- sb.AppendLine($" aliases: [{string.Join(", ", aliases.Select(a => JsonEncode(a)))}],");
- sb.AppendLine(" },");
- }
-
- sb.AppendLine(" ],");
- sb.AppendLine(" },");
-}
-
-sb.AppendLine("];");
-sb.AppendLine();
-
-// Generate built-in PowerShell cmdlet completions from core modules
-var builtinCmdletNames = new HashSet(cmdletTypes.Select(t =>
-{
- var a = t.GetCustomAttribute()!;
- return $"{a.VerbName}-{a.NounName}";
-}));
-
-Console.WriteLine("Enumerating built-in PowerShell cmdlets...");
-
-sb.AppendLine("export const builtinCmdlets: CmdletDefinition[] = [");
-
-var builtinCount = 0;
-
-try
-{
- // Use external pwsh process for full module metadata and help access
- var psScript = @"
-$commonParams = @(
- 'Verbose','Debug','ErrorAction','WarningAction','InformationAction',
- 'ErrorVariable','WarningVariable','InformationVariable','OutVariable',
- 'OutBuffer','PipelineVariable','ProgressAction','Confirm','WhatIf'
-)
-
-$modules = @(
- 'Microsoft.PowerShell.Management',
- 'Microsoft.PowerShell.Utility',
- 'Microsoft.PowerShell.Security',
- 'Microsoft.PowerShell.Archive'
-)
-
-$results = @()
-
-# Get command names from core modules, then resolve each individually for full metadata
-$commandNames = Get-Command -CommandType Cmdlet,Function -Module $modules -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name -Unique
-
-foreach ($cmdName in $commandNames) {
- $cmd = Get-Command $cmdName -ErrorAction SilentlyContinue
- if (-not $cmd) { continue }
-
- $synopsis = $null
- try {
- $h = Get-Help $cmdName -ErrorAction SilentlyContinue
- if ($h -and $h.Synopsis) {
- $s = $h.Synopsis.Trim()
- # Skip if synopsis is just the cmdlet name, or looks like syntax (contains parameter notation)
- if ($s -ne $cmdName -and $s -notmatch '\[[-<]' -and $s -notmatch '-\w+\s+<') {
- $synopsis = $s
- }
- }
- } catch {}
-
- $outputType = $null
- if ($cmd.OutputType -and $cmd.OutputType.Count -gt 0 -and $cmd.OutputType[0].Type) {
- $outputType = $cmd.OutputType[0].Type.Name
- }
-
- $params = @()
- if ($cmd.Parameters) {
- foreach ($key in $cmd.Parameters.Keys) {
- if ($key -in $commonParams) { continue }
-
- $p = $cmd.Parameters[$key]
- $pAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } | Select-Object -First 1
- $mandatory = $false
- $position = $null
- $helpMsg = $null
-
- if ($pAttr) {
- $mandatory = [bool]$pAttr.Mandatory
- if ($pAttr.Position -ne [int]::MinValue) {
- $position = $pAttr.Position
- }
- if ($pAttr.HelpMessage) {
- $helpMsg = $pAttr.HelpMessage
- }
- }
-
- $aliases = @($p.Aliases)
-
- $params += @{
- n = $key
- t = $p.ParameterType.Name
- m = $mandatory
- pos = $position
- h = $helpMsg
- a = $aliases
- }
- }
- }
-
- $results += @{
- name = $cmdName
- desc = $synopsis
- out = $outputType
- params = $params
- }
-}
-
-$results | ConvertTo-Json -Depth 4 -Compress
-";
-
- // Write the script to a temp file to avoid stdin encoding issues
- var tempScript = Path.GetTempFileName() + ".ps1";
- File.WriteAllText(tempScript, psScript, new UTF8Encoding(false));
-
- var psi = new System.Diagnostics.ProcessStartInfo
- {
- FileName = "pwsh",
- Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{tempScript}\"",
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- };
-
- // Clear .NET SDK environment variables that can interfere with pwsh's module loading
- foreach (var key in Environment.GetEnvironmentVariables().Keys.Cast()
- .Where(k => k.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase) ||
- k.StartsWith("MSBuild", StringComparison.OrdinalIgnoreCase)))
- {
- psi.Environment[key] = null;
- }
-
- using var process = System.Diagnostics.Process.Start(psi)!;
-
- var json = process.StandardOutput.ReadToEnd();
- var stderr = process.StandardError.ReadToEnd();
- process.WaitForExit(120_000);
-
-
- if (!string.IsNullOrWhiteSpace(stderr))
- Console.Error.WriteLine($" pwsh stderr: {stderr.Trim()}");
-
- try { File.Delete(tempScript); } catch { }
-
- if (process.ExitCode != 0)
- throw new Exception($"pwsh exited with code {process.ExitCode}");
-
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
-
- // Handle both array and single-object responses
- var elements = root.ValueKind == JsonValueKind.Array
- ? root.EnumerateArray().ToList()
- : new List { root };
-
- foreach (var cmd in elements.OrderBy(e => e.GetProperty("name").GetString()))
- {
- var name = cmd.GetProperty("name").GetString()!;
- if (builtinCmdletNames.Contains(name))
- continue;
-
- string? description = null;
- if (cmd.TryGetProperty("desc", out var descEl) && descEl.ValueKind == JsonValueKind.String)
- description = descEl.GetString();
-
- string? outputTypeName = null;
- if (cmd.TryGetProperty("out", out var outEl) && outEl.ValueKind == JsonValueKind.String)
- outputTypeName = MapTypeName(outEl.GetString()!);
-
- sb.AppendLine(" {");
- sb.AppendLine($" name: {JsonEncode(name)},");
- sb.AppendLine($" description: {JsonEncode(description)},");
- sb.AppendLine($" outputType: {JsonEncode(outputTypeName)},");
- sb.AppendLine(" parameters: [");
-
- if (cmd.TryGetProperty("params", out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Array)
- {
- // Sort: positional first, then alphabetical
- var paramList = paramsEl.EnumerateArray().ToList();
- paramList.Sort((a, b) =>
- {
- var posA = a.TryGetProperty("pos", out var pa) && pa.ValueKind == JsonValueKind.Number ? pa.GetInt32() : int.MaxValue;
- var posB = b.TryGetProperty("pos", out var pb) && pb.ValueKind == JsonValueKind.Number ? pb.GetInt32() : int.MaxValue;
- var cmp = posA.CompareTo(posB);
- if (cmp != 0) return cmp;
- return string.Compare(
- a.GetProperty("n").GetString(),
- b.GetProperty("n").GetString(),
- StringComparison.Ordinal);
- });
-
- foreach (var param in paramList)
- {
- var paramName = param.GetProperty("n").GetString()!;
- var typeName = MapTypeName(param.GetProperty("t").GetString() ?? "object");
- var mandatory = param.TryGetProperty("m", out var mEl) && mEl.ValueKind == JsonValueKind.True;
-
- string? posStr = "null";
- if (param.TryGetProperty("pos", out var posEl) && posEl.ValueKind == JsonValueKind.Number)
- posStr = posEl.GetInt32().ToString();
-
- string? helpMessage = null;
- if (param.TryGetProperty("h", out var hEl) && hEl.ValueKind == JsonValueKind.String)
- helpMessage = hEl.GetString();
-
- var aliases = new List();
- if (param.TryGetProperty("a", out var aEl) && aEl.ValueKind == JsonValueKind.Array)
- {
- foreach (var alias in aEl.EnumerateArray())
- {
- if (alias.ValueKind == JsonValueKind.String)
- aliases.Add(alias.GetString()!);
- }
- }
-
- sb.AppendLine(" {");
- sb.AppendLine($" name: {JsonEncode(paramName)},");
- sb.AppendLine($" type: {JsonEncode(typeName)},");
- sb.AppendLine($" mandatory: {(mandatory ? "true" : "false")},");
- sb.AppendLine($" position: {posStr},");
- sb.AppendLine($" helpMessage: {JsonEncode(helpMessage)},");
- sb.AppendLine($" aliases: [{string.Join(", ", aliases.Select(a => JsonEncode(a)))}],");
- sb.AppendLine(" },");
- }
- }
-
- sb.AppendLine(" ],");
- sb.AppendLine(" },");
- builtinCount++;
- }
-}
-catch (Exception ex)
-{
- Console.Error.WriteLine($"Warning: Failed to enumerate built-in cmdlets: {ex.Message}");
- Console.Error.WriteLine("Ensure 'pwsh' (PowerShell 7+) is installed and on PATH.");
-}
-
-sb.AppendLine("];");
-sb.AppendLine();
-
-// Generate type definitions for complex objects used as script variables
-var variableTypes = new Dictionary
-{
- ["GameManifest"] = typeof(LANCommander.SDK.Models.Manifest.Game),
- ["ToolManifest"] = typeof(LANCommander.SDK.Models.Manifest.Tool),
- ["RedistributableManifest"] = typeof(LANCommander.SDK.Models.Manifest.Redistributable),
- ["Server"] = typeof(LANCommander.SDK.Models.Server),
- ["Game"] = typeof(LANCommander.SDK.Models.Game),
- ["User"] = typeof(LANCommander.SDK.Models.User),
- ["Tool"] = typeof(LANCommander.SDK.Models.Tool),
- ["Redistributable"] = typeof(LANCommander.SDK.Models.Redistributable),
-};
-
-sb.AppendLine("export interface TypeProperty {");
-sb.AppendLine(" name: string;");
-sb.AppendLine(" type: string;");
-sb.AppendLine("}");
-sb.AppendLine();
-sb.AppendLine("export interface TypeDefinition {");
-sb.AppendLine(" name: string;");
-sb.AppendLine(" properties: TypeProperty[];");
-sb.AppendLine("}");
-sb.AppendLine();
-sb.AppendLine("export const variableTypes: TypeDefinition[] = [");
-
-foreach (var (typeName, clrType) in variableTypes.OrderBy(kv => kv.Key))
-{
- sb.AppendLine(" {");
- sb.AppendLine($" name: {JsonEncode(typeName)},");
- sb.AppendLine(" properties: [");
-
- var props = clrType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .Where(p => p.CanRead)
- .OrderBy(p => p.Name);
-
- foreach (var prop in props)
- {
- sb.AppendLine(" {");
- sb.AppendLine($" name: {JsonEncode(prop.Name)},");
- sb.AppendLine($" type: {JsonEncode(GetPropertyTypeName(prop.PropertyType))},");
- sb.AppendLine(" },");
- }
-
- sb.AppendLine(" ],");
- sb.AppendLine(" },");
-}
-
-sb.AppendLine("];");
-sb.AppendLine();
-
-// Generate ScriptType enum values
-sb.AppendLine("export const scriptTypeValues: string[] = [");
-foreach (var value in Enum.GetNames())
-{
- sb.AppendLine($" {JsonEncode(value)},");
-}
-sb.AppendLine("];");
-
-var directory = Path.GetDirectoryName(outputPath);
-if (!string.IsNullOrEmpty(directory))
- Directory.CreateDirectory(directory);
-
-File.WriteAllText(outputPath, sb.ToString(), Encoding.UTF8);
-
-Console.WriteLine($"Generated completions for {cmdletTypes.Count()} LANCommander cmdlets, {builtinCount} built-in cmdlets, and {variableTypes.Count} variable types -> {outputPath}");
-return 0;
-
-static string JsonEncode(string? value)
-{
- if (value == null) return "null";
- return JsonSerializer.Serialize(value);
-}
-
-static string GetPropertyTypeName(Type type)
-{
- var underlying = Nullable.GetUnderlyingType(type);
- if (underlying != null)
- return GetPropertyTypeName(underlying) + "?";
-
- if (type.IsArray)
- return GetPropertyTypeName(type.GetElementType()!) + "[]";
-
- if (type.IsGenericType)
- {
- var genericDef = type.GetGenericTypeDefinition();
- if (genericDef == typeof(IEnumerable<>) || genericDef == typeof(ICollection<>) ||
- genericDef == typeof(List<>) || genericDef == typeof(IList<>))
- {
- var elementType = type.GetGenericArguments()[0];
- return GetPropertyTypeName(elementType) + "[]";
- }
- }
-
- // Check non-generic IEnumerable
- if (type != typeof(string) && type.GetInterfaces().Any(i =>
- i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)))
- {
- var elementType = type.GetInterfaces()
- .First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))
- .GetGenericArguments()[0];
- return GetPropertyTypeName(elementType) + "[]";
- }
-
- if (type == typeof(string)) return "string";
- if (type == typeof(int) || type == typeof(long)) return "int";
- if (type == typeof(uint)) return "uint";
- if (type == typeof(double) || type == typeof(float)) return "double";
- if (type == typeof(bool)) return "bool";
- if (type == typeof(Guid)) return "Guid";
- if (type == typeof(DateTime)) return "DateTime";
- if (type == typeof(Uri)) return "Uri";
- if (type.IsEnum) return type.Name;
-
- return type.Name;
-}
-
-static string MapTypeName(string typeName) => typeName switch
-{
- "String" => "string",
- "Int32" => "int",
- "Int64" => "long",
- "UInt32" => "uint",
- "Double" => "double",
- "Single" => "float",
- "Boolean" => "bool",
- "Byte" => "byte",
- "Guid" => "Guid",
- "Uri" => "Uri",
- "DateTime" => "DateTime",
- "Object" => "object",
- "SwitchParameter" => "SwitchParameter",
- "SecureString" => "SecureString",
- "String[]" => "string[]",
- "Int32[]" => "int[]",
- "Object[]" => "object[]",
- "Byte[]" => "byte[]",
- "PSObject" => "object",
- "PSObject[]" => "object[]",
- "Hashtable" => "Hashtable",
- "ScriptBlock" => "ScriptBlock",
- "TimeSpan" => "TimeSpan",
- "PSCredential" => "PSCredential",
- _ => typeName,
-};
-
-static string GetFriendlyTypeName(Type? type)
-{
- if (type == null) return "object";
-
- // Handle nullable types
- var underlying = Nullable.GetUnderlyingType(type);
- if (underlying != null)
- return GetFriendlyTypeName(underlying) + "?";
-
- // Handle arrays
- if (type.IsArray)
- return GetFriendlyTypeName(type.GetElementType()) + "[]";
-
- // Handle SwitchParameter
- if (type == typeof(SwitchParameter))
- return "SwitchParameter";
-
- // Handle common types
- if (type == typeof(string)) return "string";
- if (type == typeof(int)) return "int";
- if (type == typeof(long)) return "long";
- if (type == typeof(uint)) return "uint";
- if (type == typeof(double)) return "double";
- if (type == typeof(float)) return "float";
- if (type == typeof(bool)) return "bool";
- if (type == typeof(byte)) return "byte";
- if (type == typeof(byte[])) return "byte[]";
- if (type == typeof(Guid)) return "Guid";
- if (type == typeof(Uri)) return "Uri";
- if (type == typeof(object)) return "object";
-
- // Handle SecureString
- if (type == typeof(System.Security.SecureString))
- return "SecureString";
-
- return type.Name;
-}
diff --git a/LANCommander.Documentation/Overview.md b/LANCommander.Documentation/Overview.md
index 9968fb3c..f446de80 100644
--- a/LANCommander.Documentation/Overview.md
+++ b/LANCommander.Documentation/Overview.md
@@ -25,6 +25,5 @@ This site serves as the main documentation platform for the project. As such, it
- [Getting Started](/GettingStarted)
- [Server](/Server/Overview)
- [Launcher](/Launcher/Overview)
-- [Packager](/Packager/Overview)
- [Scripting](/Scripting/Overview)
- [SDK Documentation](/SDK/Overview)
\ No newline at end of file
diff --git a/LANCommander.Documentation/Packager/Getting Started.md b/LANCommander.Documentation/Packager/Getting Started.md
deleted file mode 100644
index a66ade30..00000000
--- a/LANCommander.Documentation/Packager/Getting Started.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-sidebar_label: Getting Started
-sidebar_position: 2
----
-
-# Getting Started
-
-## Requirements
-
-- **Windows 10 or later** (x86 or x64)
-- **Administrator privileges** - the Packager requires elevation to monitor installer processes via DLL injection
-
-The Packager is distributed as a single 32-bit executable (`LANCommander.Packager.exe`). No installation is required.
-
-## Download
-
-Download the latest release from the [GitHub Releases page](https://github.com/LANCommander/LANCommander/releases). The Packager artifact is named `LANCommander.Packager-Windows-x86-v{VERSION}.zip`.
-
-Extract the archive to a directory of your choice and run `LANCommander.Packager.exe`.
-
-## Command-Line Usage
-
-The Packager can optionally accept arguments to skip the initial file picker dialog:
-
-```
-LANCommander.Packager.exe [installer-path] [-o output-path]
-```
-
-| Argument | Description |
-|:--------:|:------------|
-| `installer-path` | Path to the installer executable to monitor |
-| `-o`, `--output` | Path for the output `.lcx` file |
-
-If no installer path is provided, a file picker dialog will appear on launch.
diff --git a/LANCommander.Documentation/Packager/LCX Format.md b/LANCommander.Documentation/Packager/LCX Format.md
deleted file mode 100644
index abac23cc..00000000
--- a/LANCommander.Documentation/Packager/LCX Format.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-sidebar_label: LCX Package Format
-sidebar_position: 4
----
-
-# LCX Package Format
-
-An `.LCX` file is a standard ZIP archive containing everything needed to install and configure a game through LANCommander. The Packager generates this format automatically, but understanding its structure is useful for troubleshooting or manual editing.
-
-## Archive Structure
-
-```
-package.lcx (ZIP)
-├── manifest.yaml # Game metadata (YAML)
-├── Archives/
-│ └── {guid} # Inner ZIP containing game files
-└── Scripts/
- ├── {guid} # Install script (PowerShell)
- └── {guid} # Uninstall script (PowerShell)
-```
-
-### manifest.yaml
-
-The manifest is a YAML file describing the game's metadata, actions, archive references, and script references. It follows the LANCommander SDK's `Game` manifest schema and includes:
-
-- **Title, Sort Title, Version, Description, Notes** - basic metadata
-- **Released On, Singleplayer** - classification
-- **Directory Name** - the expected install directory name
-- **Actions** - launch configurations (name, executable path, arguments, primary flag)
-- **Archives** - references to inner archive entries with compressed/uncompressed sizes
-- **Scripts** - references to script entries with type (Install/Uninstall) and admin requirements
-
-### Archives
-
-The `Archives/` directory contains one or more inner ZIP files, each identified by a GUID. The inner archive holds the game files with paths relative to the install directory root.
-
-### Scripts
-
-The `Scripts/` directory contains PowerShell scripts identified by GUID. The Packager generates up to two scripts:
-
-**Install Script** - Recreates registry keys and values captured during monitoring. If the Patch GameSpy option was enabled, it also includes an `Edit-PatchGameSpy` call. Scripts assume `$InstallDirectory` is available in the execution environment (provided by the launcher's PowerShell runtime).
-
-**Uninstall Script** - Removes the registry keys and values that were created by the install script.
-
-## Importing into LANCommander
-
-`.LCX` packages can be imported directly through the LANCommander server's web interface. The server reads the manifest, extracts the archive and scripts, and creates the corresponding game entry with all metadata, actions, and scripts pre-configured.
diff --git a/LANCommander.Documentation/Packager/Overview.md b/LANCommander.Documentation/Packager/Overview.md
deleted file mode 100644
index ac2437b1..00000000
--- a/LANCommander.Documentation/Packager/Overview.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-sidebar_label: Overview
-sidebar_position: 1
----
-
-# Packager
-
-The LANCommander Packager is a standalone Windows utility that automates the creation of `.LCX` game packages. It monitors a game installer as it runs, captures all file and registry changes, and guides you through a wizard to produce a ready-to-import package for your LANCommander server.
-
-Instead of manually creating archives, writing install scripts, and filling out metadata by hand, the Packager handles all of this in a single guided workflow.
-
-- [Getting Started](/Packager/Getting%20Started) - requirements, download, and command-line usage
-- [Wizard Walkthrough](/Packager/Wizard) - step-by-step guide through the seven wizard stages
-- [LCX Package Format](/Packager/LCX%20Format) - internal structure of `.LCX` files
diff --git a/LANCommander.Documentation/Packager/Wizard.md b/LANCommander.Documentation/Packager/Wizard.md
deleted file mode 100644
index bc5bcea8..00000000
--- a/LANCommander.Documentation/Packager/Wizard.md
+++ /dev/null
@@ -1,114 +0,0 @@
----
-sidebar_label: Wizard Walkthrough
-sidebar_position: 3
----
-
-# Wizard Walkthrough
-
-The Packager walks you through seven steps to create a complete `.LCX` package. Each step is shown in the sidebar with a progress indicator.
-
----
-
-## Step 1: Monitor Installer
-
-After selecting an installer executable, the Packager launches it and monitors all file and registry activity using native DLL injection (Interposer). A real-time log displays captured events as the installer runs.
-
-The Packager automatically:
-- Detects the installer's architecture (32-bit or 64-bit) and injects the appropriate Interposer DLL
-- Monitors child processes spawned by the installer
-- Filters out writes to system directories (Windows, temp folders)
-- Captures both file writes and registry key/value creation
-
-Once the installer exits, the captured data is summarized in the status bar. Click **Next** to continue.
-
-:::info
-The log view continues to show captured events for reference. All diagnostic output is also written to `packager.log` in the application directory.
-:::
-
----
-
-## Step 2: Install Directory
-
-The Packager analyzes the captured file writes to detect the game's installation directory. This is determined by finding the most common non-system directory among the written files.
-
-If the detected directory is incorrect, click **Browse** to manually select the correct location. This directory becomes the root of the game archive.
-
----
-
-## Step 3: Select Files
-
-All files within the install directory are displayed in a tree view with checkboxes. By default, every file is selected.
-
-- **Check/uncheck a directory** to toggle all files within it
-- **Select All** / **Select None** buttons at the top for bulk operations
-- The counter at the top shows how many files are currently selected
-
-Files outside the install directory (if any were captured) are listed by their full paths. Only files that still exist on disk at this point are shown.
-
----
-
-## Step 4: Registry Entries
-
-All captured registry writes are displayed in a tree view organized by hive and key path. Entries are deduplicated so if the same key and value were written multiple times during installation, only one entry is shown.
-
-Each leaf entry displays an indicator:
-- **Green +** - the entry was created during installation
-- **Yellow ~** - the entry was updated (written to an existing key)
-
-Selected entries will be included in the auto-generated install and uninstall scripts. The install script recreates the registry keys and values; the uninstall script removes them.
-
----
-
-## Step 5: Game Metadata
-
-Enter basic information about the game. The title is pre-populated from the installer's filename.
-
-| Field | Description |
-|:------|:------------|
-| **Title** | Display name of the game (required) |
-| **Sort Title** | Optional override for alphabetical sorting |
-| **Version** | Game version, defaults to `1.0` |
-| **Released On** | Release date of the game |
-| **Singleplayer** | Whether the game supports singleplayer |
-| **Description** | A description of the game |
-| **Notes** | Private notes (admin-only, not shown to users) |
-
----
-
-## Step 6: Game Executable
-
-The Packager scans your selected files for `.exe` files and filters out common installer/redistributable executables (e.g. `vcredist`, `dxsetup`, `setup`, `unins`). The remaining executables are displayed in a list.
-
-Select the primary game executable. This is the file the launcher will run when the user clicks "Play". You can also customize:
-
-| Field | Description |
-|:------|:------------|
-| **Action Name** | Label shown on the play button, defaults to `Play` |
-| **Arguments** | Command-line arguments passed when launching |
-
----
-
-## Step 7: Generate Package
-
-Configure the output path for the `.LCX` file and optionally adjust packaging options before generating.
-
-### Output Path
-
-The default output path is based on the game title in the current working directory. Click **Browse** to choose a different location.
-
-### Options
-
-Expand the **Options** panel to configure additional settings:
-
-| Option | Description |
-|:-------|:------------|
-| **Patch GameSpy** | Adds an `Edit-PatchGameSpy -Path $InstallDirectory` call to the install script. This scans the install directory for GameSpy references and patches them for OpenSpy compatibility. |
-| **Compression Level** | Controls the trade-off between archive size and packaging speed. Options: Optimal (default), Fastest, No Compression, Smallest Size. |
-| **Write Summary Log** | Writes a `.Package.log` file alongside the `.LCX` output documenting the source installer, selected files, registry entries, metadata, and options used. |
-
-Click **Generate .LCX** to build the package. A progress bar shows the current stage:
-1. Creating game files archive
-2. Generating scripts
-3. Writing manifest
-
-On completion, the output path and file size are displayed.
diff --git a/LANCommander.Documentation/Releases/2.1.0-rc1.mdx b/LANCommander.Documentation/Releases/2.1.0-rc1.mdx
deleted file mode 100644
index 93674111..00000000
--- a/LANCommander.Documentation/Releases/2.1.0-rc1.mdx
+++ /dev/null
@@ -1,195 +0,0 @@
----
-title: 2.1.0-rc1
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ScreenshotCarousel from '@site/src/components/ScreenshotCarousel';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.0 Release Candidate 1 Release Notes
-
-## Breaking Changes
-
-### .NET 10
-LANCommander has been upgraded from .NET 8 to .NET 10. This should be a seamless transition as all LANCommander binaries are self-contained and include the appropriate .NET runtime. However, if you are running an older version of Windows that does not support .NET 10, you may encounter issues running this version of LANCommander. The minimum supported Windows version is now Windows 10 version 1809.
-
-## Launching Into the Future
-To kick things off, LANCommander 2.1.0 introduces a new launcher. As a recap, the previous launcher was built using .NET Blazor and wrapped into a Webview2 chrome using the open source project [Photino](https://www.tryphotino.io/). This decision was originally made in an effort to maintain a somewhat shared codebase with the server web UI.
-
-Unfortunately, over time limitations of the platform made it apparent that a change had to be made. Community member [aaronpowell](https://github.com/aaronpowell) stepped up and built out a proof of concept for a launcher using Avalonia, an open-source native cross-platform UI framework for .NET. The potential was immediately clear and the decision was made to jump in head first. The following months saw the project refocus on build a new launcher with a major emphasis on performance, reliability, design, and feature parity.
-
-The result is, well, a project that went off a little off the rails in the best possible fashion:
-
-
-
-I think the screenshots speak for themselves. Okay, not quite, there's a lot of fun stuff in this build so let's dive right in!
-
-### User Library
-Previous versions of the launcher attempted to bring the art of a game to the forefront. These views are where most players spend their time interacting with the application. However, everyone's tastes are different. As such the library now contains three main views; cover grid, list, and shelf.
-
-
-
-### Game Details
-Browsing the user library is great and all, but the detail view is where a game really gets to shine. Metadata has shifted to the right-hand side, descriptions now support more complex formatting with Markdown rendering support, and _finally_ videos and screenshots are displayed in a carousel. A lightbox viewer lets you zoom in on screenshots, watch videos, and even read game manuals right from the detail view thanks to the new built-in PDF viewer.
-
-
-
-This is really just the start of something new. Expect this part of the launcher to be built out more in the future with better publishing tools and metadata expansion.
-
-### Depot
-The depot has been overhauled too, probably more than any other part of the launcher. Previous iterations have been _fine_, but with large game sets it was hard to find just what you needed. The new depot puts a focus on categorization while maintaining granular filtering tools to get you exactly where you need to go.
-
-
-
-As you can see, this section more than any other benefits from high quality metadata. We'll get into that later, but first let's talk about game installation.
-
-### Download Queue
-Game installation and downloading has been the hardest component to develop in the entire LANCommander platform. And rightly so! Without game installation, all you get is a fancy web UI and a pretty launcher that can't launch any games.
-
-The LANCommander launcher is essentially a UI wrapper for the LANCommander SDK. The SDK is a .NET library that provides all of the useful bits of communicating with a LANCommander server install. Authentication, script execution, save management, game installation and so much more are provided through this library. In previous iterations, the SDK had a very strict execution plan for installing games. Often times this would lead to deadlocks and outright failures.
-
-To resolve this in 2.1.0, the game install process has moved to a two-layer queuing system. The first layer of the queue manages what actually enters the download queue. This could be a game, redistributable, tool, or addon. The second layer handles any subprocessing step such as executing the different script types, save downloading, existing file verification, etc. The result is a system that is more transparent and reliable.
-
-This queuing system has been exposed somewhat in the download queue:
-
-
-
-Each entry in the download queue will now report each step of the process during a game installation. In addition to looking a bit less mystical, this should give users an idea of what _exactly_ is happening during a game installation.
-
-### Discord Rich Presence
-The launcher now integrates with Discord Rich Presence. When you're playing a game through LANCommander, your Discord status will update to show what you're playing. By default this will show as LANCommander with the game name as the status. If the game is mapped to the correct Discord ID (via external IDs in the server), then the game's official Discord assets will be used for a more polished presentation.
-
-### Offline Mode
-Previous attempts at an offline mode in the launcher were pretty clunky and unreliable. In 2.1.0, the launcher now has a proper offline mode that allows you to launch previously installed games even when the server is unreachable.
-
-### Single Sign-On
-SSO was supported in the previous launcher, but it was a bit of an afterthought and not super reliable. In the new launcher it's been given more attention and should work as expected.
-
-### Chat
-As with the last version, the chat functionality remains pretty barebones. This is an area that will get more attention in the future, but it _is_ currently available in the new launcher. It might even work a little better.
-
-## Server Improvements
-This release should also be pretty exciting for server admins. With a new launcher with shiny new features brings a set of tools to manage them.
-
-### LANCommander HQ
-A new metadata provider has been added: LANCommander HQ. This gives server admins another option for sourcing game metadata and media alongside IGDB and SteamGridDB. In addition, support for external IDs beyond IGDB has been introduced, making it easier to cross-reference games across multiple platforms and services. To get started with the HQ, link your Discord account under "Integrations / HQ" in the server settings. Existing metadata providers such as IGDB and SteamGridDB are still available as well, so no functionality has been lost from previous versions.
-
-### Preview Tool
-A new "Preview" editing view has been added to the server for managing game art. Inspired by the launcher's presentation, this tool gives admins a better sense of how their media will look in the launcher before publishing. Screenshots and videos can now be uploaded in bulk, and the media grabber supports selecting multiple assets at once. Animated covers are now supported as well, APNG images grabbed from SteamGridDB are automatically converted to video and should display in the new launcher.
-
-
-
-### Script Editor Improvements
-The script editor has received a significant upgrade. Context-aware code completions now suggest built-in PowerShell cmdlets, LANCommander-specific variables, and generated type definitions. Validation has been improved, and script errors are now logged rather than silently swallowed. A new `Set-GameDetails` cmdlet has been added for specifying package details from within scripts.
-
-### Redistributable Options
-Redistributables now support configurable options. Options can be defined with a schema editor, given display names, and are preserved across import/export. Assigning redistributables to a game can now be accessed under the "Redistributables" section of the game editor. When selected, a redistributable with options will render as an easy-to-use form and per-game overrides can be defined.
-
-
-
-A new **RunWrapper** script type has been added as well, giving admins more control over how redistributables and games are executed. This should enhance redistributables to cover more use cases such as emulators, launchers, and compatiblity shims like Proton or dgVoodoo.
-
-### Key Management
-Key allocation has been improved. The allocation method on keys is now nullable, and a new manual key assignment dialog has been added. The logic for choosing the next available key has also been reworked.
-
-### Media & Tooling
-Server settings now include checks for ffmpeg and yt-dlp availability, with automatic installation support in Docker environments. A new streaming endpoint for media has been added, improving video playback in the launcher. Automatic downloads for Steam screenshots have been added as well. YouTube video support has been added to the media grabber, and ffmpeg is now used for video processing tasks such as APNG conversion and thumbnail generation.
-
-## LANCommander HQ
-Last but far from least, this release coincides with the launch of [LANCommander HQ](https://hq.lancommander.com), a new metadata provider and community hub for LANCommander users. The HQ aims to provide a centralized platform for game metadata and media across multiple providers, while also normalizing and enhancing that data with a focus on the needs of LANCommander users. There's a bit to cover, so let's dive in!
-
-### Metadata
-LANCommander HQ aggregates metadata from multiple sources including IGDB, SteamGridDB, and user contributions. This data is then normalized and enhanced with a focus on the needs of LANCommander users. There are many metadata providers out there, and unfortunately they all have their own unique schemas and data quality issues. The HQ's goal is to provide a single source of truth for game metadata that can be consumed by LANCommander servers and launchers.
-
-There are a lot of exciting features in the works for the HQ, but for this release the focus is on building out the core infrastructure and integrating with the new launcher. Expect to see more features and improvements in the future as the HQ continues to evolve.
-
-### Community
-The main features of the HQ are currently focused on metadata, but there is the potential to expand it into a broader community hub for LANCommander users. This could include an area to share game configurations, scripts, and other resources. Additionally, there are some content moderation tools in place that should allow the HQ to host user-generated content and provide the best possible experience for all users. If you have a knack for curation and want to help out, there will likely be opportunities to get involved in the future!
-
-### Tiering / Subscriptions
-The HQ operates on a tiered subscription model. Currently there are only two tiers: Basic (Free) and Premium ($5/month). The Basic tier provides access to the core metadata features of the HQ, while the Premium tier provides access to additional features such as enhanced metadata, relaxed rate limiting, and more. More information will be provided in the near future and will be provided on this site under the [LANCommander HQ](/hq) section. All paid subscriptions on the HQ are handled by Stripe, so every transaction is secure and your personal information is protected.
-
-This subscription model is primarily in place to help cover the costs of running the HQ while also providing a sustainable path forward for continued development of LANCommander itself. This entire project is developed and maintained by essentially one person with some help from the community here and there. While this is a labor of love, there are real costs associated with running the infrastructure for the HQ and continued development of LANCommander. The subscription model is designed to be as accessible as possible while also providing a way for users to support the project if they find value in it.
-
-As such, this will probably mean that the Patreon page will be going away in the near future. For existing patrons, it is recommended to switch over to the new subscription model on the HQ as it provides a more direct way to support the project and access the benefits of the HQ _today_.
-
-## Contributions
-This release includes contributions from the following community members:
-
-**@aaronpowell | PR [#395](https://github.com/LANCommander/LANCommander/pull/395): Avalonia Launcher**
-A substantial 42-commit, 6,000-line PR that bootstrapped the cross-platform Avalonia launcher. Aaron built out the core application structure, service wiring, and async initialization, and then built out the UI with filtered depot browsing and grid layout, a download queue, install/uninstall flows, a settings page, PDF manual viewer, game action bars, and a full CI pipeline for Windows, Linux, and macOS builds. A lot of the foundation that the current launcher sits on came from this PR.
-
-**@akifreak | PR [#398](https://github.com/LANCommander/LANCommander/pull/398): Fix Game Archive Upload**
-Discovered that archive uploads through the web UI were silently broken after upgrading to v2.0.2, traced the problem through the JavaScript bundle, and submitted 11 targeted fixes rather than a single bug report. The fixes covered ES module loading, null-guards on DOM elements, a FileMode.Append seek conflict, incorrect archive ID passing, and error propagation throughout the upload pipeline. The uploading process should feel much more stable now!
-
-**@MasterMNB | PR [#400](https://github.com/LANCommander/LANCommander/pull/400): Avalonia Launcher Back Button**
-Improved the visibility and styling of the back button in the `GameDetailView`, which was previously hard to distinguish from the background. It's a small change, but it's a real usability improvement that makes navigation feel more intentional.
-
-## Changelog
-
-**Launcher**
-- Added: Brand new launcher built on Avalonia
-- Added: Screenshots and videos in game detail carousel with lightbox viewer
-- Added: Redesigned depot with categorization carousels and advanced filtering
-- Added: Tools support
-- Added: Download transfer speed graph
-- Added: OS notifications upon game installation completion
-- Added: Discord Rich Presence integration
-- Added: Offline mode for playing games without server connectivity
-- Added: SSO/OIDC external authentication provider support
-- Added: Built-in PDF viewer for game manuals
-- Added: Splash screen on startup
-- Added: Verify files option in game dropdown
-- Added: Markdown rendering for game descriptions
-- Changed: Refactored game/redistributable/tool install process to two-layer queue system
-- Changed: Imports should be faster and execute in the background without causing major UI hiccups
-- Changed: Improved memory management with proper disposal of HTTP clients, RPC clients, and cancellation tokens
-- Fixed: Installing games that already have files on disk will show the verification process during install
-- Fixed: Logging in and logging out should no longer cause a hung UI
-- Fixed: Install-uninstall-reinstall cycle no longer locks the game
-- Fixed: Authentication token race condition causing crashes after login
-
-**Server**
-- Added: LANCommander HQ metadata provider support
-- Added: Support for external IDs beyond IGDB
-- Added: New launcher-inspired "Preview" tool for managing art
-- Added: Context-aware script editor with code completions, validation, and PowerShell type definitions
-- Added: Redistributable options with schema editor and display names
-- Added: RunWrapper script type
-- Added: Package versioning for archives
-- Added: Bulk upload for screenshots and videos
-- Added: Multiple select support in media grabber
-- Added: Animated cover support (APNG auto-conversion to video)
-- Added: Steam screenshot downloads
-- Added: YouTube video support in media grabber
-- Added: Media streaming endpoint
-- Added: ffmpeg/yt-dlp availability checks and auto-install in Docker
-- Added: Manual key assignment dialog and reworked key allocation
-- Added: `Set-GameDetails` cmdlet for package scripts
-- Fixed: Media uploads should now behave as expected
-- Fixed: Game archive uploads in web UI
-- Fixed: Orphaned files tool
-- Fixed: Redistributable archive downloads
-- Fixed: Script working directory for games
-- Fixed: SteamCMD login when SteamGuard is enabled
-- Fixed: Import file cleanup after completion
-
-**Infrastructure**
-- Upgraded to .NET 10
-- Added ARM64 build support
-- Removed legacy Photino launcher from CI
-- Added nightly builds for Avalonia launcher
-
-## Downloads
-
-
-
-## Contributors
-
-
\ No newline at end of file
diff --git a/LANCommander.Documentation/Releases/2.1.0-rc2.mdx b/LANCommander.Documentation/Releases/2.1.0-rc2.mdx
deleted file mode 100644
index 2858b8bb..00000000
--- a/LANCommander.Documentation/Releases/2.1.0-rc2.mdx
+++ /dev/null
@@ -1,99 +0,0 @@
----
-title: 2.1.0-rc2
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ScreenshotCarousel from '@site/src/components/ScreenshotCarousel';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.0 Release Candidate 2 Release Notes
-
-## Variable Picker Dialog
-A new variable picker dialog has been added to the action editor and save path editor in the server UI. When editing action arguments, working directories, or save paths, a new button opens a dialog that lets you browse and insert LANCommander variables (`{InstallDir}`, `{DisplayWidth}`, etc.), environment variables (`%APPDATA%`, `%LOCALAPPDATA%`, etc.), and special folder paths (`%MyDocuments%`, `%Desktop%`, etc.) with a single click. No more needing to remember exact variable names or syntax.
-
-## Redistributable Improvements
-
-### File Tracking and Cleanup
-Redistributable files installed into a game's directory are now tracked. When a game is uninstalled, any files that were extracted by redistributables are cleaned up alongside the game's own files. This prevents leftover redistributable artifacts from lingering in the install directory after uninstallation.
-
-### Detect Install Script Behavior
-The redistributable install detection logic has been updated. If a redistributable does not have a detect install script defined, the install script will now always run rather than being skipped. This ensures that redistributables without detection scripts are reliably installed every time, which is the expected behavior for most simple redistributable configurations.
-
-## Package Script Filtering
-Package scripts are now filtered out of API responses for games, redistributables, and tools. These scripts are used internally during the packaging process and should not be sent to clients. This filtering is applied at both the endpoint and AutoMapper levels, ensuring that package scripts never leak to launchers or other API consumers.
-
-## Launcher CLI Improvements
-
-### Full Command Line Verb Support
-The Avalonia launcher now supports the full set of CLI verbs: `RunScript`, `Install`, `Uninstall`, `Run`, `Sync`, `Import`, `Export`, `Upload`, `Login`, `Logout`, and `ChangeAlias`. Previously only `RunScript` was supported in headless mode. The `--help` and `--version` flags are also now recognized. Console logging has been added for headless execution.
-
-### CLI Project Removal
-The standalone `LANCommander.Launcher.CLI` project has been removed. All CLI functionality is now handled directly by the Avalonia launcher's headless mode, consolidating the codebase and eliminating the need for a separate CLI binary.
-
-## C++ SDK
-A new C++ SDK (`LANCommander.SDK.Cpp`) has been introduced. Written in C++14, it provides maximum compatibility from Windows 95 through modern platforms. The SDK includes 16 API clients covering authentication, games, library, saves, media, tools, depot, and more. It ships with two HTTP backend implementations (WinINet for Windows and libcurl for cross-platform) and uses vendored cJSON for zero mandatory external dependencies. This SDK is the foundation that powers the legacy launcher.
-
-## Legacy Launcher
-
-:::note A Fun Detour
-The legacy launcher is a fun side project and detour from the main development track. It should not be regarded as a primary concern or a supported production launcher. Think of it as an experiment in seeing just how far back LANCommander's reach can extend.
-:::
-
-The legacy launcher is a native Win32 application written in C++ that targets Windows 9x (Windows 95/98/ME) and beyond. Built on Allegro 4 for rendering and GDI+ for image decoding (JPEG cover art, backgrounds, etc.), it provides a surprisingly full-featured LANCommander experience on vintage hardware. GDI+ is a requirement and must be available on the target system. For Windows 9x systems, this means installing the [GDI+ redistributable](https://www.microsoft.com/en-us/download/details.aspx?id=18909).
-
-Features include:
-- **Library and Depot browsing** with cover art grid
-- **Game detail view** with metadata and screenshots
-- **Download queue** with progress tracking
-- **Login screen** with server authentication
-- **Settings screen** with configurable server address
-- **SQLite-backed local database** for offline game metadata
-- **Custom window chrome** with a themed UI
-- **Unicode support** for international character sets
-
-
-
-## Changelog
-
-**Server**
-- Added: Variable picker dialog for action editor and save path editor
-- Fixed: Package scripts are no longer exposed through API responses
-- Fixed: Script helper now correctly maps Package and RunWrapper script filenames
-
-**Launcher**
-- Added: Full set of CLI verbs (Install, Uninstall, Run, Sync, Import, Export, Upload, Login, Logout, ChangeAlias)
-- Added: Console logging for headless CLI execution
-- Changed: Redistributable files are now tracked and cleaned up on uninstall
-- Changed: Redistributables without detect install scripts now always run the install script
-- Fixed: Script writing for all script types
-- Removed: Standalone CLI project (functionality merged into Avalonia launcher)
-
-**Legacy Launcher**
-- Added: Complete native Win32 launcher targeting Windows 9x
-- Added: Library and depot browsing with cover art grid
-- Added: Game detail view with metadata display
-- Added: Download queue with SQLite-backed local database
-- Added: Settings screen with YAML configuration
-- Added: Custom themed window chrome
-- Added: Unicode support for Win9x targets
-- Added: CI workflow for automated builds
-
-**SDK**
-- Added: C++ SDK (LANCommander.SDK.Cpp) with C++14 compatibility
-- Added: WinINet and libcurl HTTP backend implementations
-- Added: 16 API clients covering full LANCommander API surface
-- Changed: Game list filtering now shows only standalone mods/expansions and main games
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.0-rc3.mdx b/LANCommander.Documentation/Releases/2.1.0-rc3.mdx
deleted file mode 100644
index ee74989b..00000000
--- a/LANCommander.Documentation/Releases/2.1.0-rc3.mdx
+++ /dev/null
@@ -1,73 +0,0 @@
----
-title: 2.1.0-rc3
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.0 Release Candidate 3 Release Notes
-
-## Server Discovery
-The Avalonia launcher now supports automatic server discovery using the beacon system. When connecting to a server, the launcher can discover LANCommander servers on the local network without needing to manually enter an address. This feature was already present in the legacy Photino launcher and has now been brought forward to the new Avalonia-based launcher.
-
-## Launcher Improvements
-
-### List View Rework
-The list view in the launcher library has been reworked for a cleaner presentation and better usability. It is essentially a reimagination of the library view in the previous launcher.
-
-### Carousel Fixes
-Cover art in carousels has received several visual fixes. The shadow overlay on covers now renders correctly, and covers are no longer cut off at the bottom when hovered. These are small polish items that improve the overall look and feel of the depot and library views.
-
-### Play Button State
-The play button now correctly reflects the current state of a running game, showing the appropriate playing/stop indicator when a game is actively running.
-
-### Performance Optimizations
-General launcher optimizations have been made to improve responsiveness and reduce resource usage.
-
-## Bug Fixes
-
-### Redistributable and Tool Importing
-Fixed an issue where importing redistributables and tools from an LCX file was not working correctly.
-
-### Linux Path Expansion
-Path variable expansion now correctly preserves the Linux root (`/`) prefix. Previously, expanding variables like `{InstallDir}` on Linux could strip the leading slash, resulting in incorrect paths.
-
-## Contributions
-This release includes contributions from the following community members:
-
-**@akifreak | PR [#403](https://github.com/LANCommander/LANCommander/pull/403): Fix Beacon Discovery**
-Back for another round of contributions, akifreak tracked down and fixed an issue where server discovery wasn't working reliably. In some cases the launcher was actually finding servers on the network but wasn't surfacing them in the UI. The fix spans the full discovery pipeline; making the probe socket more resilient to bad data, ensuring beacon responses actually reach the client, and refreshing the server list as soon as a new server is found.
-
-## Changelog
-
-**Launcher**
-- Added: Automatic server discovery via beacon system
-- Added: Play session sync on game import
-- Added: Additional save handling logging
-- Changed: Reworked list view layout
-- Changed: Performance optimizations
-- Changed: Increased cover art rendering quality
-- Changed: Action bar layout and behavior adjustments
-- Fixed: Shadow overlay on covers in carousels
-- Fixed: Cover art clipping on hover in carousels
-- Fixed: Play button now shows correct playing/stop state when a game is running
-- Fixed: Linux path variable expansion now preserves root prefix
-
-**Server**
-- Fixed: Redistributable and tool importing
-- Fixed: Media settings page failing to render
-
-**SDK**
-- Fixed: Discovery probe socket resilience against malformed data
-- Fixed: Broadcast send reliability in DiscoveryProbe
-- Fixed: Beacon responses now forwarded to BeaconClient event
-- Fixed: Save client now checks for newer server saves before uploading
-- Added: Logging in save packaging and upload pipeline
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.0-rc4.mdx b/LANCommander.Documentation/Releases/2.1.0-rc4.mdx
deleted file mode 100644
index b5e8794f..00000000
--- a/LANCommander.Documentation/Releases/2.1.0-rc4.mdx
+++ /dev/null
@@ -1,57 +0,0 @@
----
-title: 2.1.0-rc4
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.0 Release Candidate 4 Release Notes
-
-## Big Screen Mode
-The Avalonia launcher now supports a big screen mode designed for use with TVs and handhelds. When enabled, the launcher switches to a fullscreen layout optimized for focused gaming. This pairs with the new gamepad navigation support to make LANCommander usable from the sofa or a handheld without a keyboard or mouse.
-
-## Gamepad Navigation
-Full gamepad support has been added to the Avalonia launcher. Controllers are detected and mapped using SDL2, and directional navigation has been implemented across the library views, carousels, game detail pages, and overlay dialogs. Focus management handles moving between UI elements naturally with a d-pad or analog stick, making it possible to browse, install, and launch games entirely with a controller. This feature is still a bit rough around the edges, but should improve in future releases.
-
-## RunWrapper Scripts for Redistributables
-RunWrapper scripts were previously defined as a script type but never actually executed. This release adds full execution support during game launch. RunWrapper scripts now run alongside the game process with proper state tracking, cancellation support, and child process cleanup when the game exits.
-
-## Packaging Dialog
-The "Package" button has been moved from individual game, redistributable, and tool edit pages to the archive editor. A new packaging dialog provides a consolidated view of the packaging process, making it easier to build and manage archives directly from the archive list.
-
-## Bug Fixes
-- Fixed scrollbar being overlapped by the titlebar in the launcher
-- Fixed HQ metadata lookups by updating the HQ SDK dependency
-- Fixed media downloads failing during game import
-- Fixed icon downloading
-- Fixed server save check incorrectly determining newer saves were available
-
-## Changelog
-
-**Launcher**
-- Added: Big screen mode for fullscreen TV/controller use
-- Added: Gamepad navigation across library, carousels, detail views, and overlays
-- Fixed: Scrollbar overlapped by titlebar
-- Fixed: Media download on import
-- Fixed: Icon downloading
-
-**Server**
-- Added: Packaging dialog on archive editor
-- Added: RunWrapper script type available for redistributable scripts
-- Changed: Moved "Package" button from edit pages to archive editor
-- Changed: Updated PowerShell code snippets
-- Fixed: HQ metadata lookups
-
-**SDK**
-- Added: RunWrapper script execution for redistributables with process lifecycle management
-- Changed: Multi-target framework support
-- Changed: Source-generated cmdlet registration via new `CmdletRegistrationGenerator`
-- Fixed: Save client check for newer saves on server
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.0-rc5.mdx b/LANCommander.Documentation/Releases/2.1.0-rc5.mdx
deleted file mode 100644
index 88f47b74..00000000
--- a/LANCommander.Documentation/Releases/2.1.0-rc5.mdx
+++ /dev/null
@@ -1,59 +0,0 @@
----
-title: 2.1.0-rc5
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.0 Release Candidate 5 Release Notes
-
-## LANCommander Packager
-A new standalone Avalonia application, LANCommander Packager, has been added for building LCX package files outside of the server. The packager works by monitoring the files and registry entries created by a game's installer. You can then choose which files / registry keys are bundled into the final LCX file.
-
-## Background Uploading
-Archive uploads now continue in the background, allowing you to navigate away from the upload page without losing progress. An upload indicator in the sidebar tracks active uploads so you can continue working in other areas of the server while files transfer. This also applies to the import upload dialog.
-
-## HQ Login in First Time Setup
-The first time setup wizard now includes a step to log in to LANCommander HQ directly during initial configuration. This streamlines connecting to HQ for metadata and media lookups without needing to visit the settings page after setup is complete.
-
-## File Manager Improvements
-The server file manager has received several usability improvements including faster enumeration of large directories, toggleable columns, new Created and Type columns, fixed breadcrumb navigation, and a properly built directory tree with support for cross-platform roots and special folders.
-
-## Bug Fixes
-- Fixed standalone expansions and mods appearing in nested game lists (#284)
-- Fixed missing nested game data
-- Fixed save path matching failing on Windows due to backslash normalization (#266)
-- Fixed log viewer not working under Settings / Logs (#393)
-- Fixed user approval menu item not functioning (#405)
-- Fixed archive processing when using "Use Local File"
-- Fixed "Use Local File" button being incorrectly disabled
-
-## Changelog
-
-**Server**
-- Added: Background uploading with sidebar progress indicator
-- Added: HQ login step in first time setup wizard
-- Added: Button to download current log file
-- Changed: Improved file manager with faster directory enumeration, toggleable columns, and cross-platform directory tree
-- Fixed: Log viewer under Settings / Logs (#393)
-- Fixed: User approval menu item (#405)
-- Fixed: Archive processing for "Use Local File"
-- Fixed: "Use Local File" disabled button
-- Fixed: Standalone expansions/mods shown in nested game lists (#284)
-- Fixed: Missing nested game data
-
-**Packager**
-- Added: New standalone application for building LCX package files
-- Added: CI workflow for building and releasing the packager
-- Added: Documentation for packager usage and LCX format
-
-**SDK**
-- Fixed: Save path normalization to use forward slashes before regex matching (#266)
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.0.mdx b/LANCommander.Documentation/Releases/2.1.0.mdx
deleted file mode 100644
index 2e80e1e7..00000000
--- a/LANCommander.Documentation/Releases/2.1.0.mdx
+++ /dev/null
@@ -1,678 +0,0 @@
----
-title: 2.1.0
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ScreenshotCarousel from '@site/src/components/ScreenshotCarousel';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.0 Release Notes
-
-:::tip Latest Version
-This page covers the full LANCommander 2.1 series. The latest patch is **2.1.9** — see [Patch Updates](#patch-updates) below for what's changed since the initial release.
-:::
-
-LANCommander 2.1.0 is a landmark release that touches virtually every part of the platform. A brand new launcher built on Avalonia, a standalone packager application, a C++ SDK powering a legacy Win32 launcher, major server improvements, and the launch of LANCommander HQ all come together in what has been the most ambitious update cycle to date.
-
-## Breaking Changes
-
-### .NET 10
-LANCommander has been upgraded from .NET 8 to .NET 10. This should be a seamless transition as all LANCommander binaries are self-contained and include the appropriate .NET runtime. However, if you are running an older version of Windows that does not support .NET 10, you may encounter issues running this version of LANCommander. The minimum supported Windows version is now Windows 10 version 1809.
-
-## Launching Into the Future
-To kick things off, LANCommander 2.1.0 introduces a new launcher. As a recap, the previous launcher was built using .NET Blazor and wrapped into a Webview2 chrome using the open source project [Photino](https://www.tryphotino.io/). This decision was originally made in an effort to maintain a somewhat shared codebase with the server web UI.
-
-Unfortunately, over time limitations of the platform made it apparent that a change had to be made. Community member [aaronpowell](https://github.com/aaronpowell) stepped up and built out a proof of concept for a launcher using Avalonia, an open-source native cross-platform UI framework for .NET. The potential was immediately clear and the decision was made to jump in head first. The following months saw the project refocus on building a new launcher with a major emphasis on performance, reliability, design, and feature parity.
-
-The result is, well, a project that went off a little off the rails in the best possible fashion:
-
-
-
-I think the screenshots speak for themselves. Okay, not quite, there's a lot of fun stuff in this build so let's dive right in!
-
-### User Library
-Previous versions of the launcher attempted to bring the art of a game to the forefront. These views are where most players spend their time interacting with the application. However, everyone's tastes are different. As such the library now contains three main views; cover grid, list, and shelf.
-
-
-
-### Game Details
-Browsing the user library is great and all, but the detail view is where a game really gets to shine. Metadata has shifted to the right-hand side, descriptions now support more complex formatting with Markdown rendering support, and _finally_ videos and screenshots are displayed in a carousel. A lightbox viewer lets you zoom in on screenshots, watch videos, and even read game manuals right from the detail view thanks to the new built-in PDF viewer.
-
-
-
-This is really just the start of something new. Expect this part of the launcher to be built out more in the future with better publishing tools and metadata expansion.
-
-### Depot
-The depot has been overhauled too, probably more than any other part of the launcher. Previous iterations have been _fine_, but with large game sets it was hard to find just what you needed. The new depot puts a focus on categorization while maintaining granular filtering tools to get you exactly where you need to go.
-
-
-
-As you can see, this section more than any other benefits from high quality metadata. We'll get into that later, but first let's talk about game installation.
-
-### Download Queue
-Game installation and downloading has been the hardest component to develop in the entire LANCommander platform. And rightly so! Without game installation, all you get is a fancy web UI and a pretty launcher that can't launch any games.
-
-The LANCommander launcher is essentially a UI wrapper for the LANCommander SDK. The SDK is a .NET library that provides all of the useful bits of communicating with a LANCommander server install. Authentication, script execution, save management, game installation and so much more are provided through this library. In previous iterations, the SDK had a very strict execution plan for installing games. Often times this would lead to deadlocks and outright failures.
-
-To resolve this in 2.1.0, the game install process has moved to a two-layer queuing system. The first layer of the queue manages what actually enters the download queue. This could be a game, redistributable, tool, or addon. The second layer handles any subprocessing step such as executing the different script types, save downloading, existing file verification, etc. The result is a system that is more transparent and reliable.
-
-This queuing system has been exposed somewhat in the download queue:
-
-
-
-Each entry in the download queue will now report each step of the process during a game installation. In addition to looking a bit less mystical, this should give users an idea of what _exactly_ is happening during a game installation.
-
-### Big Screen Mode & Gamepad Navigation
-The launcher now supports a big screen mode designed for use with TVs and handhelds. When enabled, the launcher switches to a fullscreen layout optimized for focused gaming. Gamepad support has been added as well. Controllers are detected and mapped using SDL2, and directional navigation has been implemented across the library views, carousels, game detail pages, and overlay dialogs. Focus management handles moving between UI elements naturally with a d-pad or analog stick, making it possible to browse, install, and launch games with a controller. This feature is pretty barebones for now, but adds a foundation for deeper integration in the future.
-
-### Discord Rich Presence
-The launcher now integrates with Discord Rich Presence. When you're playing a game through LANCommander, your Discord status will update to show what you're playing. By default this will show as LANCommander with the game name as the status. If the game is mapped to the correct Discord ID (via external IDs in the server), then the game's official Discord assets will be used for a more polished presentation.
-
-### Offline Mode
-Previous attempts at an offline mode in the launcher were pretty clunky and unreliable. In 2.1.0, the launcher now has a proper offline mode that allows you to launch previously installed games even when the server is unreachable.
-
-### Command Line Interface
-The Avalonia launcher now handles all CLI functionality directly, replacing the previous standalone CLI project. The full set of CLI verbs is supported: `RunScript`, `Install`, `Uninstall`, `Run`, `Sync`, `Import`, `Export`, `Upload`, `Login`, `Logout`, and `ChangeAlias`. The `--help` and `--version` flags are also recognized, and console logging has been added for headless execution.
-
-### Single Sign-On
-SSO was supported in the previous launcher, but it was a bit of an afterthought and not super reliable. In the new launcher it's been given more attention and should work as expected.
-
-### Chat
-As with the last version, the chat functionality remains pretty barebones. This is an area that will get more attention in the future, but it _is_ currently available in the new launcher. It might even work a little better.
-
-## Server Improvements
-This release should also be pretty exciting for server admins. With a new launcher and shiny new features brings a set of tools to manage them.
-
-### LANCommander HQ
-A new metadata provider has been added: LANCommander HQ. This gives server admins another option for sourcing game metadata and media alongside IGDB and SteamGridDB. In addition, support for external IDs beyond IGDB has been introduced, making it easier to cross-reference games across multiple platforms and services. To get started with the HQ, link your Discord account under "Integrations / HQ" in the server settings. The first time setup wizard now includes a step to log in to HQ directly during initial configuration, streamlining the onboarding experience. Existing metadata providers such as IGDB and SteamGridDB are still available as well, so no functionality has been lost from previous versions. We'll cover more about HQ later on in these notes.
-
-### Preview Tool
-A new "Preview" editing view has been added to the server for managing game art. Inspired by the launcher's presentation, this tool gives admins a better sense of how their media will look in the launcher before publishing. Screenshots and videos can now be uploaded in bulk, and the media grabber supports selecting multiple assets at once. Animated covers are now supported as well, APNG images grabbed from SteamGridDB are automatically converted to video and should display in the new launcher.
-
-
-
-### Script Editor Improvements
-The script editor has received a significant upgrade. Context-aware code completions now suggest built-in PowerShell cmdlets, LANCommander-specific variables, and generated type definitions. Validation has been improved, and script errors are now logged rather than silently swallowed. A new `Set-GameDetails` cmdlet has been added for specifying package details from within scripts.
-
-### Variable Picker Dialog
-A new variable picker dialog has been added to the action editor and save path editor. When editing action arguments, working directories, or save paths, a new button opens a dialog that lets you browse and insert LANCommander variables (`{InstallDir}`, `{DisplayWidth}`, etc.), environment variables (`%APPDATA%`, `%LOCALAPPDATA%`, etc.), and special folder paths (`%MyDocuments%`, `%Desktop%`, etc.) with a single click.
-
-### Redistributable Options
-Redistributables now support configurable options. Options can be defined with a schema editor, given display names, and are preserved across import/export. Assigning redistributables to a game can now be accessed under the "Redistributables" section of the game editor. When selected, a redistributable with options will render as an easy-to-use form and per-game overrides can be defined.
-
-
-
-A new **RunWrapper** script type has been added as well, giving admins more control over how redistributables and games are executed. RunWrapper scripts now run alongside the game process with proper state tracking, cancellation support, and child process cleanup when the game exits. This should enhance redistributables to cover more use cases such as emulators, launchers, and compatibility shims like Proton or dgVoodoo.
-
-### Packaging Dialog
-The "Package" button has been moved from individual game, redistributable, and tool edit pages to the archive editor. A new packaging dialog provides a consolidated view of the packaging process, making it easier to build and manage archives directly from the archive list.
-
-### Background Uploading
-Archive uploads now continue in the background, allowing you to navigate away from the upload page without losing progress. An upload indicator in the sidebar tracks active uploads so you can continue working in other areas of the server while files transfer. This also applies to the import upload dialog.
-
-### File Manager Improvements
-The server file manager has received several usability improvements including faster enumeration of large directories, toggleable columns, new Created and Type columns, fixed breadcrumb navigation, and a properly built directory tree with support for cross-platform roots and special folders.
-
-### Key Management
-Key allocation has been improved. The allocation method on keys is now nullable, and a new manual key assignment dialog has been added. The logic for choosing the next available key has also been reworked.
-
-### Media & Tooling
-Server settings now include checks for ffmpeg and yt-dlp availability, with automatic installation support in Docker environments. A new streaming endpoint for media has been added, improving video playback in the launcher. Automatic downloads for Steam screenshots have been added as well. YouTube video support has been added to the media grabber, and ffmpeg is now used for video processing tasks such as APNG conversion and thumbnail generation.
-
-## LANCommander Packager
-A new standalone Avalonia application, LANCommander Packager, has been added for building LCX package files outside of the server. The packager works by monitoring the files and registry entries created by a game's installer. You can then choose which files and registry keys are bundled into the final LCX file. If the tool is authenticated to a LANCommander server instance, the game can be directly uploaded to the server as well.
-
-
-
-## C++ SDK & Legacy Launcher
-
-### C++ SDK
-A new C++ SDK (`LANCommander.SDK.Cpp`) has been introduced. Written in C++14, it provides maximum compatibility from Windows 95 through modern platforms. The SDK includes 16 API clients covering authentication, games, library, saves, media, tools, depot, and more. It ships with two HTTP backend implementations (WinINet for Windows and libcurl for cross-platform) and uses vendored cJSON for zero mandatory external dependencies. This SDK is the foundation that powers the legacy launcher.
-
-### Legacy Launcher
-
-:::note A Fun Detour
-The legacy launcher is a fun side project and detour from the main development track. It should not be regarded as a primary concern or a supported production launcher. Think of it as an experiment in seeing just how far back LANCommander's reach can extend.
-:::
-
-The legacy launcher is a native Win32 application written in C++ that targets Windows 9x (Windows 95/98/ME) and beyond. Built on Allegro 4 for rendering and GDI+ for image decoding (JPEG cover art, backgrounds, etc.), it provides a surprisingly full-featured LANCommander experience on vintage hardware. GDI+ is a requirement and must be available on the target system. For Windows 9x systems, this means installing the [GDI+ redistributable](https://www.microsoft.com/en-us/download/details.aspx?id=18909).
-
-Features include:
-- **Library and Depot browsing** with cover art grid
-- **Game detail view** with metadata and screenshots
-- **Download queue** with progress tracking
-- **Login screen** with server authentication
-- **Settings screen** with configurable server address
-- **SQLite-backed local database** for offline game metadata
-- **Custom window chrome** with a themed UI
-- **Unicode support** for international character sets
-
-
-
-## LANCommander HQ
-Last but far from least, this release coincides with the launch of [LANCommander HQ](https://hq.lancommander.com), a new metadata provider and community hub for LANCommander users. The HQ aims to provide a centralized platform for game metadata and media across multiple providers, while also normalizing and enhancing that data with a focus on the needs of LANCommander users. There's a bit to cover, so let's dive in!
-
-### Metadata
-LANCommander HQ aggregates metadata from multiple sources including IGDB, SteamGridDB, and user contributions. This data is then normalized and enhanced with a focus on the needs of LANCommander users. There are many metadata providers out there, and unfortunately they all have their own unique schemas and data quality issues. The HQ's goal is to provide a single source of truth for game metadata that can be consumed by LANCommander servers and launchers.
-
-There are a lot of exciting features in the works for the HQ, but for this release the focus is on building out the core infrastructure and integrating with the new launcher. Expect to see more features and improvements in the future as the HQ continues to evolve.
-
-### Community
-The main features of the HQ are currently focused on metadata, but there is the potential to expand it into a broader community hub for LANCommander users. This could include an area to share game configurations, scripts, and other resources. Additionally, there are some content moderation tools in place that should allow the HQ to host user-generated content and provide the best possible experience for all users. If you have a knack for curation and want to help out, there will likely be opportunities to get involved in the future!
-
-### Tiering / Subscriptions
-The HQ operates on a tiered subscription model. Currently there are only two tiers: Basic (Free) and Premium ($5/month). The Basic tier provides access to the core metadata features of the HQ, while the Premium tier provides access to additional features such as enhanced metadata, relaxed rate limiting, and more. More information will be provided in the near future and will be provided on this site under the [LANCommander HQ](/hq) section. All paid subscriptions on the HQ are handled by Stripe, so every transaction is secure and your personal information is protected.
-
-This subscription model is primarily in place to help cover the costs of running the HQ while also providing a sustainable path forward for continued development of LANCommander itself. This entire project is developed and maintained by essentially one person with some help from the community here and there. While this is a labor of love, there are real costs associated with running the infrastructure for the HQ and continued development of LANCommander. The subscription model is designed to be as accessible as possible while also providing a way for users to support the project if they find value in it.
-
-As such, this will probably mean that the Patreon page will be going away in the near future. For existing patrons, it is recommended to switch over to the new subscription model on the HQ as it provides a more direct way to support the project and access the benefits of the HQ _today_.
-
-## Contributions
-This release includes contributions from the following community members:
-
-**@aaronpowell | PR [#395](https://github.com/LANCommander/LANCommander/pull/395): Avalonia Launcher**
-A substantial 42-commit, 6,000-line PR that bootstrapped the cross-platform Avalonia launcher. Aaron built out the core application structure, service wiring, and async initialization, and then built out the UI with filtered depot browsing and grid layout, a download queue, install/uninstall flows, a settings page, PDF manual viewer, game action bars, and a full CI pipeline for Windows, Linux, and macOS builds. A lot of the foundation that the current launcher sits on came from this PR.
-
-**@akifreak | PR [#398](https://github.com/LANCommander/LANCommander/pull/398): Fix Game Archive Upload**
-Discovered that archive uploads through the web UI were silently broken after upgrading to v2.0.2, traced the problem through the JavaScript bundle, and submitted 11 targeted fixes rather than a single bug report. The fixes covered ES module loading, null-guards on DOM elements, a FileMode.Append seek conflict, incorrect archive ID passing, and error propagation throughout the upload pipeline. The uploading process should feel much more stable now!
-
-**@akifreak | PR [#403](https://github.com/LANCommander/LANCommander/pull/403): Fix Beacon Discovery**
-Back for another round of contributions, akifreak tracked down and fixed an issue where server discovery wasn't working reliably. In some cases the launcher was actually finding servers on the network but wasn't surfacing them in the UI. The fix spans the full discovery pipeline; making the probe socket more resilient to bad data, ensuring beacon responses actually reach the client, and refreshing the server list as soon as a new server is found.
-
-**@MasterMNB | PR [#400](https://github.com/LANCommander/LANCommander/pull/400): Avalonia Launcher Back Button**
-Improved the visibility and styling of the back button in the `GameDetailView`, which was previously hard to distinguish from the background. It's a small change, but it's a real usability improvement that makes navigation feel more intentional.
-
-## Changelog
-
-**Launcher**
-- Added: Brand new launcher built on Avalonia
-- Added: Screenshots and videos in game detail carousel with lightbox viewer
-- Added: Redesigned depot with categorization carousels and advanced filtering
-- Added: Tools support
-- Added: Download transfer speed graph
-- Added: OS notifications upon game installation completion
-- Added: Discord Rich Presence integration
-- Added: SSO/OIDC external authentication provider support
-- Added: Built-in PDF viewer for game manuals
-- Added: Splash screen on startup
-- Added: Verify files option in game dropdown
-- Added: Markdown rendering for game descriptions
-- Added: Big screen mode for fullscreen TV/controller use
-- Added: Gamepad navigation across library, carousels, detail views, and overlays
-- Added: Full set of CLI verbs (Install, Uninstall, Run, Sync, Import, Export, Upload, Login, Logout, ChangeAlias)
-- Added: Console logging for headless CLI execution
-- Changed: Refactored game/redistributable/tool install process to two-layer queue system
-- Changed: Imports should be faster and execute in the background without causing major UI hiccups
-- Changed: Improved memory management with proper disposal of HTTP clients, RPC clients, and cancellation tokens
-- Changed: Redistributable files are now tracked and cleaned up on uninstall
-- Changed: Redistributables without detect install scripts now always run the install script
-- Fixed: Installing games that already have files on disk will show the verification process during install
-- Fixed: Linux path variable expansion now preserves root prefix
-- Fixed: Script writing for all script types
-- Removed: Standalone CLI project (functionality merged into Avalonia launcher)
-
-**Server**
-- Added: LANCommander HQ metadata provider support
-- Added: Support for external IDs beyond IGDB
-- Added: New launcher-inspired "Preview" tool for managing art
-- Added: Context-aware script editor with code completions, validation, and PowerShell type definitions
-- Added: Redistributable options with schema editor and display names
-- Added: RunWrapper script type with full execution support
-- Added: Package versioning for archives
-- Added: Bulk upload for screenshots and videos
-- Added: Multiple select support in media grabber
-- Added: Animated cover support (APNG auto-conversion to video)
-- Added: Steam screenshot downloads
-- Added: YouTube video support in media grabber
-- Added: Media streaming endpoint
-- Added: ffmpeg/yt-dlp availability checks and auto-install in Docker
-- Added: Manual key assignment dialog and reworked key allocation
-- Added: `Set-GameDetails` cmdlet for package scripts
-- Added: Variable picker dialog for action editor and save path editor
-- Added: Packaging dialog on archive editor
-- Added: RunWrapper script type available for redistributable scripts
-- Added: Background uploading with sidebar progress indicator
-- Added: HQ login step in first time setup wizard
-- Added: Button to download current log file
-- Changed: Moved "Package" button from edit pages to archive editor
-- Changed: Updated PowerShell code snippets
-- Changed: Improved file manager with faster directory enumeration, toggleable columns, and cross-platform directory tree
-- Fixed: Media uploads should now behave as expected
-- Fixed: Game archive uploads in web UI
-- Fixed: Orphaned files tool
-- Fixed: Redistributable archive downloads
-- Fixed: Script working directory for games
-- Fixed: SteamCMD login when SteamGuard is enabled
-- Fixed: Import file cleanup after completion
-- Fixed: Package scripts are no longer exposed through API responses
-- Fixed: Script helper now correctly maps Package and RunWrapper script filenames
-- Fixed: Redistributable and tool importing
-- Fixed: Media settings page failing to render
-- Fixed: HQ metadata lookups
-- Fixed: Log viewer under Settings / Logs (#393)
-- Fixed: User approval menu item (#405)
-- Fixed: Archive processing for "Use Local File"
-- Fixed: "Use Local File" disabled button
-- Fixed: Standalone expansions/mods shown in nested game lists (#284)
-- Fixed: Missing nested game data
-
-**Packager**
-- Added: New standalone application for building LCX package files
-- Added: CI workflow for building and releasing the packager
-
-**Legacy Launcher**
-- Added: Complete native Win32 launcher targeting Windows 9x
-- Added: Library and depot browsing with cover art grid
-- Added: Game detail view with metadata display
-- Added: Download queue with SQLite-backed local database
-- Added: Settings screen with YAML configuration
-- Added: Custom themed window chrome
-- Added: Unicode support for Win9x targets
-- Added: CI workflow for automated builds
-
-**SDK**
-- Added: C++ SDK (LANCommander.SDK.Cpp) with C++14 compatibility
-- Added: WinINet and libcurl HTTP backend implementations
-- Added: 16 API clients covering full LANCommander API surface
-- Added: RunWrapper script execution for redistributables with process lifecycle management
-- Added: Logging in save packaging and upload pipeline
-- Changed: Game list filtering now shows only standalone mods/expansions and main games
-- Changed: Multi-target framework support
-- Changed: Source-generated cmdlet registration via new `CmdletRegistrationGenerator`
-- Fixed: Discovery probe socket resilience against malformed data
-- Fixed: Broadcast send reliability in DiscoveryProbe
-- Fixed: Beacon responses now forwarded to BeaconClient event
-- Fixed: Save client now checks for newer server saves before uploading
-- Fixed: Save path normalization to use forward slashes before regex matching (#266)
-
-**Infrastructure**
-- Upgraded to .NET 10
-- Added ARM64 build support
-- Removed legacy Photino launcher from CI
-- Added nightly builds for Avalonia launcher
-
-## Patch Updates
-
-### 2.1.1
-
-View 2.1.1 patch notes
-
-#### New Features
-
-##### User Registration in Launcher
-User registration has been added back into the launcher's login screen. This was a feature from the old launcher that was missed in the port to Avalonia.
-
-##### Game Update Support
-The Avalonia launcher's update functionality was only partially-implemented. This has been completed and moved over to the new installation workflow system. Additionally, updates for games can be ignored by clicking the dropdown next to the "Update" button and selecting "Play without updating".
-
-#### Improvements
-- Game titles are now dimmed in the library when not installed, making it easier to see what's ready to play at a glance
-- The games list view and depot now display a helpful message when there are no items, instead of showing a blank screen
-- The launcher now downloads media on demand if it doesn't exist locally, and gracefully falls back if LibVLC fails to load
-
-#### Bug Fixes
-- Fixed bundling of LibVLC on macOS and Windows
-- Fixed the display of the empty library view
-- Fixed game updating
-
-
-
-
-
-### 2.1.2
-
-View 2.1.2 patch notes
-
-#### New Features
-
-##### Redistributable Update Checking
-The launcher now checks if installed redistributables are up to date and will re-run them when updates are available on the server. This ensures games always have the correct runtime dependencies.
-
-##### Drag and Drop File Import in Script Editor
-The script editor now supports drag and drop for `.ps1`, `.reg`, `.ini`, and other text files. PowerShell scripts replace the editor contents, `.reg` files are automatically converted to PowerShell commands, and other text files are inserted as escaped strings.
-
-##### Manifest and Script Refresh
-When a game has no update available, the launcher will now refresh the manifest and scripts on disk. This ensures local files stay in sync with server-side changes that don't trigger a full update.
-
-#### Improvements
-- Entire row is now clickable in the compact library list view
-- Items per page selection is now persisted in data tables
-- Improved process termination handling with fallback for Windows and absolute pathing for `kill` on Linux/macOS
-
-#### Bug Fixes
-- Fixed padding in the metadata lookup dialog
-- Fixed HQ token retrieval on the first-time setup and integrations settings pages
-- Fixed local server engine tracking (contributed by @Mavyre)
-
-
-
-
-
-### 2.1.3
-
-View 2.1.3 patch notes
-
-#### New Features
-
-##### Allow Registration Setting
-Servers now have an "Allow Registration" setting to control whether new users can register accounts. Resolves #408.
-
-##### Server Autostop Delay
-Server lifecycle management has been moved to a dedicated `ServerManager`, and servers can now be configured with an autostop delay so they shut down after a period of inactivity. Autostart/autostop for servers should be much more reliable now.
-
-##### Close to System Tray
-The launcher can now be closed to the system tray instead of exiting, keeping it running in the background.
-
-##### Image Optimization Tool
-A new server-side tool optimizes stored images to reduce disk usage and increase performance when displayed in the launcher.
-
-##### Play Session Rework
-Play session tracking has been reworked to be more reliable. Play sessions are now tracked in realtime with the launcher sending keepalives to the server. If a session is detected as stale (e.g. from a crash or improper shutdown), it will be automatically closed and the playtime will be calculated up to that point. This should result in more accurate playtime tracking and prevent issues with sessions that never end.
-
-If, for some reason, a session is improperly created with a suspiciously long duration, a new server-side tool can be used to clean up these sessions and recalculate playtime under Settings > Tools > **Long Play Session**.
-
-##### Working Directory from PCGamingWiki
-Save paths pulled from PCGamingWiki will now attempt to set a working directory automatically.
-
-#### Breaking Changes
-##### MySQL/PostgreSQL Database Engine Reinitialization
-Previous versions of LANCommander had major issues with MySQL/MariaDB and PostgreSQL database engines that would end up softlocking the server when certain operations were performed. This was caused by connections to the database being disposed of prematurely, causing the data access layer to throw exceptions and fail to recover. This has largely been resolved and should result in a much more stable experience using these other database engines.
-
-However, due to support for MySQL/PostgreSQL falling into neglect, many of the database migrations had become out of sync and would fail to apply correctly. To resolve this, all MySQL/PostgreSQL migrations have been regenerated. This will require deployments using MySQL/PostgreSQL to recreate the database. Normally this type of change would be reserved for a major release, but given the state of the MySQL/PostgreSQL support being completely broken, this change is necessary to provide a stable experience for users of these database engines. If you are using MySQL/PostgreSQL, please make sure to back up your data and be prepared to recreate your database when updating to this version.
-
-#### Improvements
-- Performance optimizations for game details were made in the launcher, fixing potentially high CPU usage and RAM allocation with games that have videos or screenshots in the media carousel.
-- Video players in the media carousel now only play when in view and pause when scrolled out of view or when the window is inactive.
-- Updated Notify.NET and fixed taskbar progress reporting
-- Localized remaining time and install progress status
-- Adjusted styling in the game description
-- Removed dead code and fixed video/screenshot loading that could block the UI
-- yt-dlp is now downloaded as a self-contained build removing the dependency on Python for Linux systems.
-
-#### Bug Fixes
-- Fixed updating one-to-many relationships on the server. This should resolve various issues with redistributables, tags, genres, and platforms not saving correctly.
-- Fixed an extra gap on the compact list scrollbar in the launcher
-- Fixed the missing "Starting" text in the play button when a game is launching
-- Fixed an exception when adding a game without a SteamGridDB API key configured
-- Fixed an error thrown during first-time setup when no storage locations exist
-- Regenerated MySQL/MariaDB and PostgreSQL migrations
-- Fixed an issue where the identity database context could be disposed of prematurely, causing various issues with user management and authentication when using other database engines
-- Improved server process termination on Linux by directly calling `kill` via `libc`
-
-
-
-
-
-### 2.1.4
-
-View 2.1.4 patch notes
-
-#### New Features
-
-##### External Auth Provider Enhancements
-External authentication providers received a major overhaul. The provider editor now supports auto-discovery of scopes and claim mappings, and claims can be mapped to roles so users are automatically assigned the correct roles when logging in over external auth. Users logging in through an external provider are also auto-provisioned.
-
-##### Auto Redirect to External Provider
-A new "Auto Redirect to Provider" setting switches authentication challenges to redirect straight to your external auth provider instead of showing the password login form. If more than one external provider is configured, a minimal provider selection page is shown instead.
-
-##### Repack Non-Streamable ZIP Archives
-A new server-side tool in the archive editor can now be used to repack archives that were not created in a streamable format. More information about why this is an issue can be found under the [Archives](/Server/Archives) documentation page.
-
-##### WebP Media Support
-Media uploads now support the WebP image format.
-
-##### Redistributable Uninstall Scripts
-Redistributables can now define an uninstall script, allowing their runtime dependencies to be cleanly removed.
-
-##### Single Instance Launcher
-The launcher now ensures only a single instance can run at a time, preventing conflicts from accidentally opening multiple copies.
-
-#### Improvements
-- Local files selected for archive upload can now be moved instead of copied, saving disk space.
-- Reworked tool installation and action resolution.
-- Install notifications now only display once the entire install chain is complete.
-- The "Last Played" text now updates every minute and has been localized.
-- Added more logging to archive extraction and made the number of install retry attempts configurable.
-- A message is now shown indicating the server needs to be restarted after changes are made to authentication providers.
-- Game started/stopped server scripts now run as fire and forget and will not prevent servers from starting.
-- Adjusted styling for buttons and dropdowns in the launcher.
-- Updated SharpCompress to 0.49.1.
-- macOS builds now generate a proper `.app` bundle, and Linux builds now produce an AppImage bundled into the normal workflows.
-
-#### Bug Fixes
-- Fixed some situations where saves were not being uploaded/downloaded.
-- Fixed an error in the launcher when tool actions were not defined.
-- Fixed server game actions not loading correctly from the server.
-- Fixed non-standalone addons appearing in the library compact list.
-- HQ authentication errors are no longer swallowed.
-- Fixed user promotion / role assignment.
-- Fixed application icons.
-
-
-
-
-
-### 2.1.5
-
-View 2.1.5 patch notes
-
-#### New Features
-
-##### Auto Redirect to External Provider in the Launcher
-The launcher now honors the server's "Auto Redirect to Provider" setting. When enabled, the login screen redirects straight to your external authentication provider, and the standard authentication fields and buttons are hidden based on the server's authentication settings.
-
-##### Exit Button in Profile Dropdown
-An "Exit" button is now available under the profile dropdown, making it easier to fully quit the launcher.
-
-#### Improvements
-- Consolidated the game flyout into a unified context menu for more consistent actions across the library views.
-- Tools that have no archive are now hidden from the install overlay.
-- Automated testing for various aspects of the server UI (thanks [aaronpowell](https://github.com/aaronpowell)!)
-
-#### Bug Fixes
-- Fixed removing games from user libraries using the launcher.
-- Fixed tool installation.
-- Fixed the clickability of games in the compact library list.
-- Fixed updating of roles.
-- Fixed saving of authentication settings.
-- Fixed admin user creation in first time setup.
-
-
-
-
-
-### 2.1.6
-
-View 2.1.6 patch notes
-
-#### New Features
-
-##### User and Role Limits
-Roles and individual users can now be assigned limits for save storage, total user storage, and download speed. This gives admins finer control over resource usage on shared servers. Resolves [#85](https://github.com/LANCommander/LANCommander/issues/85), [#84](https://github.com/LANCommander/LANCommander/issues/84), and [#100](https://github.com/LANCommander/LANCommander/issues/).
-
-##### PowerShell Modules
-A new "Scripting" section has been added to the server UI where PowerShell modules can be defined. These act as a library of reusable functions that can be called from any script. Modules are automatically synced to the launcher and imported when scripts are executed.
-
-##### Per-Game Tool Tracking
-Tools are now tracked on a per-game basis. Tools can be marked to "Always Install", and any tools installed for a game are automatically uninstalled when that game is uninstalled.
-
-##### Admin User Creation Dialog
-Admins can now create new users directly from the server with a new user creation dialog. Fixes [#419](https://github.com/LANCommander/LANCommander/issues/419).
-
-##### Database Connection Editor
-The connection string input used in first-time setup and server settings has been refactored into a full database connection component, making it easier to configure database connections. Fixes [#256](https://github.com/LANCommander/LANCommander/issues/256).
-
-#### Improvements
-- Added "Select All" and "Load More" buttons to the media grabber. Fixes [#417](https://github.com/LANCommander/LANCommander/issues/417).
-- Metadata lookups now preserve existing values instead of overwriting them. Fixes [#420](https://github.com/LANCommander/LANCommander/issues/420).
-- The Library navigation and library/depot switcher are now hidden in both the web UI and launcher when user libraries are disabled.
-- Action `ServerHost` values now default to the LANCommander server address.
-
-#### Bug Fixes
-- Fixed the display of UTC times.
-- Fixed creation of entities with many-to-many relationships. Ref [#414](https://github.com/LANCommander/LANCommander/issues/414).
-- Fixed reconciliation of library games based on whether user libraries are enabled or disabled. Ref [#423](https://github.com/LANCommander/LANCommander/issues/423).
-- The profile cache is now invalidated when logging out. Fixes [#422](https://github.com/LANCommander/LANCommander/issues/422).
-
-
-
-
-
-### 2.1.7
-
-View 2.1.7 patch notes
-
-#### Improvements
-- The launcher now loads your library from a new `/Library/Games` endpoint and displays it immediately, with images rendered directly from the server. The full import still runs in the background, so offline functionality is preserved while the library becomes usable much sooner.
-- Depot images are now streamed from a remote image cache instead of being downloaded to disk, reducing UI jank. Fixes [#427](https://github.com/LANCommander/LANCommander/issues/427).
-- Game installs and uninstalls are now more reliable: installs run in a consistent order (base game, addons, tools, then redistributables), tools are removed before their game is uninstalled, and the game action bar refreshes after a tool is installed. Ref [#414](https://github.com/LANCommander/LANCommander/issues/414).
-- The modify menu now lists installed addons.
-- Save upload failures are now logged instead of failing silently.
-- A running game process is now terminated as a fallback when no spawned window is detected.
-- View data is now loaded before transitioning, smoothing navigation.
-
-#### Bug Fixes
-- Save path changes on a game now take effect immediately instead of requiring a server restart. Games are correctly updated when a save path is added, updated, or deleted.
-- Fixed installation of games and addons that have no dependent games.
-- Fixed importing legacy LCX files that have no save paths defined. Fixes [#426](https://github.com/LANCommander/LANCommander/issues/426).
-- Fixed persisted page sizes in data tables.
-
-
-
-
-
-### 2.1.8
-
-View 2.1.8 patch notes
-
-#### New Features
-
-##### Runtime Platform Targeting
-Actions, scripts, and save paths can now be scoped to a specific runtime platform (Windows, Linux, or macOS). This makes it possible to define platform-specific launch actions, install/uninstall scripts, and save paths on a single game without them conflicting across operating systems. A new `Get-Runtime` PowerShell cmdlet is also available for detecting the current platform from within scripts.
-
-#### Improvements
-- The offline mode button has been moved to the titlebar for easier access.
-- The game description editor in the server UI is now a full Markdown editor.
-- Page functionality has been improved with better menus for adding and deleting pages, and slugs are now generated in PascalCase.
-- Client/server version mismatches are now detected and logged, making it easier to diagnose connection issues caused by version drift.
-- Data table sort order is now persisted.
-
-#### Bug Fixes
-- Images now load from the local cache when the launcher is in offline mode.
-- Fixed enumeration of script snippets.
-- Fixed PowerShell module loading in the launcher. On Windows the execution policy is set to bypass, and module load failures are now trapped and reported as a warning per module instead of failing the entire script.
-
-
-
-
-
-### 2.1.9
-
-View 2.1.9 patch notes
-
-#### Improvements
-- Path resolution has been unified across the server so that storage paths are resolved consistently everywhere. A migration aligns existing settings storage paths automatically on upgrade.
-- Depot queries have been optimized for better performance.
-- Server notifications now use a shorter timeout so a slow or unreachable server no longer holds up the launcher.
-- Updated SharpCompress to the latest version. This should resolve most extraction issues for games with large archives.
-
-#### Bug Fixes
-- Fixed detection of the primary display's resolution on some Linux multi-display configurations.
-- Fixed application path resolution on the server, correcting how saves, media, archives, and updates are located.
-- Improved handling of the bypass execution policy for scripts.
-- Fixed installation of wine32 and winetricks.
-
-
-
-
-
-## Downloads
-
-
-
-
-View 2.1.8 downloads
-
-
-
-
-
-
-View 2.1.7 downloads
-
-
-
-
-
-
-View 2.1.6 downloads
-
-
-
-
-
-
-View 2.1.5 downloads
-
-
-
-
-
-
-View 2.1.4 downloads
-
-
-
-
-
-
-View 2.1.3 downloads
-
-
-
-
-
-
-View 2.1.2 downloads
-
-
-
-
-
-
-View 2.1.1 downloads
-
-
-
-
-
-
-View 2.1.0 downloads
-
-
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.1.mdx b/LANCommander.Documentation/Releases/2.1.1.mdx
deleted file mode 100644
index 011dba89..00000000
--- a/LANCommander.Documentation/Releases/2.1.1.mdx
+++ /dev/null
@@ -1,38 +0,0 @@
----
-title: 2.1.1
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.1 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## New Features
-
-### User Registration in Launcher
-User registration has been added back into the launcher's login screen. This was a feature from the old launcher that was missed in the port to Avalonia.
-
-### Game Update Support
-The Avalonia launcher's update functionality was only partially-implemented. This has been completed and moved over to the new installation workflow system. Additionally, updates for games can be ignored by clicking the dropdown next to the "Update" button and selecting "Play without updating".
-
-## Improvements
-- Game titles are now dimmed in the library when not installed, making it easier to see what's ready to play at a glance
-- The games list view and depot now display a helpful message when there are no items, instead of showing a blank screen
-- The launcher now downloads media on demand if it doesn't exist locally, and gracefully falls back if LibVLC fails to load
-
-## Bug Fixes
-- Fixed bundling of LibVLC on macOS and Windows
-- Fixed the display of the empty library view
-- Fixed game updating
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.2.mdx b/LANCommander.Documentation/Releases/2.1.2.mdx
deleted file mode 100644
index ca29a157..00000000
--- a/LANCommander.Documentation/Releases/2.1.2.mdx
+++ /dev/null
@@ -1,41 +0,0 @@
----
-title: 2.1.2
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.2 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## New Features
-
-### Redistributable Update Checking
-The launcher now checks if installed redistributables are up to date and will re-run them when updates are available on the server. This ensures games always have the correct runtime dependencies.
-
-### Drag and Drop File Import in Script Editor
-The script editor now supports drag and drop for `.ps1`, `.reg`, `.ini`, and other text files. PowerShell scripts replace the editor contents, `.reg` files are automatically converted to PowerShell commands, and other text files are inserted as escaped strings.
-
-### Manifest and Script Refresh
-When a game has no update available, the launcher will now refresh the manifest and scripts on disk. This ensures local files stay in sync with server-side changes that don't trigger a full update.
-
-## Improvements
-- Entire row is now clickable in the compact library list view
-- Items per page selection is now persisted in data tables
-- Improved process termination handling with fallback for Windows and absolute pathing for `kill` on Linux/macOS
-
-## Bug Fixes
-- Fixed padding in the metadata lookup dialog
-- Fixed HQ token retrieval on the first-time setup and integrations settings pages
-- Fixed local server engine tracking (contributed by @Mavyre)
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.3.mdx b/LANCommander.Documentation/Releases/2.1.3.mdx
deleted file mode 100644
index 85c0bb7b..00000000
--- a/LANCommander.Documentation/Releases/2.1.3.mdx
+++ /dev/null
@@ -1,67 +0,0 @@
----
-title: 2.1.3
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.3 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## New Features
-
-### Allow Registration Setting
-Servers now have an "Allow Registration" setting to control whether new users can register accounts. Resolves #408.
-
-### Server Autostop Delay
-Server lifecycle management has been moved to a dedicated `ServerManager`, and servers can now be configured with an autostop delay so they shut down after a period of inactivity. Autostart/autostop for servers should be much more reliable now.
-
-### Close to System Tray
-The launcher can now be closed to the system tray instead of exiting, keeping it running in the background.
-
-### Image Optimization Tool
-A new server-side tool optimizes stored images to reduce disk usage and increase performance when displayed in the launcher.
-
-### Play Session Rework
-Play session tracking has been reworked to be more reliable. Play sessions are now tracked in realtime with the launcher sending keepalives to the server. If a session is detected as stale (e.g. from a crash or improper shutdown), it will be automatically closed and the playtime will be calculated up to that point. This should result in more accurate playtime tracking and prevent issues with sessions that never end.
-
-If, for some reason, a session is improperly created with a suspiciously long duration, a new server-side tool can be used to clean up these sessions and recalculate playtime under Settings > Tools > **Long Play Session**.
-
-### Working Directory from PCGamingWiki
-Save paths pulled from PCGamingWiki will now attempt to set a working directory automatically.
-
-## Breaking Changes
-### MySQL/PostgreSQL Database Engine Reinitialization
-Previous versions of LANCommander had major issues with MySQL/MariaDB and PostgreSQL database engines that would end up softlocking the server when certain operations were performed. This was caused by connections to the database being disposed of prematurely, causing the data access layer to throw exceptions and fail to recover. This has largely been resolved and should result in a much more stable experience using these other database engines.
-
-However, due to support for MySQL/PostgreSQL falling into neglect, many of the database migrations had become out of sync and would fail to apply correctly. To resolve this, all MySQL/PostgreSQL migrations have been regenerated. This will require deployments using MySQL/PostgreSQL to recreate the database. Normally this type of change would be reserved for a major release, but given the state of the MySQL/PostgreSQL support being completely broken, this change is necessary to provide a stable experience for users of these database engines. If you are using MySQL/PostgreSQL, please make sure to back up your data and be prepared to recreate your database when updating to this version.
-
-## Improvements
-- Performance optimizations for game details were made in the launcher, fixing potentially high CPU usage and RAM allocation with games that have videos or screenshots in the media carousel.
-- Video players in the media carousel now only play when in view and pause when scrolled out of view or when the window is inactive.
-- Updated Notify.NET and fixed taskbar progress reporting
-- Localized remaining time and install progress status
-- Adjusted styling in the game description
-- Removed dead code and fixed video/screenshot loading that could block the UI
-- yt-dlp is now downloaded as a self-contained build removing the dependency on Python for Linux systems.
-
-## Bug Fixes
-- Fixed updating one-to-many relationships on the server. This should resolve various issues with redistributables, tags, genres, and platforms not saving correctly.
-- Fixed an extra gap on the compact list scrollbar in the launcher
-- Fixed the missing "Starting" text in the play button when a game is launching
-- Fixed an exception when adding a game without a SteamGridDB API key configured
-- Fixed an error thrown during first-time setup when no storage locations exist
-- Regenerated MySQL/MariaDB and PostgreSQL migrations
-- Fixed an issue where the identity database context could be disposed of prematurely, causing various issues with user management and authentication when using other database engines
-- Improved server process termination on Linux by directly calling `kill` via `libc`
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.4.mdx b/LANCommander.Documentation/Releases/2.1.4.mdx
deleted file mode 100644
index 5a8f0d97..00000000
--- a/LANCommander.Documentation/Releases/2.1.4.mdx
+++ /dev/null
@@ -1,61 +0,0 @@
----
-title: 2.1.4
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.4 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## New Features
-
-### External Auth Provider Enhancements
-External authentication providers received a major overhaul. The provider editor now supports auto-discovery of scopes and claim mappings, and claims can be mapped to roles so users are automatically assigned the correct roles when logging in over external auth. Users logging in through an external provider are also auto-provisioned.
-
-### Auto Redirect to External Provider
-A new "Auto Redirect to Provider" setting switches authentication challenges to redirect straight to your external auth provider instead of showing the password login form. If more than one external provider is configured, a minimal provider selection page is shown instead.
-
-### Repack Non-Streamable ZIP Archives
-A new server-side tool in the archive editor can now be used to repack archives that were not created in a streamable format. More information about why this is an issue can be found under the [Archives](/Server/Archives) documentation page.
-
-### WebP Media Support
-Media uploads now support the WebP image format.
-
-### Redistributable Uninstall Scripts
-Redistributables can now define an uninstall script, allowing their runtime dependencies to be cleanly removed.
-
-### Single Instance Launcher
-The launcher now ensures only a single instance can run at a time, preventing conflicts from accidentally opening multiple copies.
-
-## Improvements
-- Local files selected for archive upload can now be moved instead of copied, saving disk space.
-- Reworked tool installation and action resolution.
-- Install notifications now only display once the entire install chain is complete.
-- The "Last Played" text now updates every minute and has been localized.
-- Added more logging to archive extraction and made the number of install retry attempts configurable.
-- A message is now shown indicating the server needs to be restarted after changes are made to authentication providers.
-- Game started/stopped server scripts now run as fire and forget and will not prevent servers from starting.
-- Adjusted styling for buttons and dropdowns in the launcher.
-- Updated SharpCompress to 0.49.1.
-- macOS builds now generate a proper `.app` bundle, and Linux builds now produce an AppImage bundled into the normal workflows.
-
-## Bug Fixes
-- Fixed some situations where saves were not being uploaded/downloaded.
-- Fixed an error in the launcher when tool actions were not defined.
-- Fixed server game actions not loading correctly from the server.
-- Fixed non-standalone addons appearing in the library compact list.
-- HQ authentication errors are no longer swallowed.
-- Fixed user promotion / role assignment.
-- Fixed application icons.
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.5.mdx b/LANCommander.Documentation/Releases/2.1.5.mdx
deleted file mode 100644
index e362205e..00000000
--- a/LANCommander.Documentation/Releases/2.1.5.mdx
+++ /dev/null
@@ -1,41 +0,0 @@
----
-title: 2.1.5
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.5 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## New Features
-
-### Auto Redirect to External Provider in the Launcher
-The launcher now honors the server's "Auto Redirect to Provider" setting. When enabled, the login screen redirects straight to your external authentication provider, and the standard authentication fields and buttons are hidden based on the server's authentication settings.
-
-### Exit Button in Profile Dropdown
-An "Exit" button is now available under the profile dropdown, making it easier to fully quit the launcher.
-
-## Improvements
-- Consolidated the game flyout into a unified context menu for more consistent actions across the library views.
-- Tools that have no archive are now hidden from the install overlay.
-- Automated testing for various aspects of the server UI (thanks [aaronpowell](https://github.com/aaronpowell)!)
-
-## Bug Fixes
-- Fixed removing games from user libraries using the launcher.
-- Fixed tool installation.
-- Fixed the clickability of games in the compact library list.
-- Fixed updating of roles.
-- Fixed saving of authentication settings.
-- Fixed admin user creation in first time setup.
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.6.mdx b/LANCommander.Documentation/Releases/2.1.6.mdx
deleted file mode 100644
index cb574a04..00000000
--- a/LANCommander.Documentation/Releases/2.1.6.mdx
+++ /dev/null
@@ -1,49 +0,0 @@
----
-title: 2.1.6
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.6 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## New Features
-
-### User and Role Limits
-Roles and individual users can now be assigned limits for save storage, total user storage, and download speed. This gives admins finer control over resource usage on shared servers. Resolves [#85](https://github.com/LANCommander/LANCommander/issues/85), [#84](https://github.com/LANCommander/LANCommander/issues/84), and [#100](https://github.com/LANCommander/LANCommander/issues/).
-
-### PowerShell Modules
-A new "Scripting" section has been added to the server UI where PowerShell modules can be defined. These act as a library of reusable functions that can be called from any script. Modules are automatically synced to the launcher and imported when scripts are executed.
-
-### Per-Game Tool Tracking
-Tools are now tracked on a per-game basis. Tools can be marked to "Always Install", and any tools installed for a game are automatically uninstalled when that game is uninstalled.
-
-### Admin User Creation Dialog
-Admins can now create new users directly from the server with a new user creation dialog. Fixes [#419](https://github.com/LANCommander/LANCommander/issues/419).
-
-### Database Connection Editor
-The connection string input used in first-time setup and server settings has been refactored into a full database connection component, making it easier to configure database connections. Fixes [#256](https://github.com/LANCommander/LANCommander/issues/256).
-
-## Improvements
-- Added "Select All" and "Load More" buttons to the media grabber. Fixes [#417](https://github.com/LANCommander/LANCommander/issues/417).
-- Metadata lookups now preserve existing values instead of overwriting them. Fixes [#420](https://github.com/LANCommander/LANCommander/issues/420).
-- The Library navigation and library/depot switcher are now hidden in both the web UI and launcher when user libraries are disabled.
-- Action `ServerHost` values now default to the LANCommander server address.
-
-## Bug Fixes
-- Fixed the display of UTC times.
-- Fixed creation of entities with many-to-many relationships. Ref [#414](https://github.com/LANCommander/LANCommander/issues/414).
-- Fixed reconciliation of library games based on whether user libraries are enabled or disabled. Ref [#423](https://github.com/LANCommander/LANCommander/issues/423).
-- The profile cache is now invalidated when logging out. Fixes [#422](https://github.com/LANCommander/LANCommander/issues/422).
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.7.mdx b/LANCommander.Documentation/Releases/2.1.7.mdx
deleted file mode 100644
index a1a2918f..00000000
--- a/LANCommander.Documentation/Releases/2.1.7.mdx
+++ /dev/null
@@ -1,35 +0,0 @@
----
-title: 2.1.7
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.7 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## Improvements
-- The launcher now loads your library from a new `/Library/Games` endpoint and displays it immediately, with images rendered directly from the server. The full import still runs in the background, so offline functionality is preserved while the library becomes usable much sooner.
-- Depot images are now streamed from a remote image cache instead of being downloaded to disk, reducing UI jank. Fixes [#427](https://github.com/LANCommander/LANCommander/issues/427).
-- Game installs and uninstalls are now more reliable: installs run in a consistent order (base game, addons, tools, then redistributables), tools are removed before their game is uninstalled, and the game action bar refreshes after a tool is installed. Ref [#414](https://github.com/LANCommander/LANCommander/issues/414).
-- The modify menu now lists installed addons.
-- Save upload failures are now logged instead of failing silently.
-- A running game process is now terminated as a fallback when no spawned window is detected.
-- View data is now loaded before transitioning, smoothing navigation.
-
-## Bug Fixes
-- Save path changes on a game now take effect immediately instead of requiring a server restart. Games are correctly updated when a save path is added, updated, or deleted.
-- Fixed installation of games and addons that have no dependent games.
-- Fixed importing legacy LCX files that have no save paths defined. Fixes [#426](https://github.com/LANCommander/LANCommander/issues/426).
-- Fixed persisted page sizes in data tables.
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.8.mdx b/LANCommander.Documentation/Releases/2.1.8.mdx
deleted file mode 100644
index 0f58aaff..00000000
--- a/LANCommander.Documentation/Releases/2.1.8.mdx
+++ /dev/null
@@ -1,37 +0,0 @@
----
-title: 2.1.8
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.8 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## New Features
-
-### Runtime Platform Targeting
-Actions, scripts, and save paths can now be scoped to a specific runtime platform (Windows, Linux, or macOS). This makes it possible to define platform-specific launch actions, install/uninstall scripts, and save paths on a single game without them conflicting across operating systems. A new `Get-Runtime` PowerShell cmdlet is also available for detecting the current platform from within scripts.
-
-## Improvements
-- The offline mode button has been moved to the titlebar for easier access.
-- The game description editor in the server UI is now a full Markdown editor.
-- Page functionality has been improved with better menus for adding and deleting pages, and slugs are now generated in PascalCase.
-- Client/server version mismatches are now detected and logged, making it easier to diagnose connection issues caused by version drift.
-- Data table sort order is now persisted.
-
-## Bug Fixes
-- Images now load from the local cache when the launcher is in offline mode.
-- Fixed enumeration of script snippets.
-- Fixed PowerShell module loading in the launcher. On Windows the execution policy is set to bypass, and module load failures are now trapped and reported as a warning per module instead of failing the entire script.
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/2.1.9.mdx b/LANCommander.Documentation/Releases/2.1.9.mdx
deleted file mode 100644
index cf305a44..00000000
--- a/LANCommander.Documentation/Releases/2.1.9.mdx
+++ /dev/null
@@ -1,32 +0,0 @@
----
-title: 2.1.9
----
-
-import ReleaseDownloads from '@site/src/components/ReleaseDownloads';
-import ContributorGrid from '@site/src/components/ContributorGrid';
-
-# LANCommander 2.1.9 Release Notes
-
-:::info Full Release Notes
-This is a patch release for the **2.1 series**. For the complete feature overview and all patch notes, visit the [LANCommander 2.1.0 release notes](./2.1.0).
-:::
-
-## Improvements
-- Path resolution has been unified across the server so that storage paths are resolved consistently everywhere. A migration aligns existing settings storage paths automatically on upgrade.
-- Depot queries have been optimized for better performance.
-- Server notifications now use a shorter timeout so a slow or unreachable server no longer holds up the launcher.
-- Updated SharpCompress to the latest version. This should resolve most extraction issues for games with large archives.
-
-## Bug Fixes
-- Fixed detection of the primary display's resolution on some Linux multi-display configurations.
-- Fixed application path resolution on the server, correcting how saves, media, archives, and updates are located.
-- Improved handling of the bypass execution policy for scripts.
-- Fixed installation of wine32 and winetricks.
-
-## Downloads
-
-
-
-## Contributors
-
-
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Depot.jpg b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Depot.jpg
deleted file mode 100644
index adb82e85..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Depot.jpg and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Downloads.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Downloads.png
deleted file mode 100644
index 8ef444e1..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Downloads.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Launcher.jpg b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Launcher.jpg
deleted file mode 100644
index df6514be..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Launcher.jpg and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Generate Package.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Generate Package.png
deleted file mode 100644
index 3f7ba703..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Generate Package.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Metadata Lookup.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Metadata Lookup.png
deleted file mode 100644
index a5e53ade..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Metadata Lookup.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Metadata.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Metadata.png
deleted file mode 100644
index 7c0b2ffd..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Metadata.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Monitor.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Monitor.png
deleted file mode 100644
index 3b20fd99..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Monitor.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Registry.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Registry.png
deleted file mode 100644
index f928fdfb..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Registry.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Select Executable.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Select Executable.png
deleted file mode 100644
index eca49ec8..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Select Executable.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Select Files.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Select Files.png
deleted file mode 100644
index 0ddfdfa0..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Packager - Select Files.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Screenshot.jpg b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Screenshot.jpg
deleted file mode 100644
index 65b9690a..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Screenshot.jpg and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Server - Preview.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Server - Preview.png
deleted file mode 100644
index 35ffb746..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Server - Preview.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Server - Redistributable Options.png b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Server - Redistributable Options.png
deleted file mode 100644
index c26ca14e..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Server - Redistributable Options.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Shelf.jpg b/LANCommander.Documentation/Releases/_Assets/2.1.0 - Shelf.jpg
deleted file mode 100644
index cb3a2328..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0 - Shelf.jpg and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Detail.png b/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Detail.png
deleted file mode 100644
index 77391070..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Detail.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Downloads.png b/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Downloads.png
deleted file mode 100644
index 04ca0506..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Downloads.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Library.png b/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Library.png
deleted file mode 100644
index 9cc07a40..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Library.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Login.png b/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Login.png
deleted file mode 100644
index 0870933c..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Login.png and /dev/null differ
diff --git a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Settings.png b/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Settings.png
deleted file mode 100644
index a6b26a03..00000000
Binary files a/LANCommander.Documentation/Releases/_Assets/2.1.0-rc2 - Legacy Launcher - Settings.png and /dev/null differ
diff --git a/LANCommander.Documentation/Scripting/Cmdlets.md b/LANCommander.Documentation/Scripting/Cmdlets.md
index 0c66c08e..bbdcb584 100644
--- a/LANCommander.Documentation/Scripting/Cmdlets.md
+++ b/LANCommander.Documentation/Scripting/Cmdlets.md
@@ -200,36 +200,6 @@ This cmdlet can be useful if you have a game that might require a persistent ID
Get-UserCustomField -Name "SteamId"
```
-## `Get-RedistributableOptions`
-Retrieves the resolved options for a redistributable assigned to a game.
-
-### Syntax
-```powershell
-Get-RedistributableOptions
- -Path
- -Id
- -Name
-```
-
-### Description
-The `Get-RedistributableOptions` cmdlet reads the game manifest and returns the resolved option values for the specified redistributable as a nested `PSObject`. Options are resolved in order: schema defaults, then per-game configured values. Nested options in the schema are represented as nested properties on the returned object.
-
-This cmdlet is useful in [Install](/Scripting/Script Types/Install), [Before Start](/Scripting/Script Types/Before Start), and [Run Wrapper](/Scripting/Script Types/Run Wrapper) scripts where you need to access compatibility shim configuration.
-
-### Example
-```powershell
-$options = Get-RedistributableOptions -Path $InstallDirectory -Id $GameManifest.Id -Name "umu-launcher"
-
-# Access nested options
-Write-Host $options.Game.GAMEID # "umu-default" or admin-configured value
-Write-Host $options.Proton.PROTONPATH # "GE-Proton" or admin-configured value
-
-# Use in a script
-if ($options.Proton.PROTONPATH -eq "GE-Proton") {
- Write-Host "Using default Proton version"
-}
-```
-
## `Update-UserCustomField`
Updates the value of a custom field on a users profile.
@@ -248,216 +218,6 @@ The companion to `Get-UserCustomField`, this cmdlet lets you update or set the v
Update-UserCustomField -Name "SteamId" -Value "34950494"
```
-## `ConvertFrom-SerializedBase64`
-Deserializes a Base64-encoded YAML string back into an object.
-
-### Syntax
-```powershell
-ConvertFrom-SerializedBase64
- -Input
-```
-
-### Description
-The `ConvertFrom-SerializedBase64` cmdlet takes a Base64-encoded string containing YAML-serialized data, decodes it, and deserializes it back into a PowerShell object. This is the companion to `ConvertTo-SerializedBase64` and is useful for reading data that was previously serialized and encoded for storage or transport. Accepts pipeline input.
-
-### Example
-```powershell
-$data = ConvertFrom-SerializedBase64 -Input "TmFtZTogSGVsbG8="
-
-# Or via pipeline
-"TmFtZTogSGVsbG8=" | ConvertFrom-SerializedBase64
-```
-
-## `ConvertTo-SerializedBase64`
-Serializes an object to YAML and encodes it as a Base64 string.
-
-### Syntax
-```powershell
-ConvertTo-SerializedBase64
- -Input
-
+
diff --git a/LANCommander.Launcher.Avalonia.Tests/TestApp.axaml b/LANCommander.Launcher.Avalonia.Tests/TestApp.axaml
new file mode 100644
index 00000000..cb8b9d98
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia.Tests/TestApp.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/LANCommander.Launcher.Tests/TestApp.axaml.cs b/LANCommander.Launcher.Avalonia.Tests/TestApp.axaml.cs
similarity index 79%
rename from LANCommander.Launcher.Tests/TestApp.axaml.cs
rename to LANCommander.Launcher.Avalonia.Tests/TestApp.axaml.cs
index b132c9de..230f8499 100644
--- a/LANCommander.Launcher.Tests/TestApp.axaml.cs
+++ b/LANCommander.Launcher.Avalonia.Tests/TestApp.axaml.cs
@@ -1,7 +1,7 @@
using Avalonia;
using Avalonia.Markup.Xaml;
-namespace LANCommander.Launcher.Tests;
+namespace LANCommander.Launcher.Avalonia.Tests;
public partial class TestApp : Application
{
diff --git a/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs b/LANCommander.Launcher.Avalonia.Tests/Tests/ViewLayoutTests.cs
similarity index 91%
rename from LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs
rename to LANCommander.Launcher.Avalonia.Tests/Tests/ViewLayoutTests.cs
index 9e6979c4..4b9da319 100644
--- a/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs
+++ b/LANCommander.Launcher.Avalonia.Tests/Tests/ViewLayoutTests.cs
@@ -3,17 +3,16 @@ using System.Collections.ObjectModel;
using System.IO;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
-using LANCommander.Launcher.Services;
-using LANCommander.Launcher.Tests.Helpers;
-using LANCommander.Launcher.ViewModels;
-using LANCommander.Launcher.ViewModels.Components;
-using LANCommander.Launcher.Views;
-using LANCommander.Launcher.Views.Components;
+using LANCommander.Launcher.Avalonia.Tests.Helpers;
+using LANCommander.Launcher.Avalonia.ViewModels;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
+using LANCommander.Launcher.Avalonia.Views;
+using LANCommander.Launcher.Avalonia.Views.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Xunit;
-namespace LANCommander.Launcher.Tests.Tests;
+namespace LANCommander.Launcher.Avalonia.Tests.Tests;
///
/// Renders each major view in the headless Avalonia environment and compares the
@@ -32,23 +31,12 @@ public class ViewLayoutTests
private const int WindowWidth = 1200;
private const int WindowHeight = 800;
- static ViewLayoutTests()
- {
- // The Login, Splash and ServerSelection views pick a random full-screen
- // background on load. Disable that here so the captured screenshots — and the
- // committed baselines — are deterministic; otherwise every run compares against
- // a different photo and reports a spurious regression.
- ViewBackground.Enabled = false;
- }
-
// ---------------------------------------------------------------------------
// Service provider shared by all tests that need ViewModels with DI dependencies.
- // Minimal: logging plus navigation — GameDetailViewModel resolves INavigationService
- // in its constructor. No real SDK services needed for layout-only rendering.
+ // Minimal: just logging — no real SDK services needed for layout-only rendering.
// ---------------------------------------------------------------------------
private static readonly IServiceProvider _testServices = new ServiceCollection()
.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Warning))
- .AddSingleton()
.BuildServiceProvider();
// ---------------------------------------------------------------------------
diff --git a/LANCommander.Launcher/App.axaml b/LANCommander.Launcher.Avalonia/App.axaml
similarity index 53%
rename from LANCommander.Launcher/App.axaml
rename to LANCommander.Launcher.Avalonia/App.axaml
index a3733655..45d924e1 100644
--- a/LANCommander.Launcher/App.axaml
+++ b/LANCommander.Launcher.Avalonia/App.axaml
@@ -1,15 +1,15 @@
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -526,17 +516,6 @@
-
-
-
-
-
-
-
-
@@ -568,28 +547,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/LANCommander.Launcher/ViewModels/ChatWindowViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/ChatWindowViewModel.cs
similarity index 97%
rename from LANCommander.Launcher/ViewModels/ChatWindowViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/ChatWindowViewModel.cs
index 467ca478..87e89528 100644
--- a/LANCommander.Launcher/ViewModels/ChatWindowViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/ChatWindowViewModel.cs
@@ -6,14 +6,14 @@ using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.Services;
+using LANCommander.Launcher.Avalonia.Services;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Models;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-namespace LANCommander.Launcher.ViewModels;
+namespace LANCommander.Launcher.Avalonia.ViewModels;
public partial class ChatWindowViewModel : ViewModelBase
{
@@ -176,7 +176,6 @@ public partial class ChatWindowViewModel : ViewModelBase
partial void OnSelectedThreadChanged(ChatThreadViewModel? oldValue, ChatThreadViewModel? newValue)
{
SendMessageCommand.NotifyCanExecuteChanged();
-
if (newValue != null)
{
_ = MarkThreadReadAsync(newValue);
@@ -198,7 +197,9 @@ public partial class ChatWindowViewModel : ViewModelBase
var response = await _chatClient.GetMessagesAsync(threadVm.Thread.Id, null, 50);
if (response.Items != null && response.Items.Any())
+ {
await threadVm.Thread.MessagesReceivedAsync(response.Items);
+ }
}
catch (Exception ex)
{
@@ -214,9 +215,7 @@ public partial class ChatWindowViewModel : ViewModelBase
try
{
threadVm.UnreadCount = 0;
-
UpdateTotalUnreadCount();
-
await _chatClient.UpdatedReadStatus(threadVm.Thread.Id);
}
catch (Exception ex)
@@ -231,7 +230,6 @@ public partial class ChatWindowViewModel : ViewModelBase
private void MoveThreadToTop(ChatThreadViewModel threadVm)
{
var index = Threads.IndexOf(threadVm);
-
if (index > 0)
Threads.Move(index, 0);
}
@@ -245,7 +243,6 @@ public partial class ChatWindowViewModel : ViewModelBase
return;
var text = MessageInput.Trim();
-
MessageInput = string.Empty;
try
@@ -255,7 +252,6 @@ public partial class ChatWindowViewModel : ViewModelBase
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send chat message");
-
MessageInput = text;
}
}
@@ -289,13 +285,11 @@ public partial class ChatWindowViewModel : ViewModelBase
foreach (var user in users.Where(u => u.Id != currentUserId))
{
var vm = new UserSelectionViewModel(user);
-
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(UserSelectionViewModel.IsSelected))
OnPropertyChanged(nameof(HasSelectedUsers));
};
-
AvailableUsers.Add(vm);
}
@@ -314,7 +308,6 @@ public partial class ChatWindowViewModel : ViewModelBase
{
IsCreatingThread = false;
UserSearchText = string.Empty;
-
foreach (var u in AvailableUsers)
u.IsSelected = false;
}
@@ -326,7 +319,6 @@ public partial class ChatWindowViewModel : ViewModelBase
return;
var selected = AvailableUsers.Where(u => u.IsSelected).ToList();
-
if (selected.Count == 0)
return;
@@ -351,7 +343,6 @@ public partial class ChatWindowViewModel : ViewModelBase
{
// Select the newly created thread
var newThread = Threads.FirstOrDefault(t => t.Thread.Id == threadId);
-
if (newThread != null)
SelectedThread = newThread;
@@ -379,8 +370,11 @@ public partial class ChatWindowViewModel : ViewModelBase
foreach (var user in AvailableUsers)
{
- if (string.IsNullOrEmpty(query) || user.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrEmpty(query) ||
+ user.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
+ {
FilteredUsers.Add(user);
+ }
}
}
}
diff --git a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameActionBarViewModel.cs
similarity index 58%
rename from LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/Components/GameActionBarViewModel.cs
index 562b11df..af70a42b 100644
--- a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameActionBarViewModel.cs
@@ -1,1673 +1,1147 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
-using System.Threading.Tasks;
-using Avalonia;
-using Avalonia.Controls.ApplicationLifetimes;
-using Avalonia.Controls.Primitives;
-using Avalonia.Data;
-using Avalonia.Threading;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.Views;
-using LANCommander.SDK.Clients;
-using LANCommander.Launcher.Data.Models;
-using LANCommander.Launcher.Services;
-using LANCommander.Launcher.Services.PowerShell;
-using LANCommander.SDK.Abstractions;
-using Microsoft.EntityFrameworkCore;
-using LANCommander.SDK.Enums;
-using LANCommander.SDK.Helpers;
-using LANCommander.SDK.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace LANCommander.Launcher.ViewModels.Components;
-
-///
-/// ViewModel for the game action bar component.
-/// Handles play, install, uninstall, and library management actions.
-///
-public partial class GameActionBarViewModel : ViewModelBase, IDisposable
-{
- private readonly IServiceProvider _serviceProvider;
- private readonly ILogger _logger;
-
- [ObservableProperty]
- private Guid _gameId;
-
- [ObservableProperty]
- private string _title = string.Empty;
-
- // Library state
- [ObservableProperty]
- private bool _isInLibrary;
-
- [ObservableProperty]
- private bool _isAddingToLibrary;
-
- [ObservableProperty]
- private bool _isRemovingFromLibrary;
-
- // Install state
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))]
-
- [NotifyPropertyChangedFor(nameof(CanInstall))]
- private bool _isInstalled;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(CanInstall))]
- private bool _isInstalling;
-
- [ObservableProperty]
- private bool _isUninstalling;
-
- [ObservableProperty]
- private bool _isVerifyingFiles;
-
- [ObservableProperty]
- private string? _installDirectory;
-
- // Play state
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(ShowUpdateLabel))]
- [NotifyPropertyChangedFor(nameof(ShowPlayLabel))]
- private bool _isRunning;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(ShowUpdateLabel))]
- [NotifyPropertyChangedFor(nameof(ShowPlayLabel))]
- private bool _isStarting;
-
- [ObservableProperty]
- private bool _isStopping;
-
- // Stats
- [ObservableProperty]
- private string _playTime = Localize("PlayStatNone");
-
- [ObservableProperty]
- private string _lastPlayed = Localize("LastPlayedNever");
-
- // Status
- [ObservableProperty]
- private string? _statusMessage;
-
- // Download size (from server archive metadata)
- [ObservableProperty]
- private string _downloadSizeText = string.Empty;
-
- // Available game actions
- [ObservableProperty]
- private ObservableCollection _actions = new();
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))]
- private bool _hasMultipleActions;
-
- // Non-primary (secondary) actions shown in the split-button dropdown
- [ObservableProperty]
- private ObservableCollection _secondaryActions = new();
-
- [ObservableProperty]
- private bool _hasSecondaryActions;
-
- // Manuals
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(OpenFirstManualCommand))]
- private ObservableCollection _manuals = new();
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(OpenFirstManualCommand))]
- private bool _hasManuals;
-
- ///
- /// Command to open the first manual. Returns null if no manuals exist.
- ///
- public IRelayCommand? OpenFirstManualCommand => Manuals.FirstOrDefault()?.OpenCommand;
-
- // Script debugging
- [ObservableProperty]
- private bool _isScriptDebuggingEnabled;
-
- // Update available
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))]
-
- [NotifyPropertyChangedFor(nameof(ShowUpdateLabel))]
- [NotifyPropertyChangedFor(nameof(ShowPlayLabel))]
- private bool _isUpdateAvailable;
-
- // Offline mode - disables install
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(CanInstall))]
- private bool _isOfflineMode;
-
- ///
- /// Shows the simple play button when installed but has only one or zero actions
- ///
- public bool ShowSimplePlayButton => IsInstalled && !HasMultipleActions;
-
- ///
- /// Shows "Update" label when update available and game is idle (not running/starting)
- ///
- public bool ShowUpdateLabel => IsUpdateAvailable && !IsRunning && !IsStarting;
-
- ///
- /// Shows "Play" label when no update available and game is idle (not running/starting)
- ///
- public bool ShowPlayLabel => !IsRunning && (!IsUpdateAvailable || IsStarting);
-
- ///
- /// Can install only when online and not already installed
- ///
- public bool CanInstall => !IsOfflineMode && !IsInstalled && !IsInstalling;
-
- public bool PlayButtonIsEnabled => !IsStopping && !IsStarting;
-
- // Timer for checking running state
- private System.Threading.Timer? _runningCheckTimer;
-
- // Timer for refreshing the "Last Played" relative time text
- private System.Threading.Timer? _lastPlayedTimer;
-
- // End time of the most recent play session, used to recompute the relative text
- private DateTime? _lastSessionEnd;
-
- // Events
- public event EventHandler? LibraryChanged;
- public event EventHandler? InstallRequested;
-
- public GameActionBarViewModel(IServiceProvider serviceProvider)
- {
- _serviceProvider = serviceProvider;
- _logger = serviceProvider.GetRequiredService>();
- }
-
- ///
- /// Loads the action bar state for a game from local database
- ///
- public async Task LoadFromLocalGameAsync(Game game)
- {
- GameId = game.Id;
- Title = game.Title ?? "Unknown";
- IsInstalled = game.Installed;
- InstallDirectory = game.InstallDirectory;
- IsUpdateAvailable = game.Installed
- && !string.IsNullOrWhiteSpace(game.LatestVersion)
- && game.InstalledVersion != game.LatestVersion;
-
- using var scope = _serviceProvider.CreateScope();
-
- var libraryService = scope.ServiceProvider.GetRequiredService();
- var settingsProvider = scope.ServiceProvider.GetRequiredService();
-
- IsInLibrary = await libraryService.IsInLibraryAsync(game.Id);
- IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging;
-
- await LoadPlayStatsAsync(game.Id);
- LoadManuals(game);
- await LoadActionsAsync();
- StartRunningCheck();
-
- // Check server for updates if installed and not already detected locally
- if (game.Installed && !IsUpdateAvailable)
- _ = CheckForUpdateFromServerAsync(game.Id, game.InstalledVersion);
- }
-
- ///
- /// Loads action bar state for a transient context menu (right-click / gamepad) without
- /// starting the running-state polling timers. Seeds from the list item, then enriches
- /// from the local database when the game is present locally.
- ///
- public async Task LoadForMenuAsync(GameItemViewModel item)
- {
- GameId = item.Id;
- Title = item.Title;
- IsInstalled = item.IsInstalled;
- IsInLibrary = item.InLibrary;
- IsUpdateAvailable = item.IsUpdateAvailable;
-
- using var scope = _serviceProvider.CreateScope();
-
- var libraryService = scope.ServiceProvider.GetRequiredService();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var settingsProvider = scope.ServiceProvider.GetRequiredService();
-
- IsInLibrary = await libraryService.IsInLibraryAsync(item.Id);
- IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging;
-
- var localGame = await gameService.GetAsync(item.Id);
-
- if (localGame != null)
- {
- IsInstalled = localGame.Installed;
- InstallDirectory = localGame.InstallDirectory;
- IsUpdateAvailable = localGame.Installed
- && !string.IsNullOrWhiteSpace(localGame.LatestVersion)
- && localGame.InstalledVersion != localGame.LatestVersion;
-
- await LoadPlayStatsAsync(localGame.Id);
- LoadManuals(localGame);
- await LoadActionsAsync();
- }
- }
-
- ///
- /// Loads the action bar state for a game from SDK model
- ///
- public async Task LoadFromSdkGameAsync(SDK.Models.Game game)
- {
- GameId = game.Id;
- Title = game.Title ?? "Unknown";
-
- using var scope = _serviceProvider.CreateScope();
- var libraryService = scope.ServiceProvider.GetRequiredService();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var settingsProvider = scope.ServiceProvider.GetRequiredService();
-
- IsInLibrary = await libraryService.IsInLibraryAsync(game.Id);
- IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging;
-
- // Check if installed from local database
- var localGame = await gameService.GetAsync(game.Id);
- if (localGame != null)
- {
- IsInstalled = localGame.Installed;
- InstallDirectory = localGame.InstallDirectory;
- IsUpdateAvailable = localGame.Installed
- && !string.IsNullOrWhiteSpace(localGame.LatestVersion)
- && localGame.InstalledVersion != localGame.LatestVersion;
- await LoadPlayStatsAsync(localGame.Id);
- LoadManuals(localGame);
- }
- else
- {
- IsInstalled = false;
- InstallDirectory = null;
- IsUpdateAvailable = false;
- PlayTime = Localize("PlayStatNone");
- LastPlayed = Localize("LastPlayedNever");
- Manuals.Clear();
- HasManuals = false;
- }
-
- // Download size from latest archive (only show when not installed)
- if (!IsInstalled)
- {
- var latestArchive = game.Archives?.OrderByDescending(a => a.CreatedOn).FirstOrDefault();
- DownloadSizeText = latestArchive?.CompressedSize > 0
- ? FormatBytes(latestArchive.CompressedSize)
- : string.Empty;
- }
- else
- {
- DownloadSizeText = string.Empty;
- }
-
- await LoadActionsAsync();
- StartRunningCheck();
-
- // Check server for updates if installed and not already detected locally
- if (localGame != null && localGame.Installed && !IsUpdateAvailable)
- _ = CheckForUpdateFromServerAsync(localGame.Id, localGame.InstalledVersion);
- }
-
- private static string FormatBytes(long bytes)
- {
- string[] sizes = { "B", "KB", "MB", "GB", "TB" };
- int order = 0;
- double size = bytes;
- while (size >= 1024 && order < sizes.Length - 1) { order++; size /= 1024; }
- return $"{size:0.##} {sizes[order]}";
- }
-
- ///
- /// Loads available actions for the current game
- ///
- private async Task LoadActionsAsync()
- {
- Actions.Clear();
- SecondaryActions.Clear();
- HasMultipleActions = false;
- HasSecondaryActions = false;
-
- if (!IsInstalled || string.IsNullOrEmpty(InstallDirectory))
- return;
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameClient = scope.ServiceProvider.GetRequiredService();
-
- var actions = await gameClient.GetActionsAsync(InstallDirectory, GameId);
- if (actions != null && actions.Any())
- {
- foreach (var action in actions.Where(a => a.IsPrimaryAction).OrderBy(a => a.SortOrder))
- Actions.Add(new GameActionViewModel(action, RunActionAsync));
-
- foreach (var action in actions.Where(a => !a.IsPrimaryAction).OrderBy(a => a.SortOrder))
- SecondaryActions.Add(new GameActionViewModel(action, RunActionAsync));
-
- HasMultipleActions = Actions.Count > 1;
- HasSecondaryActions = SecondaryActions.Count > 0;
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to load actions for game {GameId}", GameId);
- }
- }
-
- ///
- /// Refreshes the state from the database.
- /// Called after an installation completes.
- ///
- public async Task RefreshAsync()
- {
- if (GameId == Guid.Empty) return;
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var libraryService = scope.ServiceProvider.GetRequiredService();
-
- var localGame = await gameService.GetAsync(GameId);
- if (localGame != null)
- {
- IsInstalled = localGame.Installed;
- InstallDirectory = localGame.InstallDirectory;
- IsUpdateAvailable = localGame.Installed
- && !string.IsNullOrWhiteSpace(localGame.LatestVersion)
- && localGame.InstalledVersion != localGame.LatestVersion;
- IsInLibrary = await libraryService.IsInLibraryAsync(GameId);
- await LoadPlayStatsAsync(localGame.Id);
- LoadManuals(localGame);
- await LoadActionsAsync();
- StatusMessage = IsInstalled ? "Installation complete!" : null;
- _logger.LogInformation("Refreshed action bar for {Title}: Installed={Installed}", Title, IsInstalled);
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to refresh action bar for {GameId}", GameId);
- }
- }
-
- public void StartRunningCheck()
- {
- _runningCheckTimer?.Dispose();
- _runningCheckTimer = new System.Threading.Timer(
- _ => CheckRunningState(),
- null,
- TimeSpan.Zero,
- TimeSpan.FromMilliseconds(500));
-
- _lastPlayedTimer?.Dispose();
- _lastPlayedTimer = new System.Threading.Timer(
- _ => Dispatcher.UIThread.Post(UpdateLastPlayedText),
- null,
- TimeSpan.FromMinutes(1),
- TimeSpan.FromMinutes(1));
- }
-
- public void StopRunningCheck()
- {
- _runningCheckTimer?.Dispose();
- _runningCheckTimer = null;
-
- _lastPlayedTimer?.Dispose();
- _lastPlayedTimer = null;
- }
-
- ///
- /// Stops any polling timers. Used by transient menu-backing instances so they don't
- /// leave background timers running after the menu closes.
- ///
- public void Dispose()
- {
- StopRunningCheck();
- }
-
- private void CheckRunningState()
- {
- if (GameId == Guid.Empty) return;
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameClient = scope.ServiceProvider.GetRequiredService();
-
- var wasRunning = IsRunning;
- var nowRunning = gameClient.IsRunning(GameId);
-
- // If game stopped running, reset states and refresh stats
- if (wasRunning && !nowRunning)
- {
- // Dispatch UI updates to the UI thread
- Dispatcher.UIThread.Post(async () =>
- {
- IsRunning = false;
- IsStarting = false;
- IsStopping = false;
-
- // Refresh play stats
- await RefreshPlayStatsAsync();
- });
- }
- else if (nowRunning != IsRunning)
- {
- // Update running state on UI thread
- Dispatcher.UIThread.Post(() => IsRunning = nowRunning);
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error checking running state");
- }
- }
-
- private async Task RefreshPlayStatsAsync()
- {
- try
- {
- if (Dispatcher.UIThread.CheckAccess())
- {
- await LoadPlayStatsAsync(GameId);
- }
- else
- {
- await Dispatcher.UIThread.InvokeAsync(() => LoadPlayStatsAsync(GameId));
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to refresh play stats");
- }
- }
-
- private async Task LoadPlayStatsAsync(Guid gameId)
- {
- using var scope = _serviceProvider.CreateScope();
- var dbContext = scope.ServiceProvider.GetRequiredService();
-
- var playSessions = await dbContext.Set()
- .Where(ps => ps.GameId == gameId && ps.Start != null && ps.End != null)
- .ToListAsync();
-
- if (playSessions.Any())
- {
- var totalTime = new TimeSpan(playSessions
- .Select(ps => ps.End!.Value.Subtract(ps.Start!.Value))
- .Sum(ts => ts.Ticks));
-
- if (totalTime.TotalMinutes < 1)
- PlayTime = Localize("PlayStatNone");
- else if (totalTime.TotalHours < 1)
- PlayTime = Localize("PlayTimeMinutes", $"{totalTime.TotalMinutes:0}");
- else
- PlayTime = Localize("PlayTimeHours", $"{totalTime.TotalHours:0.#}");
-
- var lastSession = playSessions
- .OrderByDescending(ps => ps.End)
- .First();
-
- _lastSessionEnd = lastSession.End!.Value;
- UpdateLastPlayedText();
- }
- else
- {
- PlayTime = Localize("PlayStatNone");
- _lastSessionEnd = null;
- LastPlayed = Localize("LastPlayedNever");
- }
- }
-
- ///
- /// Recomputes the relative "Last Played" text from the cached last session end time.
- /// Called on load and periodically so the text stays current without re-querying.
- ///
- private void UpdateLastPlayedText()
- {
- if (_lastSessionEnd is not { } end)
- {
- LastPlayed = Localize("LastPlayedNever");
- return;
- }
-
- var elapsed = DateTime.UtcNow - end;
- if (elapsed.TotalMinutes < 1)
- LastPlayed = Localize("LastPlayedJustNow");
- else if (elapsed.TotalHours < 1)
- {
- var minutes = (int)elapsed.TotalMinutes;
- LastPlayed = Localize(minutes == 1 ? "LastPlayedMinuteAgo" : "LastPlayedMinutesAgo", minutes);
- }
- else if (elapsed.TotalDays < 1)
- {
- var hours = (int)elapsed.TotalHours;
- LastPlayed = Localize(hours == 1 ? "LastPlayedHourAgo" : "LastPlayedHoursAgo", hours);
- }
- else if (elapsed.TotalDays < 7)
- {
- var days = (int)elapsed.TotalDays;
- LastPlayed = Localize(days == 1 ? "LastPlayedDayAgo" : "LastPlayedDaysAgo", days);
- }
- else
- LastPlayed = end.ToLocalTime().ToString("MMM d, yyyy");
- }
-
- ///
- /// Checks the server for available updates and updates local state if found.
- /// If no update is available, refreshes the on-disk manifest and scripts.
- /// Runs in the background (fire-and-forget) so it doesn't block UI loading.
- ///
- private async Task CheckForUpdateFromServerAsync(Guid gameId, string installedVersion)
- {
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameClient = scope.ServiceProvider.GetRequiredService();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var importService = scope.ServiceProvider.GetRequiredService();
- var redistributableClient = scope.ServiceProvider.GetRequiredService();
-
- var hasUpdate = await gameClient.CheckForUpdateAsync(gameId, installedVersion);
-
- if (!hasUpdate && !string.IsNullOrEmpty(InstallDirectory))
- {
- // Check redistributables for updates
- var localGame = await gameService.GetAsync(gameId);
-
- if (localGame?.Redistributables != null)
- {
- foreach (var redistributable in localGame.Redistributables)
- {
- try
- {
- var redistManifest = await ManifestHelper.ReadAsync(InstallDirectory, redistributable.Id);
-
- if (redistManifest == null || string.IsNullOrWhiteSpace(redistManifest.Version))
- continue;
-
- var redistHasUpdate = await redistributableClient.CheckForUpdateAsync(redistributable.Id, redistManifest.Version);
-
- if (redistHasUpdate)
- {
- _logger.LogInformation("Redistributable {RedistName} ({RedistId}) has an update available for game {GameId}",
- redistributable.Name, redistributable.Id, gameId);
- hasUpdate = true;
- break;
- }
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Could not check for redistributable {RedistId} updates", redistributable.Id);
- }
- }
- }
- }
-
- if (hasUpdate)
- {
- _logger.LogInformation("Server reports update available for game {GameId}", gameId);
-
- // Re-import the game to pull latest version info into local DB
- await importService.ImportGameAsync(gameId);
-
- var localGame = await gameService.GetAsync(gameId);
- if (localGame != null)
- {
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- IsUpdateAvailable = true;
- });
- }
- }
- else if (!string.IsNullOrEmpty(InstallDirectory))
- {
- // No update available — refresh manifest and scripts to keep them in sync
- _logger.LogDebug("Refreshing manifest and scripts for game {GameId}", gameId);
- await gameClient.RefreshManifestAndScriptsAsync(InstallDirectory, gameId);
- }
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Could not check for updates for game {GameId}", gameId);
- }
- }
-
- [RelayCommand]
- private async Task UpdateGameAsync()
- {
- if (!IsInstalled || !IsUpdateAvailable || IsInstalling) return;
-
- IsInstalling = true;
- StatusMessage = "Preparing to update...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var installService = scope.ServiceProvider.GetRequiredService();
-
- var localGame = await gameService.GetAsync(GameId);
- if (localGame == null)
- throw new InvalidOperationException("Game not found in local database");
-
- _logger.LogInformation("Adding game {GameId} ({Title}) to update queue", GameId, Title);
-
- await installService.Add(localGame, localGame.InstallDirectory);
-
- StatusMessage = "Added to download queue";
- InstallRequested?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to start update for game {GameId} ({Title})", GameId, Title);
- StatusMessage = $"Failed to update: {ex.Message}";
- await Views.AlertOverlay.ShowAsync("Failed to Update", ex.Message);
- }
- finally
- {
- IsInstalling = false;
- }
- }
-
- [RelayCommand(AllowConcurrentExecutions = true)]
- private Task PrimaryActionAsync()
- {
- if (IsRunning)
- return StopAsync();
- if (IsUpdateAvailable)
- return UpdateGameAsync();
- return PlayAsync();
- }
-
- [RelayCommand(AllowConcurrentExecutions = true)]
- private Task PlayOrStopAsync() => IsRunning ? StopAsync() : PlayAsync();
-
- [RelayCommand]
- private async Task PlayAsync()
- {
- if (!IsInstalled || IsStarting || IsRunning) return;
-
- // If we have actions loaded, either pick directly or show a chooser
- if (Actions.Any())
- {
- SDK.Models.Manifest.Action? chosen;
-
- if (HasMultipleActions)
- {
- var tcs = new System.Threading.Tasks.TaskCompletionSource();
-
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- var overlayVm = new GameActionsOverlayViewModel(Title, Actions);
- var overlay = new Views.GameActionsOverlay
- {
- DataContext = overlayVm,
- HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
- VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
- };
-
- overlay.ActionSelected += (_, action) => tcs.TrySetResult(action);
-
- var mainWindow = (Application.Current?.ApplicationLifetime
- as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
- var layer = OverlayLayer.GetOverlayLayer(mainWindow);
-
- if (layer is not null)
- {
- overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty,
- new Binding("Bounds.Width") { Source = layer });
- overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty,
- new Binding("Bounds.Height") { Source = layer });
- layer.Children.Add(overlay);
- }
- else
- {
- // No overlay layer available — fall back to first action
- tcs.TrySetResult(Actions.First().Action);
- }
- });
-
- chosen = await tcs.Task;
- }
- else
- {
- chosen = Actions.First().Action;
- }
-
- if (chosen != null)
- await RunActionAsync(chosen);
-
- return;
- }
-
- // Fallback: load actions on demand and run the first primary one
- IsStarting = true;
- {
- StatusMessage = "Starting...";
-
- var discordClient = _serviceProvider.GetRequiredService();
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var gameClient = scope.ServiceProvider.GetRequiredService();
-
- var localGame = await gameService.GetAsync(GameId);
- if (localGame == null)
- {
- throw new InvalidOperationException("Game not found in local database");
- }
-
- // Get available actions
- var actions = await gameClient.GetActionsAsync(localGame.InstallDirectory, GameId);
- if (actions == null || !actions.Any())
- {
- throw new InvalidOperationException("No actions available for this game");
- }
-
- // Find primary action or first action
- var primaryAction = actions.FirstOrDefault(a => a.IsPrimaryAction) ?? actions.First();
-
- _logger.LogInformation("Running action {ActionName} for game {GameId}", primaryAction.Name, GameId);
-
- var discordAppId = localGame.ExternalIds?
- .FirstOrDefault(e => string.Equals(e.Provider, "Discord", StringComparison.OrdinalIgnoreCase))
- ?.ExternalId;
- discordClient.UpdatePresence(localGame.Title, discordAppId);
-
- // Run the game
- await gameService.Run(localGame, primaryAction);
-
- StatusMessage = null;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to play game {GameId}", GameId);
- StatusMessage = $"Failed to start: {ex.Message}";
- await Views.AlertOverlay.ShowAsync("Failed to Launch", ex.Message);
- }
- finally
- {
- discordClient.ClearPresence();
- IsStarting = false;
- }
- }
- }
-
- ///
- /// Runs a specific action for the game
- ///
- public async Task RunActionAsync(SDK.Models.Manifest.Action action)
- {
- if (!IsInstalled || IsStarting || IsRunning) return;
-
- IsStarting = true;
- StatusMessage = $"Starting {action.Name}...";
-
- var discordClient = _serviceProvider.GetRequiredService();
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameService = scope.ServiceProvider.GetRequiredService();
-
- var localGame = await gameService.GetAsync(GameId);
- if (localGame == null)
- {
- throw new InvalidOperationException("Game not found in local database");
- }
-
- _logger.LogInformation("Running action {ActionName} for game {GameId}", action.Name, GameId);
-
- var discordAppId = localGame.ExternalIds?
- .FirstOrDefault(e => string.Equals(e.Provider, "Discord", StringComparison.OrdinalIgnoreCase))
- ?.ExternalId;
- discordClient.UpdatePresence(localGame.Title, discordAppId);
-
- // Run the game with the specific action
- await gameService.Run(localGame, action);
-
- StatusMessage = null;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to run action {ActionName} for game {GameId}", action.Name, GameId);
- StatusMessage = $"Failed to start: {ex.Message}";
- await Views.AlertOverlay.ShowAsync("Failed to Launch", ex.Message);
- }
- finally
- {
- discordClient.ClearPresence();
- IsStarting = false;
- }
- }
-
- [RelayCommand]
- private async Task StopAsync()
- {
- if (!IsRunning || IsStopping) return;
-
- IsStopping = true;
- StatusMessage = "Stopping...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameClient = scope.ServiceProvider.GetRequiredService();
-
- gameClient.Stop(GameId);
- _logger.LogInformation("Stop requested for game {GameId}", GameId);
-
- // Wait briefly for process to stop
- await Task.Delay(500);
-
- StatusMessage = null;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to stop game {GameId}", GameId);
- StatusMessage = $"Failed to stop: {ex.Message}";
- }
- finally
- {
- IsStopping = false;
- }
- }
-
- [RelayCommand]
- private async Task InstallAsync()
- {
- if (IsInstalling) return;
-
- IsInstalling = true;
- StatusMessage = "Preparing to install...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var importService = scope.ServiceProvider.GetRequiredService();
- var libraryService = scope.ServiceProvider.GetRequiredService();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var installService = scope.ServiceProvider.GetRequiredService();
- var gameClient = scope.ServiceProvider.GetRequiredService();
- var settingsProvider = scope.ServiceProvider.GetRequiredService();
-
- // Ensure game is in library
- if (!IsInLibrary)
- {
- _logger.LogInformation("Game {GameId} ({Title}) not in library, adding first", GameId, Title);
- StatusMessage = "Adding to library...";
-
- await importService.ImportGameAsync(GameId);
- await libraryService.AddToLibraryAsync(GameId);
- await libraryService.RefreshItemsAsync();
-
- IsInLibrary = true;
- LibraryChanged?.Invoke(this, EventArgs.Empty);
- }
-
- var localGame = await gameService.GetAsync(GameId);
- if (localGame == null)
- throw new InvalidOperationException("Game not found in local database after import");
-
- // ── Gather options ─────────────────────────────────────────────────
- StatusMessage = "Checking available options...";
-
- var installDirectories = settingsProvider.CurrentValue.Games.InstallDirectories ?? [];
- var availableAddons = Array.Empty();
- var availableTools = Array.Empty();
-
- try
- {
- var addons = await gameClient.GetAddonsAsync(GameId);
- availableAddons = addons?.ToArray() ?? [];
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Could not fetch addons for {GameId}", GameId);
- }
-
- try
- {
- var tools = await gameClient.GetToolsAsync(GameId);
- availableTools = tools?.Where(t => (t.Archives?.Any() ?? false) && !t.AlwaysInstall).ToArray() ?? [];
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Could not fetch tools for {GameId}", GameId);
- }
-
- var needsDialog = availableAddons.Length > 0 || availableTools.Length > 0 || installDirectories.Length > 1;
-
- // ── Build options VM ───────────────────────────────────────────────
- var optionsVm = new InstallOptionsViewModel();
-
- foreach (var dir in installDirectories)
- optionsVm.InstallDirectories.Add(dir);
-
- optionsVm.SelectedInstallDirectory = installDirectories.FirstOrDefault() ?? string.Empty;
- optionsVm.GameTitle = Title ?? "Game";
- optionsVm.DialogTitle = $"Install {optionsVm.GameTitle}";
- optionsVm.ConfirmButtonText = "Install";
-
- // Fetch base game archive sizes
- try
- {
- var game = await gameClient.GetAsync(GameId);
- var archives = game?.Archives?.ToArray() ?? [];
- optionsVm.BaseDownloadSize = archives.Sum(a => a.CompressedSize);
- optionsVm.BaseSpaceRequired = archives.Sum(a => a.UncompressedSize);
- }
- catch { /* sizes will show as 0 */ }
-
- // Add addons sorted by type, then name
- foreach (var addon in availableAddons
- .OrderBy(a => a.Type)
- .ThenBy(a => a.Title ?? string.Empty))
- optionsVm.Addons.Add(new InstallAddonItemViewModel(addon, selectedByDefault: false));
-
- // Add tools sorted by name
- foreach (var tool in availableTools.OrderBy(t => t.Name ?? string.Empty))
- optionsVm.Tools.Add(new InstallToolItemViewModel(tool, selectedByDefault: false));
-
- // ── Show dialog if needed ──────────────────────────────────────────
- if (needsDialog)
- {
- var tcs = new System.Threading.Tasks.TaskCompletionSource();
-
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- var overlay = new Views.InstallOptionsOverlay
- {
- DataContext = optionsVm,
- HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
- VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
- };
-
- overlay.DialogClosed += (_, result) => tcs.TrySetResult(result);
-
- var mainWindow = (Application.Current?.ApplicationLifetime
- as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
-
- var layer = OverlayLayer.GetOverlayLayer(mainWindow);
-
- if (layer is not null)
- {
- overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer });
- overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer });
-
- layer.Children.Add(overlay);
- }
- });
-
- var confirmed = await tcs.Task;
-
- if (confirmed != true)
- {
- StatusMessage = null;
- return;
- }
- }
-
- // ── Queue the install ──────────────────────────────────────────────
- StatusMessage = "Starting installation...";
- _logger.LogInformation("Adding game {GameId} ({Title}) to install queue", GameId, Title);
-
- await installService.Add(
- localGame,
- optionsVm.SelectedInstallDirectory,
- optionsVm.SelectedAddons.Length > 0 ? optionsVm.SelectedAddons : null,
- optionsVm.SelectedTools.Length > 0 ? optionsVm.SelectedTools : null);
-
- StatusMessage = "Added to download queue";
- InstallRequested?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to start installation for game {GameId} ({Title})", GameId, Title);
- StatusMessage = $"Failed to install: {ex.Message}";
- await Views.AlertOverlay.ShowAsync("Failed to Install", ex.Message);
- }
- finally
- {
- IsInstalling = false;
- }
- }
-
- [RelayCommand]
- private async Task ModifyAsync()
- {
- if (!IsInstalled) return;
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameService = scope.ServiceProvider.GetRequiredService();
- var installService = scope.ServiceProvider.GetRequiredService();
- var gameClient = scope.ServiceProvider.GetRequiredService();
- var settingsProvider = scope.ServiceProvider.GetRequiredService();
-
- var dbContext = scope.ServiceProvider.GetRequiredService();
-
- var localGame = await dbContext.Set()
- .Include(g => g.GameTools)
- .FirstOrDefaultAsync(g => g.Id == GameId);
-
- if (localGame == null)
- throw new InvalidOperationException("Game not found in local database");
-
- // ── Gather options ─────────────────────────────────────────────────
- var installDirectories = settingsProvider.CurrentValue.Games.InstallDirectories ?? [];
- var availableAddons = Array.Empty();
- var availableTools = Array.Empty();
-
- try
- {
- var addons = await gameClient.GetAddonsAsync(GameId);
- availableAddons = addons?.ToArray() ?? [];
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Could not fetch addons for {GameId}", GameId);
- }
-
- try
- {
- var tools = await gameClient.GetToolsAsync(GameId);
- availableTools = tools?.Where(t => (t.Archives?.Any() ?? false) && !t.AlwaysInstall).ToArray() ?? [];
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Could not fetch tools for {GameId}", GameId);
- }
-
- // Build set of currently installed addon IDs. Addons install as their own Game
- // records (with Installed set), and the local DependentGames relationship is not
- // populated during import, so look the available addons up directly by ID.
- var availableAddonIds = availableAddons.Select(a => a.Id).ToArray();
- var installedAddonIds = new HashSet(
- await dbContext.Set()
- .Where(g => availableAddonIds.Contains(g.Id) && g.Installed)
- .Select(g => g.Id)
- .ToListAsync());
-
- // Build set of currently installed tool IDs (tracked per game)
- var installedToolIds = new HashSet(
- (localGame.GameTools ?? [])
- .Where(gt => gt.Installed)
- .Select(gt => gt.ToolId));
-
- // ── Build options VM ───────────────────────────────────────────────
- var optionsVm = new InstallOptionsViewModel();
-
- foreach (var dir in installDirectories)
- optionsVm.InstallDirectories.Add(dir);
-
- // If current install directory isn't in the list, add it
- if (!string.IsNullOrEmpty(localGame.InstallDirectory))
- {
- var currentDir = System.IO.Path.GetDirectoryName(localGame.InstallDirectory) ?? localGame.InstallDirectory;
-
- if (!optionsVm.InstallDirectories.Contains(currentDir))
- optionsVm.InstallDirectories.Insert(0, currentDir);
-
- optionsVm.SelectedInstallDirectory = currentDir;
- }
- else
- {
- optionsVm.SelectedInstallDirectory = installDirectories.FirstOrDefault() ?? string.Empty;
- }
-
- optionsVm.GameTitle = Title ?? "Game";
- optionsVm.DialogTitle = $"Modify {optionsVm.GameTitle}";
- optionsVm.ConfirmButtonText = "Apply";
- optionsVm.AlwaysShowDirectory = true;
-
- // Fetch base game archive sizes
- try
- {
- var game = await gameClient.GetAsync(GameId);
- var archives = game?.Archives?.ToArray() ?? [];
- optionsVm.BaseDownloadSize = archives.Sum(a => a.CompressedSize);
- optionsVm.BaseSpaceRequired = archives.Sum(a => a.UncompressedSize);
- }
- catch { /* sizes will show as 0 */ }
-
- // Add addons sorted by type, then name; pre-select currently installed ones
- foreach (var addon in availableAddons
- .OrderBy(a => a.Type)
- .ThenBy(a => a.Title ?? string.Empty))
- optionsVm.Addons.Add(new InstallAddonItemViewModel(addon, selectedByDefault: installedAddonIds.Contains(addon.Id)));
-
- // Add tools sorted by name; pre-select currently installed ones
- foreach (var tool in availableTools.OrderBy(t => t.Name ?? string.Empty))
- optionsVm.Tools.Add(new InstallToolItemViewModel(tool, selectedByDefault: installedToolIds.Contains(tool.Id)));
-
- // ── Show dialog ───────────────────────────────────────────────────
- var tcs = new System.Threading.Tasks.TaskCompletionSource();
-
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- var overlay = new Views.InstallOptionsOverlay
- {
- DataContext = optionsVm,
- HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
- VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
- };
-
- overlay.DialogClosed += (_, result) => tcs.TrySetResult(result);
-
- var mainWindow = (Application.Current?.ApplicationLifetime
- as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
-
- var layer = OverlayLayer.GetOverlayLayer(mainWindow);
-
- if (layer is not null)
- {
- overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer });
- overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer });
-
- layer.Children.Add(overlay);
- }
- });
-
- var confirmed = await tcs.Task;
-
- if (confirmed != true)
- return;
-
- // ── Queue the modification ────────────────────────────────────────
- _logger.LogInformation("Modifying game {GameId} ({Title}): install dir={Dir}, addons={AddonCount}, tools={ToolCount}",
- GameId, Title, optionsVm.SelectedInstallDirectory, optionsVm.SelectedAddons.Length, optionsVm.SelectedTools.Length);
-
- await installService.Add(
- localGame,
- optionsVm.SelectedInstallDirectory,
- optionsVm.SelectedAddons.Length > 0 ? optionsVm.SelectedAddons : null,
- optionsVm.SelectedTools.Length > 0 ? optionsVm.SelectedTools : null);
-
- StatusMessage = "Added to download queue";
- InstallRequested?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to modify game {GameId} ({Title})", GameId, Title);
- StatusMessage = $"Failed to modify: {ex.Message}";
- }
- }
-
- [RelayCommand]
- private async Task UninstallAsync()
- {
- if (!IsInstalled || IsUninstalling) return;
-
- IsUninstalling = true;
- StatusMessage = "Uninstalling...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameService = scope.ServiceProvider.GetRequiredService();
-
- var localGame = await gameService.GetAsync(GameId);
- if (localGame == null)
- {
- throw new InvalidOperationException("Game not found in local database");
- }
-
- _logger.LogInformation("Uninstalling game {GameId} ({Title})", GameId, Title);
-
- await gameService.UninstallAsync(localGame);
-
- IsInstalled = false;
- InstallDirectory = null;
- StatusMessage = "Uninstalled";
- _logger.LogInformation("Game {GameId} ({Title}) uninstalled", GameId, Title);
-
- // Notify that library has changed (install status changed)
- LibraryChanged?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to uninstall game {GameId} ({Title})", GameId, Title);
- StatusMessage = $"Failed to uninstall: {ex.Message}";
- }
- finally
- {
- IsUninstalling = false;
- }
- }
-
- [RelayCommand]
- private async Task VerifyFilesAsync()
- {
- if (!IsInstalled || IsVerifyingFiles || string.IsNullOrEmpty(InstallDirectory)) return;
-
- IsVerifyingFiles = true;
- StatusMessage = "Verifying files...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameClient = scope.ServiceProvider.GetRequiredService();
-
- var conflicts = await gameClient.ValidateFilesAsync(InstallDirectory, GameId);
- var conflictList = conflicts?.ToList() ?? new();
-
- if (conflictList.Count == 0)
- {
- StatusMessage = "All files verified successfully";
- _logger.LogInformation("File verification passed for game {GameId} ({Title})", GameId, Title);
- }
- else
- {
- StatusMessage = $"{conflictList.Count} file(s) need repair, restoring...";
- _logger.LogInformation("File verification found {Count} conflict(s) for game {GameId} ({Title}), restoring", conflictList.Count, GameId, Title);
-
- await gameClient.RestoreFilesAsync(InstallDirectory, GameId, conflictList.Select(c => c.FullName));
-
- StatusMessage = $"{conflictList.Count} file(s) restored";
- _logger.LogInformation("File restoration complete for game {GameId} ({Title})", GameId, Title);
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to verify files for game {GameId} ({Title})", GameId, Title);
- StatusMessage = $"Verification failed: {ex.Message}";
- }
- finally
- {
- IsVerifyingFiles = false;
- }
- }
-
- [RelayCommand]
- private async Task AddToLibraryAsync()
- {
- if (IsInLibrary || IsAddingToLibrary) return;
-
- IsAddingToLibrary = true;
- StatusMessage = "Adding to library...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var importService = scope.ServiceProvider.GetRequiredService();
- var libraryService = scope.ServiceProvider.GetRequiredService();
-
- await importService.ImportGameAsync(GameId);
- await libraryService.AddToLibraryAsync(GameId);
- await libraryService.RefreshItemsAsync();
-
- IsInLibrary = true;
- StatusMessage = "Added to library";
- _logger.LogInformation("Game {GameId} ({Title}) added to library", GameId, Title);
-
- LibraryChanged?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to add game {GameId} ({Title}) to library", GameId, Title);
- StatusMessage = $"Failed to add: {ex.Message}";
- await Views.AlertOverlay.ShowAsync("Failed to Add to Library", ex.Message);
- }
- finally
- {
- IsAddingToLibrary = false;
- }
- }
-
- [RelayCommand]
- private async Task RemoveFromLibraryAsync()
- {
- if (!IsInLibrary || IsRemovingFromLibrary) return;
-
- IsRemovingFromLibrary = true;
- StatusMessage = "Removing from library...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var libraryService = scope.ServiceProvider.GetRequiredService();
-
- await libraryService.RemoveFromLibraryAsync(GameId);
- await libraryService.RefreshItemsAsync();
-
- IsInLibrary = false;
- StatusMessage = "Removed from library";
- _logger.LogInformation("Game {GameId} ({Title}) removed from library", GameId, Title);
-
- LibraryChanged?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to remove game {GameId} ({Title}) from library", GameId, Title);
- StatusMessage = $"Failed to remove: {ex.Message}";
- await Views.AlertOverlay.ShowAsync("Failed to Remove from Library", ex.Message);
- }
- finally
- {
- IsRemovingFromLibrary = false;
- }
- }
-
- [RelayCommand]
- private void BrowseFiles()
- {
- if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory))
- {
- StatusMessage = "Install directory not found";
- return;
- }
-
- try
- {
- Process.Start(new ProcessStartInfo
- {
- FileName = InstallDirectory,
- UseShellExecute = true
- });
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to open install directory");
- StatusMessage = $"Failed to open folder: {ex.Message}";
- }
- }
-
- private void LoadManuals(Game game)
- {
- Manuals.Clear();
-
- if (game.Media == null || !game.Media.Any())
- {
- HasManuals = false;
- return;
- }
-
- using var scope = _serviceProvider.CreateScope();
- var mediaService = scope.ServiceProvider.GetRequiredService();
-
- var manualMedia = game.Media
- .Where(m => m.Type == SDK.Enums.MediaType.Manual)
- .ToList();
-
- foreach (var manual in manualMedia)
- {
- var filePath = mediaService.GetImagePath(manual);
- if (File.Exists(filePath))
- {
- var title = string.IsNullOrWhiteSpace(manual.Name) ? "Manual" : manual.Name;
- Manuals.Add(new ManualViewModel(title, filePath, OpenManual));
- }
- }
-
- HasManuals = Manuals.Count > 0;
- }
-
- private void OpenManual(ManualViewModel manual)
- {
- try
- {
- var items = new List();
- int startIndex = 0;
-
- for (int i = 0; i < Manuals.Count; i++)
- {
- items.Add(new LightboxItem
- {
- Type = LightboxItemType.Pdf,
- Path = Manuals[i].FilePath,
- Title = Manuals[i].Title,
- });
-
- if (Manuals[i] == manual)
- startIndex = i;
- }
-
- LightboxOverlay.ShowOverlay(items, startIndex);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to open manual {Title}", manual.Title);
- StatusMessage = $"Failed to open manual: {ex.Message}";
- }
- }
-
- ///
- /// Opens a PowerShell console in the game's install directory
- ///
- [RelayCommand]
- private void OpenPowerShellConsole()
- {
- if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory))
- {
- StatusMessage = "Game is not installed";
- return;
- }
-
- try
- {
- // Open a standalone PowerShell window for the install directory
- var processInfo = new ProcessStartInfo
- {
- FileName = "powershell.exe",
- Arguments = $"-NoLogo -NoExit -WorkingDirectory \"{InstallDirectory}\"",
- UseShellExecute = true
- };
- Process.Start(processInfo);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to open PowerShell console for {Title}", Title);
- StatusMessage = $"Failed to open console: {ex.Message}";
- }
- }
-
- ///
- /// Opens a console window, runs the specified script type using the SDK's script execution, then stays interactive
- ///
- private async Task OpenScriptTerminalAsync(ScriptType scriptType, string scriptTypeName)
- {
- if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory))
- {
- StatusMessage = "Game is not installed";
- return;
- }
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var scriptClient = scope.ServiceProvider.GetRequiredService();
- var scriptDebugger = _serviceProvider.GetRequiredService();
-
- // Create and show the console window
- var viewModel = new PowerShellConsoleViewModel($"{scriptTypeName} Scripts - {Title}", InstallDirectory);
- var window = new Views.PowerShellConsoleWindow
- {
- DataContext = viewModel
- };
-
- viewModel.CloseAction = () => window.Close();
-
- // Wire up the debugger events to the console control
- var console = window.ConsoleControl;
-
- scriptDebugger.OnDebugStart = context =>
- {
- console.OnDebugStart(context);
- return Task.CompletedTask;
- };
-
- scriptDebugger.OnOutput = (level, message) =>
- {
- console.OnOutput(level, message);
- return Task.CompletedTask;
- };
-
- scriptDebugger.OnDebugBreak = async context =>
- {
- await console.OnDebugBreakAsync(context);
- };
-
- scriptDebugger.OnDebugEnd = context =>
- {
- console.OnDebugEnd(context);
- return Task.CompletedTask;
- };
-
- // Show the window
- window.Show();
-
- // Run the appropriate script type
- // The script client already handles debug mode when EnableScriptDebugging is true
- scriptClient.Debug = true; // Force debug mode for this execution
-
- StatusMessage = $"Running {scriptTypeName} scripts...";
-
- var gameClient = scope.ServiceProvider.GetRequiredService();
- var manifests = await gameClient.GetManifestsAsync(InstallDirectory, GameId);
-
- foreach (var manifest in manifests)
- {
- switch (scriptType)
- {
- case ScriptType.Install:
- await scriptClient.Game_RunInstallScriptAsync(InstallDirectory, GameId);
- break;
- case ScriptType.Uninstall:
- await scriptClient.Game_RunUninstallScriptAsync(InstallDirectory, GameId);
- break;
- case ScriptType.NameChange:
- var userService = scope.ServiceProvider.GetRequiredService();
- var user = await userService.GetCurrentUser();
- await scriptClient.Game_RunNameChangeScriptAsync(InstallDirectory, GameId, user.GetUserNameSafe ?? SDK.Models.Settings.DEFAULT_GAME_USERNAME);
-
- break;
- case ScriptType.KeyChange:
- var key = await gameClient.GetAllocatedKeyAsync(manifest.Id);
- await scriptClient.Game_RunKeyChangeScriptAsync(InstallDirectory, GameId, key);
- break;
- }
- }
-
- StatusMessage = $"{scriptTypeName} scripts completed";
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to run {ScriptType} scripts for {Title}", scriptTypeName, Title);
- StatusMessage = $"Script error: {ex.Message}";
- }
- }
-
- ///
- /// Runs install scripts in a debug console
- ///
- [RelayCommand]
- private Task RunInstallScriptsAsync()
- {
- return OpenScriptTerminalAsync(ScriptType.Install, "Install");
- }
-
- ///
- /// Runs uninstall scripts in a debug console
- ///
- [RelayCommand]
- private Task RunUninstallScriptsAsync()
- {
- return OpenScriptTerminalAsync(ScriptType.Uninstall, "Uninstall");
- }
-
- ///
- /// Runs name change scripts
- ///
- [RelayCommand]
- private Task RunNameChangeScriptsAsync()
- {
- return OpenScriptTerminalAsync(ScriptType.NameChange, "Name Change");
- }
-
- ///
- /// Runs key change scripts
- ///
- [RelayCommand]
- private Task RunKeyChangeScriptsAsync()
- {
- return OpenScriptTerminalAsync(ScriptType.KeyChange, "Key Change");
- }
-}
-
-///
-/// ViewModel for a game action (used in the Play dropdown)
-///
-public partial class GameActionViewModel : ViewModelBase
-{
- public SDK.Models.Manifest.Action Action { get; }
-
- public string Name => Action.Name;
-
- private readonly Func _runAction;
-
- public GameActionViewModel(SDK.Models.Manifest.Action action, Func runAction)
- {
- Action = action;
- _runAction = runAction;
- }
-
- [RelayCommand]
- private async Task RunAsync()
- {
- await _runAction(Action);
- }
-}
-
-///
-/// ViewModel for a game manual (used in the Manuals menu)
-///
-public partial class ManualViewModel : ViewModelBase
-{
- public string Title { get; }
- public string FilePath { get; }
-
- private readonly Action _openManual;
-
- public ManualViewModel(string title, string filePath, Action openManual)
- {
- Title = title;
- FilePath = filePath;
- _openManual = openManual;
- }
-
- [RelayCommand]
- private void Open()
- {
- _openManual(this);
- }
-}
-
-///
-/// ViewModel for the "choose a primary action" overlay shown when a game
-/// has more than one primary action configured.
-///
-public class GameActionsOverlayViewModel
-{
- public string GameTitle { get; }
- public IReadOnlyList Actions { get; }
-
- public GameActionsOverlayViewModel(string gameTitle, IEnumerable actions)
- {
- GameTitle = gameTitle;
- Actions = actions.ToList();
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.Launcher.Avalonia.Views;
+using LANCommander.Launcher.Data.Models;
+using LANCommander.Launcher.Services;
+using LANCommander.Launcher.Services.PowerShell;
+using LANCommander.SDK.Abstractions;
+using LANCommander.SDK.Enums;
+using LANCommander.SDK.Helpers;
+using LANCommander.SDK.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
+
+///
+/// ViewModel for the game action bar component.
+/// Handles play, install, uninstall, and library management actions.
+///
+public partial class GameActionBarViewModel : ViewModelBase
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ [ObservableProperty]
+ private Guid _gameId;
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ // Library state
+ [ObservableProperty]
+ private bool _isInLibrary;
+
+ [ObservableProperty]
+ private bool _isAddingToLibrary;
+
+ [ObservableProperty]
+ private bool _isRemovingFromLibrary;
+
+ // Install state
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))]
+ [NotifyPropertyChangedFor(nameof(CanInstall))]
+ private bool _isInstalled;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanInstall))]
+ private bool _isInstalling;
+
+ [ObservableProperty]
+ private bool _isUninstalling;
+
+ [ObservableProperty]
+ private string? _installDirectory;
+
+ // Play state
+ [ObservableProperty]
+ private bool _isRunning;
+
+ [ObservableProperty]
+ private bool _isStarting;
+
+ [ObservableProperty]
+ private bool _isStopping;
+
+ // Stats
+ [ObservableProperty]
+ private string _playTime = "None";
+
+ [ObservableProperty]
+ private string _lastPlayed = "Never";
+
+ // Status
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ // Download size (from server archive metadata)
+ [ObservableProperty]
+ private string _downloadSizeText = string.Empty;
+
+ // Available game actions
+ [ObservableProperty]
+ private ObservableCollection _actions = new();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowSimplePlayButton))]
+ private bool _hasMultipleActions;
+
+ // Non-primary (secondary) actions shown in the split-button dropdown
+ [ObservableProperty]
+ private ObservableCollection _secondaryActions = new();
+
+ [ObservableProperty]
+ private bool _hasSecondaryActions;
+
+ // Manuals
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(OpenFirstManualCommand))]
+ private ObservableCollection _manuals = new();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(OpenFirstManualCommand))]
+ private bool _hasManuals;
+
+ ///
+ /// Command to open the first manual. Returns null if no manuals exist.
+ ///
+ public IRelayCommand? OpenFirstManualCommand => Manuals.FirstOrDefault()?.OpenCommand;
+
+ // Script debugging
+ [ObservableProperty]
+ private bool _isScriptDebuggingEnabled;
+
+ // Offline mode - disables install
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanInstall))]
+ private bool _isOfflineMode;
+
+ ///
+ /// Shows the simple play button when installed but has only one or zero actions
+ ///
+ public bool ShowSimplePlayButton => IsInstalled && !HasMultipleActions;
+
+ ///
+ /// Can install only when online and not already installed
+ ///
+ public bool CanInstall => !IsOfflineMode && !IsInstalled && !IsInstalling;
+
+ public bool PlayButtonIsEnabled => !IsStopping && !IsStarting;
+
+ // Timer for checking running state
+ private System.Threading.Timer? _runningCheckTimer;
+
+ // Events
+ public event EventHandler? LibraryChanged;
+ public event EventHandler? InstallRequested;
+
+ public GameActionBarViewModel(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = serviceProvider.GetRequiredService>();
+ }
+
+ ///
+ /// Loads the action bar state for a game from local database
+ ///
+ public async Task LoadFromLocalGameAsync(Game game)
+ {
+ GameId = game.Id;
+ Title = game.Title ?? "Unknown";
+ IsInstalled = game.Installed;
+ InstallDirectory = game.InstallDirectory;
+
+ using var scope = _serviceProvider.CreateScope();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+ var settingsProvider = scope.ServiceProvider.GetRequiredService();
+
+ IsInLibrary = await libraryService.IsInLibraryAsync(game.Id);
+ IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging;
+
+ LoadPlayStats(game);
+ LoadManuals(game);
+ await LoadActionsAsync();
+ StartRunningCheck();
+ }
+
+ ///
+ /// Loads the action bar state for a game from SDK model
+ ///
+ public async Task LoadFromSdkGameAsync(SDK.Models.Game game)
+ {
+ GameId = game.Id;
+ Title = game.Title ?? "Unknown";
+
+ using var scope = _serviceProvider.CreateScope();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var settingsProvider = scope.ServiceProvider.GetRequiredService();
+
+ IsInLibrary = await libraryService.IsInLibraryAsync(game.Id);
+ IsScriptDebuggingEnabled = settingsProvider.CurrentValue.Debug.EnableScriptDebugging;
+
+ // Check if installed from local database
+ var localGame = await gameService.GetAsync(game.Id);
+ if (localGame != null)
+ {
+ IsInstalled = localGame.Installed;
+ InstallDirectory = localGame.InstallDirectory;
+ LoadPlayStats(localGame);
+ LoadManuals(localGame);
+ }
+ else
+ {
+ IsInstalled = false;
+ InstallDirectory = null;
+ PlayTime = "None";
+ LastPlayed = "Never";
+ Manuals.Clear();
+ HasManuals = false;
+ }
+
+ // Download size from latest archive
+ var latestArchive = game.Archives?.OrderByDescending(a => a.CreatedOn).FirstOrDefault();
+ DownloadSizeText = latestArchive?.CompressedSize > 0
+ ? FormatBytes(latestArchive.CompressedSize)
+ : string.Empty;
+
+ await LoadActionsAsync();
+ StartRunningCheck();
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ string[] sizes = { "B", "KB", "MB", "GB", "TB" };
+ int order = 0;
+ double size = bytes;
+ while (size >= 1024 && order < sizes.Length - 1) { order++; size /= 1024; }
+ return $"{size:0.##} {sizes[order]}";
+ }
+
+ ///
+ /// Loads available actions for the current game
+ ///
+ private async Task LoadActionsAsync()
+ {
+ Actions.Clear();
+ SecondaryActions.Clear();
+ HasMultipleActions = false;
+ HasSecondaryActions = false;
+
+ if (!IsInstalled || string.IsNullOrEmpty(InstallDirectory))
+ return;
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+
+ var actions = await gameClient.GetActionsAsync(InstallDirectory, GameId);
+ if (actions != null && actions.Any())
+ {
+ foreach (var action in actions.Where(a => a.IsPrimaryAction).OrderBy(a => a.SortOrder))
+ Actions.Add(new GameActionViewModel(action, RunActionAsync));
+
+ foreach (var action in actions.Where(a => !a.IsPrimaryAction).OrderBy(a => a.SortOrder))
+ SecondaryActions.Add(new GameActionViewModel(action, RunActionAsync));
+
+ HasMultipleActions = Actions.Count > 1;
+ HasSecondaryActions = SecondaryActions.Count > 0;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load actions for game {GameId}", GameId);
+ }
+ }
+
+ ///
+ /// Refreshes the state from the database.
+ /// Called after an installation completes.
+ ///
+ public async Task RefreshAsync()
+ {
+ if (GameId == Guid.Empty) return;
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+
+ var localGame = await gameService.GetAsync(GameId);
+ if (localGame != null)
+ {
+ IsInstalled = localGame.Installed;
+ InstallDirectory = localGame.InstallDirectory;
+ IsInLibrary = await libraryService.IsInLibraryAsync(GameId);
+ LoadPlayStats(localGame);
+ LoadManuals(localGame);
+ await LoadActionsAsync();
+ StatusMessage = IsInstalled ? "Installation complete!" : null;
+ _logger.LogInformation("Refreshed action bar for {Title}: Installed={Installed}", Title, IsInstalled);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to refresh action bar for {GameId}", GameId);
+ }
+ }
+
+ public void StartRunningCheck()
+ {
+ _runningCheckTimer?.Dispose();
+ _runningCheckTimer = new System.Threading.Timer(
+ _ => CheckRunningState(),
+ null,
+ TimeSpan.Zero,
+ TimeSpan.FromMilliseconds(500));
+ }
+
+ public void StopRunningCheck()
+ {
+ _runningCheckTimer?.Dispose();
+ _runningCheckTimer = null;
+ }
+
+ private void CheckRunningState()
+ {
+ if (GameId == Guid.Empty) return;
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+
+ var wasRunning = IsRunning;
+ var nowRunning = gameClient.IsRunning(GameId);
+
+ // If game stopped running, reset states and refresh stats
+ if (wasRunning && !nowRunning)
+ {
+ // Dispatch UI updates to the UI thread
+ Dispatcher.UIThread.Post(async () =>
+ {
+ IsRunning = false;
+ IsStarting = false;
+ IsStopping = false;
+
+ // Refresh play stats
+ await RefreshPlayStatsAsync();
+ });
+ }
+ else if (nowRunning != IsRunning)
+ {
+ // Update running state on UI thread
+ Dispatcher.UIThread.Post(() => IsRunning = nowRunning);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error checking running state");
+ }
+ }
+
+ private async Task RefreshPlayStatsAsync()
+ {
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var localGame = await gameService.GetAsync(GameId);
+ if (localGame != null)
+ {
+ // Ensure we're on the UI thread when updating properties
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ LoadPlayStats(localGame);
+ }
+ else
+ {
+ await Dispatcher.UIThread.InvokeAsync(() => LoadPlayStats(localGame));
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to refresh play stats");
+ }
+ }
+
+ private void LoadPlayStats(Game game)
+ {
+ // Calculate play time
+ if (game.PlaySessions != null && game.PlaySessions.Any())
+ {
+ var totalTime = new TimeSpan(game.PlaySessions
+ .Where(ps => ps.End != null && ps.Start != null)
+ .Select(ps => ps.End!.Value.Subtract(ps.Start!.Value))
+ .Sum(ts => ts.Ticks));
+
+ if (totalTime.TotalMinutes < 1)
+ PlayTime = "None";
+ else if (totalTime.TotalHours < 1)
+ PlayTime = $"{totalTime.TotalMinutes:0} minutes";
+ else
+ PlayTime = $"{totalTime.TotalHours:0.#} hours";
+
+ // Last played
+ var lastSession = game.PlaySessions
+ .Where(ps => ps.End != null && ps.Start != null)
+ .OrderByDescending(ps => ps.End)
+ .FirstOrDefault();
+
+ if (lastSession?.End != null)
+ {
+ var elapsed = DateTime.Now - lastSession.End.Value;
+ if (elapsed.TotalMinutes < 1)
+ LastPlayed = "Just now";
+ else if (elapsed.TotalHours < 1)
+ LastPlayed = $"{elapsed.TotalMinutes:0} minutes ago";
+ else if (elapsed.TotalDays < 1)
+ LastPlayed = $"{elapsed.TotalHours:0} hours ago";
+ else if (elapsed.TotalDays < 7)
+ LastPlayed = $"{elapsed.TotalDays:0} days ago";
+ else
+ LastPlayed = lastSession.End.Value.ToString("MMM d, yyyy");
+ }
+ else
+ {
+ LastPlayed = "Never";
+ }
+ }
+ else
+ {
+ PlayTime = "None";
+ LastPlayed = "Never";
+ }
+ }
+
+ [RelayCommand(AllowConcurrentExecutions = true)]
+ private Task PlayOrStopAsync() => IsRunning ? StopAsync() : PlayAsync();
+
+ [RelayCommand]
+ private async Task PlayAsync()
+ {
+ if (!IsInstalled || IsStarting || IsRunning) return;
+
+ // If we have actions loaded, either pick directly or show a chooser
+ if (Actions.Any())
+ {
+ SDK.Models.Manifest.Action? chosen;
+
+ if (HasMultipleActions)
+ {
+ var tcs = new System.Threading.Tasks.TaskCompletionSource();
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ var overlayVm = new GameActionsOverlayViewModel(Title, Actions);
+ var overlay = new Views.GameActionsOverlay
+ {
+ DataContext = overlayVm,
+ HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
+ VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
+ };
+
+ overlay.ActionSelected += (_, action) => tcs.TrySetResult(action);
+
+ var mainWindow = (Application.Current?.ApplicationLifetime
+ as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+ var layer = OverlayLayer.GetOverlayLayer(mainWindow);
+
+ if (layer is not null)
+ {
+ overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty,
+ new Binding("Bounds.Width") { Source = layer });
+ overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty,
+ new Binding("Bounds.Height") { Source = layer });
+ layer.Children.Add(overlay);
+ }
+ else
+ {
+ // No overlay layer available — fall back to first action
+ tcs.TrySetResult(Actions.First().Action);
+ }
+ });
+
+ chosen = await tcs.Task;
+ }
+ else
+ {
+ chosen = Actions.First().Action;
+ }
+
+ if (chosen != null)
+ await RunActionAsync(chosen);
+
+ return;
+ }
+
+ // Fallback: load actions on demand and run the first primary one
+ IsStarting = true;
+ {
+ StatusMessage = "Starting...";
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+
+ var localGame = await gameService.GetAsync(GameId);
+ if (localGame == null)
+ {
+ throw new InvalidOperationException("Game not found in local database");
+ }
+
+ // Get available actions
+ var actions = await gameClient.GetActionsAsync(localGame.InstallDirectory, GameId);
+ if (actions == null || !actions.Any())
+ {
+ throw new InvalidOperationException("No actions available for this game");
+ }
+
+ // Find primary action or first action
+ var primaryAction = actions.FirstOrDefault(a => a.IsPrimaryAction) ?? actions.First();
+
+ _logger.LogInformation("Running action {ActionName} for game {GameId}", primaryAction.Name, GameId);
+
+ // Run the game
+ await gameService.Run(localGame, primaryAction);
+
+ StatusMessage = null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to play game {GameId}", GameId);
+ StatusMessage = $"Failed to start: {ex.Message}";
+ }
+ finally
+ {
+ IsStarting = false;
+ }
+ }
+ }
+
+ ///
+ /// Runs a specific action for the game
+ ///
+ public async Task RunActionAsync(SDK.Models.Manifest.Action action)
+ {
+ if (!IsInstalled || IsStarting || IsRunning) return;
+
+ IsStarting = true;
+ StatusMessage = $"Starting {action.Name}...";
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+
+ var localGame = await gameService.GetAsync(GameId);
+ if (localGame == null)
+ {
+ throw new InvalidOperationException("Game not found in local database");
+ }
+
+ _logger.LogInformation("Running action {ActionName} for game {GameId}", action.Name, GameId);
+
+ // Run the game with the specific action
+ await gameService.Run(localGame, action);
+
+ StatusMessage = null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to run action {ActionName} for game {GameId}", action.Name, GameId);
+ StatusMessage = $"Failed to start: {ex.Message}";
+ }
+ finally
+ {
+ IsStarting = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task StopAsync()
+ {
+ if (!IsRunning || IsStopping) return;
+
+ IsStopping = true;
+ StatusMessage = "Stopping...";
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+
+ gameClient.Stop(GameId);
+ _logger.LogInformation("Stop requested for game {GameId}", GameId);
+
+ // Wait briefly for process to stop
+ await Task.Delay(500);
+
+ StatusMessage = null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to stop game {GameId}", GameId);
+ StatusMessage = $"Failed to stop: {ex.Message}";
+ }
+ finally
+ {
+ IsStopping = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task InstallAsync()
+ {
+ if (IsInstalling) return;
+
+ IsInstalling = true;
+ StatusMessage = "Preparing to install...";
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var importService = scope.ServiceProvider.GetRequiredService();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var installService = scope.ServiceProvider.GetRequiredService();
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+ var settingsProvider = scope.ServiceProvider.GetRequiredService();
+
+ // Ensure game is in library
+ if (!IsInLibrary)
+ {
+ _logger.LogInformation("Game {GameId} ({Title}) not in library, adding first", GameId, Title);
+ StatusMessage = "Adding to library...";
+
+ await importService.ImportGameAsync(GameId);
+ await libraryService.AddToLibraryAsync(GameId);
+ await libraryService.RefreshItemsAsync();
+
+ IsInLibrary = true;
+ LibraryChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ var localGame = await gameService.GetAsync(GameId);
+ if (localGame == null)
+ throw new InvalidOperationException("Game not found in local database after import");
+
+ // ── Gather options ─────────────────────────────────────────────────
+ StatusMessage = "Checking available options...";
+
+ var installDirectories = settingsProvider.CurrentValue.Games.InstallDirectories ?? [];
+ var availableAddons = Array.Empty();
+
+ try
+ {
+ var addons = await gameClient.GetAddonsAsync(GameId);
+ availableAddons = addons?.ToArray() ?? [];
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Could not fetch addons for {GameId}", GameId);
+ }
+
+ var needsDialog = availableAddons.Length > 0 || installDirectories.Length > 1;
+
+ // ── Build options VM ───────────────────────────────────────────────
+ var optionsVm = new InstallOptionsViewModel();
+
+ foreach (var dir in installDirectories)
+ optionsVm.InstallDirectories.Add(dir);
+
+ optionsVm.SelectedInstallDirectory = installDirectories.FirstOrDefault() ?? string.Empty;
+
+ foreach (var addon in availableAddons)
+ optionsVm.Addons.Add(new InstallAddonItemViewModel(addon, selectedByDefault: false));
+
+ // ── Show dialog if needed ──────────────────────────────────────────
+ if (needsDialog)
+ {
+ var tcs = new System.Threading.Tasks.TaskCompletionSource();
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ var overlay = new Views.InstallOptionsOverlay
+ {
+ DataContext = optionsVm,
+ HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Stretch,
+ VerticalAlignment = global::Avalonia.Layout.VerticalAlignment.Stretch,
+ };
+
+ overlay.DialogClosed += (_, result) => tcs.TrySetResult(result);
+
+ var mainWindow = (Application.Current?.ApplicationLifetime
+ as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+
+ var layer = OverlayLayer.GetOverlayLayer(mainWindow);
+
+ if (layer is not null)
+ {
+ overlay.Bind(global::Avalonia.Layout.Layoutable.WidthProperty, new Binding("Bounds.Width") { Source = layer });
+ overlay.Bind(global::Avalonia.Layout.Layoutable.HeightProperty, new Binding("Bounds.Height") { Source = layer });
+
+ layer.Children.Add(overlay);
+ }
+ });
+
+ var confirmed = await tcs.Task;
+
+ if (confirmed != true)
+ {
+ StatusMessage = null;
+ return;
+ }
+ }
+
+ // ── Queue the install ──────────────────────────────────────────────
+ StatusMessage = "Starting installation...";
+ _logger.LogInformation("Adding game {GameId} ({Title}) to install queue", GameId, Title);
+
+ await installService.Add(
+ localGame,
+ optionsVm.SelectedInstallDirectory,
+ optionsVm.SelectedAddons.Length > 0 ? optionsVm.SelectedAddons : null);
+
+ StatusMessage = "Added to download queue";
+ InstallRequested?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to start installation for game {GameId} ({Title})", GameId, Title);
+ StatusMessage = $"Failed to install: {ex.Message}";
+ }
+ finally
+ {
+ IsInstalling = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task UninstallAsync()
+ {
+ if (!IsInstalled || IsUninstalling) return;
+
+ IsUninstalling = true;
+ StatusMessage = "Uninstalling...";
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+
+ var localGame = await gameService.GetAsync(GameId);
+ if (localGame == null)
+ {
+ throw new InvalidOperationException("Game not found in local database");
+ }
+
+ _logger.LogInformation("Uninstalling game {GameId} ({Title})", GameId, Title);
+
+ await gameService.UninstallAsync(localGame);
+
+ IsInstalled = false;
+ InstallDirectory = null;
+ StatusMessage = "Uninstalled";
+ _logger.LogInformation("Game {GameId} ({Title}) uninstalled", GameId, Title);
+
+ // Notify that library has changed (install status changed)
+ LibraryChanged?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to uninstall game {GameId} ({Title})", GameId, Title);
+ StatusMessage = $"Failed to uninstall: {ex.Message}";
+ }
+ finally
+ {
+ IsUninstalling = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task AddToLibraryAsync()
+ {
+ if (IsInLibrary || IsAddingToLibrary) return;
+
+ IsAddingToLibrary = true;
+ StatusMessage = "Adding to library...";
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var importService = scope.ServiceProvider.GetRequiredService();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+
+ await importService.ImportGameAsync(GameId);
+ await libraryService.AddToLibraryAsync(GameId);
+ await libraryService.RefreshItemsAsync();
+
+ IsInLibrary = true;
+ StatusMessage = "Added to library";
+ _logger.LogInformation("Game {GameId} ({Title}) added to library", GameId, Title);
+
+ LibraryChanged?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to add game {GameId} ({Title}) to library", GameId, Title);
+ StatusMessage = $"Failed to add: {ex.Message}";
+ }
+ finally
+ {
+ IsAddingToLibrary = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task RemoveFromLibraryAsync()
+ {
+ if (!IsInLibrary || IsRemovingFromLibrary) return;
+
+ IsRemovingFromLibrary = true;
+ StatusMessage = "Removing from library...";
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+
+ await libraryService.RemoveFromLibraryAsync(GameId);
+ await libraryService.RefreshItemsAsync();
+
+ IsInLibrary = false;
+ StatusMessage = "Removed from library";
+ _logger.LogInformation("Game {GameId} ({Title}) removed from library", GameId, Title);
+
+ LibraryChanged?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to remove game {GameId} ({Title}) from library", GameId, Title);
+ StatusMessage = $"Failed to remove: {ex.Message}";
+ }
+ finally
+ {
+ IsRemovingFromLibrary = false;
+ }
+ }
+
+ [RelayCommand]
+ private void BrowseFiles()
+ {
+ if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory))
+ {
+ StatusMessage = "Install directory not found";
+ return;
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = InstallDirectory,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to open install directory");
+ StatusMessage = $"Failed to open folder: {ex.Message}";
+ }
+ }
+
+ private void LoadManuals(Game game)
+ {
+ Manuals.Clear();
+
+ if (game.Media == null || !game.Media.Any())
+ {
+ HasManuals = false;
+ return;
+ }
+
+ using var scope = _serviceProvider.CreateScope();
+ var mediaService = scope.ServiceProvider.GetRequiredService();
+
+ var manualMedia = game.Media
+ .Where(m => m.Type == SDK.Enums.MediaType.Manual)
+ .ToList();
+
+ foreach (var manual in manualMedia)
+ {
+ var filePath = mediaService.GetImagePath(manual);
+ if (File.Exists(filePath))
+ {
+ var title = string.IsNullOrWhiteSpace(manual.Name) ? "Manual" : manual.Name;
+ Manuals.Add(new ManualViewModel(title, filePath, OpenManual));
+ }
+ }
+
+ HasManuals = Manuals.Count > 0;
+ }
+
+ private void OpenManual(ManualViewModel manual)
+ {
+ try
+ {
+ var viewModel = new ManualViewerViewModel(manual.Title, manual.FilePath);
+ var window = new Views.ManualViewerWindow
+ {
+ DataContext = viewModel
+ };
+
+ viewModel.CloseAction = () => window.Close();
+ window.Show();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to open manual {Title}", manual.Title);
+ StatusMessage = $"Failed to open manual: {ex.Message}";
+ }
+ }
+
+ ///
+ /// Opens a PowerShell console in the game's install directory
+ ///
+ [RelayCommand]
+ private void OpenPowerShellConsole()
+ {
+ if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory))
+ {
+ StatusMessage = "Game is not installed";
+ return;
+ }
+
+ try
+ {
+ // Open a standalone PowerShell window for the install directory
+ var processInfo = new ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ Arguments = $"-NoLogo -NoExit -WorkingDirectory \"{InstallDirectory}\"",
+ UseShellExecute = true
+ };
+ Process.Start(processInfo);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to open PowerShell console for {Title}", Title);
+ StatusMessage = $"Failed to open console: {ex.Message}";
+ }
+ }
+
+ ///
+ /// Opens a console window, runs the specified script type using the SDK's script execution, then stays interactive
+ ///
+ private async Task OpenScriptTerminalAsync(ScriptType scriptType, string scriptTypeName)
+ {
+ if (string.IsNullOrEmpty(InstallDirectory) || !Directory.Exists(InstallDirectory))
+ {
+ StatusMessage = "Game is not installed";
+ return;
+ }
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var scriptClient = scope.ServiceProvider.GetRequiredService();
+ var scriptDebugger = _serviceProvider.GetRequiredService();
+
+ // Create and show the console window
+ var viewModel = new PowerShellConsoleViewModel($"{scriptTypeName} Scripts - {Title}", InstallDirectory);
+ var window = new Views.PowerShellConsoleWindow
+ {
+ DataContext = viewModel
+ };
+
+ viewModel.CloseAction = () => window.Close();
+
+ // Wire up the debugger events to the console control
+ var console = window.ConsoleControl;
+
+ scriptDebugger.OnDebugStart = context =>
+ {
+ console.OnDebugStart(context);
+ return Task.CompletedTask;
+ };
+
+ scriptDebugger.OnOutput = (level, message) =>
+ {
+ console.OnOutput(level, message);
+ return Task.CompletedTask;
+ };
+
+ scriptDebugger.OnDebugBreak = async context =>
+ {
+ await console.OnDebugBreakAsync(context);
+ };
+
+ scriptDebugger.OnDebugEnd = context =>
+ {
+ console.OnDebugEnd(context);
+ return Task.CompletedTask;
+ };
+
+ // Show the window
+ window.Show();
+
+ // Run the appropriate script type
+ // The script client already handles debug mode when EnableScriptDebugging is true
+ scriptClient.Debug = true; // Force debug mode for this execution
+
+ StatusMessage = $"Running {scriptTypeName} scripts...";
+
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+ var manifests = await gameClient.GetManifestsAsync(InstallDirectory, GameId);
+
+ foreach (var manifest in manifests)
+ {
+ switch (scriptType)
+ {
+ case ScriptType.Install:
+ await scriptClient.Game_RunInstallScriptAsync(InstallDirectory, GameId);
+ break;
+ case ScriptType.Uninstall:
+ await scriptClient.Game_RunUninstallScriptAsync(InstallDirectory, GameId);
+ break;
+ case ScriptType.NameChange:
+ var userService = scope.ServiceProvider.GetRequiredService();
+ var user = await userService.GetCurrentUser();
+ await scriptClient.Game_RunNameChangeScriptAsync(InstallDirectory, GameId, user.GetUserNameSafe ?? SDK.Models.Settings.DEFAULT_GAME_USERNAME);
+
+ break;
+ case ScriptType.KeyChange:
+ var key = await gameClient.GetAllocatedKeyAsync(manifest.Id);
+ await scriptClient.Game_RunKeyChangeScriptAsync(InstallDirectory, GameId, key);
+ break;
+ }
+ }
+
+ StatusMessage = $"{scriptTypeName} scripts completed";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to run {ScriptType} scripts for {Title}", scriptTypeName, Title);
+ StatusMessage = $"Script error: {ex.Message}";
+ }
+ }
+
+ ///
+ /// Runs install scripts in a debug console
+ ///
+ [RelayCommand]
+ private Task RunInstallScriptsAsync()
+ {
+ return OpenScriptTerminalAsync(ScriptType.Install, "Install");
+ }
+
+ ///
+ /// Runs uninstall scripts in a debug console
+ ///
+ [RelayCommand]
+ private Task RunUninstallScriptsAsync()
+ {
+ return OpenScriptTerminalAsync(ScriptType.Uninstall, "Uninstall");
+ }
+
+ ///
+ /// Runs name change scripts
+ ///
+ [RelayCommand]
+ private Task RunNameChangeScriptsAsync()
+ {
+ return OpenScriptTerminalAsync(ScriptType.NameChange, "Name Change");
+ }
+
+ ///
+ /// Runs key change scripts
+ ///
+ [RelayCommand]
+ private Task RunKeyChangeScriptsAsync()
+ {
+ return OpenScriptTerminalAsync(ScriptType.KeyChange, "Key Change");
+ }
+}
+
+///
+/// ViewModel for a game action (used in the Play dropdown)
+///
+public partial class GameActionViewModel : ViewModelBase
+{
+ public SDK.Models.Manifest.Action Action { get; }
+
+ public string Name => Action.Name;
+
+ private readonly Func _runAction;
+
+ public GameActionViewModel(SDK.Models.Manifest.Action action, Func runAction)
+ {
+ Action = action;
+ _runAction = runAction;
+ }
+
+ [RelayCommand]
+ private async Task RunAsync()
+ {
+ await _runAction(Action);
+ }
+}
+
+///
+/// ViewModel for a game manual (used in the Manuals menu)
+///
+public partial class ManualViewModel : ViewModelBase
+{
+ public string Title { get; }
+ public string FilePath { get; }
+
+ private readonly Action _openManual;
+
+ public ManualViewModel(string title, string filePath, Action openManual)
+ {
+ Title = title;
+ FilePath = filePath;
+ _openManual = openManual;
+ }
+
+ [RelayCommand]
+ private void Open()
+ {
+ _openManual(this);
+ }
+}
+
+///
+/// ViewModel for the "choose a primary action" overlay shown when a game
+/// has more than one primary action configured.
+///
+public class GameActionsOverlayViewModel
+{
+ public string GameTitle { get; }
+ public IReadOnlyList Actions { get; }
+
+ public GameActionsOverlayViewModel(string gameTitle, IEnumerable actions)
+ {
+ GameTitle = gameTitle;
+ Actions = actions.ToList();
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/Components/GameItemViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameItemViewModel.cs
similarity index 66%
rename from LANCommander.Launcher/ViewModels/Components/GameItemViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/Components/GameItemViewModel.cs
index 003caab5..e1f0b8ed 100644
--- a/LANCommander.Launcher/ViewModels/Components/GameItemViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameItemViewModel.cs
@@ -1,172 +1,128 @@
-using System;
-using System.Linq;
-using CommunityToolkit.Mvvm.ComponentModel;
-using LANCommander.Launcher.Data.Models;
-using LANCommander.SDK.Enums;
-
-namespace LANCommander.Launcher.ViewModels.Components;
-
-///
-/// ViewModel for a game item in the depot/games list
-///
-public partial class GameItemViewModel : ViewModelBase
-{
- [ObservableProperty]
- private Guid _id;
-
- [ObservableProperty]
- private string _title = string.Empty;
-
- [ObservableProperty]
- private string _description = string.Empty;
-
- [ObservableProperty]
- private string _sortTitle = string.Empty;
-
- [ObservableProperty]
- private DateTime _releasedOn;
-
- [ObservableProperty]
- private bool _singleplayer;
-
- [ObservableProperty]
- private string _genres = string.Empty;
-
- [ObservableProperty]
- private string _collections = string.Empty;
-
- [ObservableProperty]
- private string _developers = string.Empty;
-
- [ObservableProperty]
- private string _publishers = string.Empty;
-
- [ObservableProperty]
- private string _tags = string.Empty;
-
- [ObservableProperty]
- private bool _hasLocalMultiplayer;
-
- [ObservableProperty]
- private bool _hasLanMultiplayer;
-
- [ObservableProperty]
- private bool _hasOnlineMultiplayer;
-
- [ObservableProperty]
- private string? _coverPath;
-
- [ObservableProperty]
- private string? _coverMimeType;
-
- [ObservableProperty]
- private string? _iconPath;
-
- [ObservableProperty]
- private string? _heroPath;
-
- [ObservableProperty]
- private string? _logoPath;
-
- [ObservableProperty]
- private bool _hasCover;
-
- [ObservableProperty]
- private bool _isInstalled;
-
- [ObservableProperty]
- private int _maxPlayers;
-
- [ObservableProperty]
- private bool _inLibrary;
-
- [ObservableProperty]
- private bool _showInLibraryBadge;
-
- [ObservableProperty]
- private bool _isUpdateAvailable;
-
- [ObservableProperty]
- private GameType _type;
-
- public GameItemViewModel() { }
-
- public GameItemViewModel(SDK.Models.DepotGame game, string? coverPath = null, string? coverMimeType = null, bool inLibrary = false, bool showInLibraryBadge = true)
- {
- Id = game.Id;
- Title = game.Title ?? "Unknown";
- Description = game.Description ?? string.Empty;
- SortTitle = game.SortTitle ?? game.Title ?? string.Empty;
- ReleasedOn = game.ReleasedOn;
- Type = game.Type;
- Singleplayer = game.Singleplayer;
- Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty;
- Collections = game.Collections != null ? string.Join(", ", game.Collections.Select(c => c.Name)) : string.Empty;
- Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty;
- Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty;
- Tags = game.Tags != null ? string.Join(", ", game.Tags.Select(t => t.Name)) : string.Empty;
- HasLocalMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Local) ?? false;
- HasLanMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.LAN) ?? false;
- HasOnlineMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Online) ?? false;
- MaxPlayers = game.MultiplayerModes?.Where(m => m.MaxPlayers > 0).Select(m => m.MaxPlayers).DefaultIfEmpty(0).Max() ?? 0;
- CoverPath = coverPath;
- CoverMimeType = coverMimeType;
- HasCover = !string.IsNullOrEmpty(coverPath);
- InLibrary = inLibrary;
- ShowInLibraryBadge = inLibrary && showInLibraryBadge;
- }
-
- public GameItemViewModel(SDK.Models.Game game, string? coverPath = null, string? coverMimeType = null, bool inLibrary = false, bool showInLibraryBadge = true)
- {
- Id = game.Id;
- Title = game.Title ?? "Unknown";
- Description = game.Description ?? string.Empty;
- SortTitle = game.SortTitle ?? game.Title ?? string.Empty;
- ReleasedOn = game.ReleasedOn;
- Type = game.Type;
- Singleplayer = game.Singleplayer;
- Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty;
- Collections = game.Collections != null ? string.Join(", ", game.Collections.Select(c => c.Name)) : string.Empty;
- Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty;
- Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty;
- Tags = game.Tags != null ? string.Join(", ", game.Tags.Select(t => t.Name)) : string.Empty;
- HasLocalMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Local) ?? false;
- HasLanMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.LAN) ?? false;
- HasOnlineMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Online) ?? false;
- MaxPlayers = game.MultiplayerModes?.Where(m => m.MaxPlayers > 0).Select(m => m.MaxPlayers).DefaultIfEmpty(0).Max() ?? 0;
- CoverPath = coverPath;
- CoverMimeType = coverMimeType;
- HasCover = !string.IsNullOrEmpty(coverPath);
- InLibrary = inLibrary;
- ShowInLibraryBadge = inLibrary && showInLibraryBadge;
- }
-
- public GameItemViewModel(Game game, string? coverPath = null, string? coverMimeType = null, bool inLibrary = false, bool showInLibraryBadge = true)
- {
- Id = game.Id;
- Title = game.Title ?? "Unknown";
- Description = game.Description ?? string.Empty;
- SortTitle = game.SortTitle ?? game.Title ?? string.Empty;
- ReleasedOn = game.ReleasedOn ?? DateTime.MinValue;
- Type = game.Type;
- Singleplayer = game.Singleplayer;
- Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty;
- Collections = game.Collections != null ? string.Join(", ", game.Collections.Select(c => c.Name)) : string.Empty;
- Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty;
- Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty;
- Tags = game.Tags != null ? string.Join(", ", game.Tags.Select(t => t.Name)) : string.Empty;
- HasLocalMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Local) ?? false;
- HasLanMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.LAN) ?? false;
- HasOnlineMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Online) ?? false;
- MaxPlayers = game.MultiplayerModes?.Where(m => m.MaxPlayers > 0).Select(m => m.MaxPlayers).DefaultIfEmpty(0).Max() ?? 0;
- IsInstalled = game.Installed;
- IsUpdateAvailable = game.Installed
- && !string.IsNullOrWhiteSpace(game.LatestVersion)
- && game.InstalledVersion != game.LatestVersion;
- CoverPath = coverPath;
- CoverMimeType = coverMimeType;
- HasCover = !string.IsNullOrEmpty(coverPath);
- InLibrary = inLibrary;
- ShowInLibraryBadge = inLibrary && showInLibraryBadge;
- }
-}
+using System;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using LANCommander.Launcher.Data.Models;
+using LANCommander.SDK.Enums;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
+
+///
+/// ViewModel for a game item in the depot/games list
+///
+public partial class GameItemViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private Guid _id;
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private string _description = string.Empty;
+
+ [ObservableProperty]
+ private string _sortTitle = string.Empty;
+
+ [ObservableProperty]
+ private DateTime _releasedOn;
+
+ [ObservableProperty]
+ private bool _singleplayer;
+
+ [ObservableProperty]
+ private string _genres = string.Empty;
+
+ [ObservableProperty]
+ private string _collections = string.Empty;
+
+ [ObservableProperty]
+ private string _developers = string.Empty;
+
+ [ObservableProperty]
+ private string _publishers = string.Empty;
+
+ [ObservableProperty]
+ private string _tags = string.Empty;
+
+ [ObservableProperty]
+ private bool _hasLocalMultiplayer;
+
+ [ObservableProperty]
+ private bool _hasLanMultiplayer;
+
+ [ObservableProperty]
+ private bool _hasOnlineMultiplayer;
+
+ [ObservableProperty]
+ private string? _coverPath;
+
+ [ObservableProperty]
+ private string? _heroPath;
+
+ [ObservableProperty]
+ private string? _logoPath;
+
+ [ObservableProperty]
+ private bool _hasCover;
+
+ [ObservableProperty]
+ private bool _isInstalled;
+
+ [ObservableProperty]
+ private int _maxPlayers;
+
+ [ObservableProperty]
+ private bool _inLibrary;
+
+ [ObservableProperty]
+ private bool _showInLibraryBadge;
+
+ public GameItemViewModel() { }
+
+ public GameItemViewModel(SDK.Models.DepotGame game, string? coverPath = null, bool inLibrary = false, bool showInLibraryBadge = true)
+ {
+ Id = game.Id;
+ Title = game.Title ?? "Unknown";
+ Description = game.Description ?? string.Empty;
+ SortTitle = game.SortTitle ?? game.Title ?? string.Empty;
+ ReleasedOn = game.ReleasedOn;
+ Singleplayer = game.Singleplayer;
+ Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty;
+ Collections = game.Collections != null ? string.Join(", ", game.Collections.Select(c => c.Name)) : string.Empty;
+ Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty;
+ Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty;
+ Tags = game.Tags != null ? string.Join(", ", game.Tags.Select(t => t.Name)) : string.Empty;
+ HasLocalMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Local) ?? false;
+ HasLanMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.LAN) ?? false;
+ HasOnlineMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Online) ?? false;
+ MaxPlayers = game.MultiplayerModes?.Where(m => m.MaxPlayers > 0).Select(m => m.MaxPlayers).DefaultIfEmpty(0).Max() ?? 0;
+ CoverPath = coverPath;
+ HasCover = !string.IsNullOrEmpty(coverPath);
+ InLibrary = inLibrary;
+ ShowInLibraryBadge = inLibrary && showInLibraryBadge;
+ }
+
+ public GameItemViewModel(Game game, string? coverPath = null, bool inLibrary = false, bool showInLibraryBadge = true)
+ {
+ Id = game.Id;
+ Title = game.Title ?? "Unknown";
+ Description = game.Description ?? string.Empty;
+ SortTitle = game.SortTitle ?? game.Title ?? string.Empty;
+ ReleasedOn = game.ReleasedOn ?? DateTime.MinValue;
+ Singleplayer = game.Singleplayer;
+ Genres = game.Genres != null ? string.Join(", ", game.Genres.Select(g => g.Name)) : string.Empty;
+ Collections = game.Collections != null ? string.Join(", ", game.Collections.Select(c => c.Name)) : string.Empty;
+ Developers = game.Developers != null ? string.Join(", ", game.Developers.Select(d => d.Name)) : string.Empty;
+ Publishers = game.Publishers != null ? string.Join(", ", game.Publishers.Select(p => p.Name)) : string.Empty;
+ Tags = game.Tags != null ? string.Join(", ", game.Tags.Select(t => t.Name)) : string.Empty;
+ HasLocalMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Local) ?? false;
+ HasLanMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.LAN) ?? false;
+ HasOnlineMultiplayer = game.MultiplayerModes?.Any(m => m.Type == MultiplayerType.Online) ?? false;
+ MaxPlayers = game.MultiplayerModes?.Where(m => m.MaxPlayers > 0).Select(m => m.MaxPlayers).DefaultIfEmpty(0).Max() ?? 0;
+ IsInstalled = game.Installed;
+ CoverPath = coverPath;
+ HasCover = !string.IsNullOrEmpty(coverPath);
+ InLibrary = inLibrary;
+ ShowInLibraryBadge = inLibrary && showInLibraryBadge;
+ }
+}
diff --git a/LANCommander.Launcher.Avalonia/ViewModels/Components/GameMediaItemViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameMediaItemViewModel.cs
new file mode 100644
index 00000000..6a5dbb43
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/GameMediaItemViewModel.cs
@@ -0,0 +1,9 @@
+namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
+
+/// Represents a single screenshot or video in the game detail media carousel.
+public class GameMediaItemViewModel
+{
+ public string Path { get; set; } = string.Empty;
+ public bool IsVideo { get; set; }
+ public string MimeType { get; set; } = string.Empty;
+}
diff --git a/LANCommander.Launcher/ViewModels/Components/GenreCarouselButtomViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/GenreCarouselButtomViewModel.cs
similarity index 90%
rename from LANCommander.Launcher/ViewModels/Components/GenreCarouselButtomViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/Components/GenreCarouselButtomViewModel.cs
index 7b6b8369..c36cb09e 100644
--- a/LANCommander.Launcher/ViewModels/Components/GenreCarouselButtomViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/GenreCarouselButtomViewModel.cs
@@ -1,6 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
-namespace LANCommander.Launcher.ViewModels.Components;
+namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
public partial class GenreCarouselButtomViewModel : ViewModelBase
{
diff --git a/LANCommander.Launcher/ViewModels/Components/LibraryItemViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/LibraryItemViewModel.cs
similarity index 88%
rename from LANCommander.Launcher/ViewModels/Components/LibraryItemViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/Components/LibraryItemViewModel.cs
index 7807ddf8..fb40f5b0 100644
--- a/LANCommander.Launcher/ViewModels/Components/LibraryItemViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/LibraryItemViewModel.cs
@@ -1,38 +1,38 @@
-using System;
-using CommunityToolkit.Mvvm.ComponentModel;
-using LANCommander.Launcher.Data.Models;
-
-namespace LANCommander.Launcher.ViewModels.Components;
-
-public partial class LibraryItemViewModel : ViewModelBase
-{
- [ObservableProperty]
- private Guid _id;
-
- [ObservableProperty]
- private string _title = string.Empty;
-
- [ObservableProperty]
- private string? _iconPath;
-
- [ObservableProperty]
- private bool _hasIcon;
-
- [ObservableProperty]
- private bool _isSelected;
-
- public LibraryItemViewModel(Game game, string? iconPath = null)
- {
- Id = game.Id;
- Title = game.Title ?? "Unknown";
- IconPath = iconPath;
- HasIcon = !string.IsNullOrEmpty(iconPath);
- }
-
- public LibraryItemViewModel(Guid id, string name)
- {
- Id = id;
- Title = name;
- HasIcon = false;
- }
-}
+using System;
+using CommunityToolkit.Mvvm.ComponentModel;
+using LANCommander.Launcher.Data.Models;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
+
+public partial class LibraryItemViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private Guid _id;
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private string? _iconPath;
+
+ [ObservableProperty]
+ private bool _hasIcon;
+
+ [ObservableProperty]
+ private bool _isSelected;
+
+ public LibraryItemViewModel(Game game, string? iconPath = null)
+ {
+ Id = game.Id;
+ Title = game.Title ?? "Unknown";
+ IconPath = iconPath;
+ HasIcon = !string.IsNullOrEmpty(iconPath);
+ }
+
+ public LibraryItemViewModel(Guid id, string name)
+ {
+ Id = id;
+ Title = name;
+ HasIcon = false;
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/Components/LibrarySidebarViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/LibrarySidebarViewModel.cs
similarity index 73%
rename from LANCommander.Launcher/ViewModels/Components/LibrarySidebarViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/Components/LibrarySidebarViewModel.cs
index fa74913c..4be66990 100644
--- a/LANCommander.Launcher/ViewModels/Components/LibrarySidebarViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/LibrarySidebarViewModel.cs
@@ -1,229 +1,183 @@
-using System;
-using System.Collections.ObjectModel;
-using System.Linq;
-using System.Threading.Tasks;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.Data.Models;
-using LANCommander.Launcher.Services;
-using LANCommander.SDK.Enums;
-using LANCommander.SDK.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace LANCommander.Launcher.ViewModels.Components;
-
-///
-/// ViewModel for the library sidebar showing user's games
-///
-public partial class LibrarySidebarViewModel : ViewModelBase
-{
- private readonly IServiceProvider _serviceProvider;
- private readonly ILogger _logger;
-
- [ObservableProperty]
- private ObservableCollection _items = new();
-
- [ObservableProperty]
- private LibraryItemViewModel? _selectedItem;
-
- [ObservableProperty]
- private bool _isLoading;
-
- [ObservableProperty]
- private bool _isDepotSelected = true;
-
- [ObservableProperty]
- private string _statusMessage = string.Empty;
-
- [ObservableProperty]
- private bool _isOfflineMode;
-
- [ObservableProperty]
- private bool _areUserLibrariesEnabled = true;
-
- public event EventHandler? DepotSelected;
- public event EventHandler? ItemSelected;
- public event EventHandler? RefreshRequested;
- public event EventHandler? LogoutRequested;
- public event EventHandler? SettingsRequested;
- public event EventHandler? GoOnlineRequested;
- public event EventHandler? GoOfflineRequested;
-
- // Prevents OnSelectedItemChanged from firing ItemSelected during programmatic selection
- private bool _suppressItemSelected;
-
- public LibrarySidebarViewModel(IServiceProvider serviceProvider)
- {
- _serviceProvider = serviceProvider;
- _logger = serviceProvider.GetRequiredService>();
- }
-
- partial void OnSelectedItemChanged(LibraryItemViewModel? value)
- {
- if (!_suppressItemSelected && value != null)
- {
- IsDepotSelected = false;
- ItemSelected?.Invoke(this, value);
- }
- }
-
- [RelayCommand]
- public async Task LoadAsync()
- {
- IsLoading = true;
- Items.Clear();
- _logger.LogDebug("Loading library items...");
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var libraryService = scope.ServiceProvider.GetRequiredService();
- var mediaService = scope.ServiceProvider.GetRequiredService();
- var mediaClient = scope.ServiceProvider.GetRequiredService();
-
- if (!IsOfflineMode)
- {
- try
- {
- var authenticationClient = scope.ServiceProvider.GetRequiredService();
- AreUserLibrariesEnabled = await authenticationClient.GetEnableUserLibrariesAsync();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to fetch user library setting; defaulting to enabled");
- }
- }
-
- var items = await libraryService.GetItemsAsync();
-
- foreach (var item in items ?? [])
- {
- if (item.DataItem is Game game)
- {
- var iconPath = await GetOrDownloadIconPathAsync(game, mediaService, mediaClient);
- Items.Add(new LibraryItemViewModel(game, iconPath));
- }
- }
-
- StatusMessage = IsOfflineMode ? $"{Items.Count} games (offline)" : $"{Items.Count} games";
- _logger.LogDebug("Loaded {Count} library items", Items.Count);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to load library items");
- StatusMessage = "Failed to load";
- }
- finally
- {
- IsLoading = false;
- }
- }
-
- private async Task GetOrDownloadIconPathAsync(Game game, MediaService mediaService, MediaClient mediaClient)
- {
- var icon = game.Media?.FirstOrDefault(m => m.Type == MediaType.Icon);
- if (icon == null) return null;
-
- var path = mediaService.GetImagePath(icon);
-
- if (mediaService.FileExists(icon))
- return path;
-
- if (IsOfflineMode)
- return null;
-
- try
- {
- var sdkMedia = new SDK.Models.Media
- {
- Id = icon.Id,
- FileId = icon.FileId,
- Crc32 = icon.Crc32,
- MimeType = icon.MimeType,
- Type = icon.Type,
- };
-
- var fileInfo = await mediaClient.DownloadAsync(sdkMedia, path);
-
- if (fileInfo.Exists)
- return fileInfo.FullName;
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to download icon for game {GameId}", game.Id);
- }
-
- return null;
- }
-
- [RelayCommand]
- private void ShowDepot()
- {
- SelectedItem = null;
- IsDepotSelected = true;
- DepotSelected?.Invoke(this, EventArgs.Empty);
- }
-
- [RelayCommand]
- private void SelectItem(LibraryItemViewModel? item)
- {
- if (item == null) return;
- SelectedItem = item;
- // OnSelectedItemChanged handles IsDepotSelected and ItemSelected event
- }
-
- [RelayCommand]
- private void Refresh()
- {
- RefreshRequested?.Invoke(this, EventArgs.Empty);
- }
-
- [RelayCommand]
- private void Logout()
- {
- LogoutRequested?.Invoke(this, EventArgs.Empty);
- }
-
- [RelayCommand]
- private void Settings()
- {
- SettingsRequested?.Invoke(this, EventArgs.Empty);
- }
-
- [RelayCommand]
- private void GoOnline()
- {
- GoOnlineRequested?.Invoke(this, EventArgs.Empty);
- }
-
- [RelayCommand]
- private void GoOffline()
- {
- GoOfflineRequested?.Invoke(this, EventArgs.Empty);
- }
-
- public void ClearSelection()
- {
- SelectedItem = null;
- IsDepotSelected = false;
- }
-
- public void SelectDepot()
- {
- SelectedItem = null;
- IsDepotSelected = true;
- }
-
- public void SelectItemById(Guid id)
- {
- var item = Items.FirstOrDefault(i => i.Id == id);
- if (item != null)
- {
- _suppressItemSelected = true;
- SelectedItem = item;
- _suppressItemSelected = false;
- IsDepotSelected = false;
- }
- }
-}
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.Launcher.Data.Models;
+using LANCommander.Launcher.Services;
+using LANCommander.SDK.Enums;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
+
+///
+/// ViewModel for the library sidebar showing user's games
+///
+public partial class LibrarySidebarViewModel : ViewModelBase
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ [ObservableProperty]
+ private ObservableCollection _items = new();
+
+ [ObservableProperty]
+ private LibraryItemViewModel? _selectedItem;
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private bool _isDepotSelected = true;
+
+ [ObservableProperty]
+ private string _statusMessage = string.Empty;
+
+ [ObservableProperty]
+ private bool _isOfflineMode;
+
+ public event EventHandler? DepotSelected;
+ public event EventHandler? ItemSelected;
+ public event EventHandler? RefreshRequested;
+ public event EventHandler? LogoutRequested;
+ public event EventHandler? SettingsRequested;
+ public event EventHandler? GoOnlineRequested;
+ public event EventHandler? GoOfflineRequested;
+
+ // Prevents OnSelectedItemChanged from firing ItemSelected during programmatic selection
+ private bool _suppressItemSelected;
+
+ public LibrarySidebarViewModel(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = serviceProvider.GetRequiredService>();
+ }
+
+ partial void OnSelectedItemChanged(LibraryItemViewModel? value)
+ {
+ if (!_suppressItemSelected && value != null)
+ {
+ IsDepotSelected = false;
+ ItemSelected?.Invoke(this, value);
+ }
+ }
+
+ [RelayCommand]
+ public async Task LoadAsync()
+ {
+ IsLoading = true;
+ Items.Clear();
+ _logger.LogDebug("Loading library items...");
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+ var mediaService = scope.ServiceProvider.GetRequiredService();
+
+ var items = await libraryService.GetItemsAsync();
+
+ foreach (var item in items ?? [])
+ {
+ if (item.DataItem is Game game)
+ {
+ var iconPath = GetIconPath(game, mediaService);
+ Items.Add(new LibraryItemViewModel(game, iconPath));
+ }
+ }
+
+ StatusMessage = IsOfflineMode ? $"{Items.Count} games (offline)" : $"{Items.Count} games";
+ _logger.LogDebug("Loaded {Count} library items", Items.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load library items");
+ StatusMessage = "Failed to load";
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ private string? GetIconPath(Game game, MediaService mediaService)
+ {
+ var icon = game.Media?.FirstOrDefault(m => m.Type == MediaType.Icon);
+ if (icon == null) return null;
+
+ var path = mediaService.GetImagePath(icon);
+ return mediaService.FileExists(icon) ? path : null;
+ }
+
+ [RelayCommand]
+ private void ShowDepot()
+ {
+ SelectedItem = null;
+ IsDepotSelected = true;
+ DepotSelected?.Invoke(this, EventArgs.Empty);
+ }
+
+ [RelayCommand]
+ private void SelectItem(LibraryItemViewModel? item)
+ {
+ if (item == null) return;
+ SelectedItem = item;
+ // OnSelectedItemChanged handles IsDepotSelected and ItemSelected event
+ }
+
+ [RelayCommand]
+ private void Refresh()
+ {
+ RefreshRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ [RelayCommand]
+ private void Logout()
+ {
+ LogoutRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ [RelayCommand]
+ private void Settings()
+ {
+ SettingsRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ [RelayCommand]
+ private void GoOnline()
+ {
+ GoOnlineRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ [RelayCommand]
+ private void GoOffline()
+ {
+ GoOfflineRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void ClearSelection()
+ {
+ SelectedItem = null;
+ IsDepotSelected = false;
+ }
+
+ public void SelectDepot()
+ {
+ SelectedItem = null;
+ IsDepotSelected = true;
+ }
+
+ public void SelectItemById(Guid id)
+ {
+ var item = Items.FirstOrDefault(i => i.Id == id);
+ if (item != null)
+ {
+ _suppressItemSelected = true;
+ SelectedItem = item;
+ _suppressItemSelected = false;
+ IsDepotSelected = false;
+ }
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/Components/ProfileViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/Components/ProfileViewModel.cs
similarity index 97%
rename from LANCommander.Launcher/ViewModels/Components/ProfileViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/Components/ProfileViewModel.cs
index c3166109..da239d45 100644
--- a/LANCommander.Launcher/ViewModels/Components/ProfileViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/Components/ProfileViewModel.cs
@@ -5,7 +5,7 @@ using LANCommander.Launcher.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-namespace LANCommander.Launcher.ViewModels.Components;
+namespace LANCommander.Launcher.Avalonia.ViewModels.Components;
public partial class ProfileViewModel : ViewModelBase
{
diff --git a/LANCommander.Launcher/ViewModels/DepotBrowseViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/DepotBrowseViewModel.cs
similarity index 90%
rename from LANCommander.Launcher/ViewModels/DepotBrowseViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/DepotBrowseViewModel.cs
index 17f14a05..2ce4c921 100644
--- a/LANCommander.Launcher/ViewModels/DepotBrowseViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/DepotBrowseViewModel.cs
@@ -4,15 +4,14 @@ using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.Services;
-using LANCommander.Launcher.ViewModels.Components;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.Settings.Enums;
using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-namespace LANCommander.Launcher.ViewModels;
+namespace LANCommander.Launcher.Avalonia.ViewModels;
/// Which dimension (if any) of the initial navigation is locked and cannot be cleared.
public enum LockedFilterKind { None, Genre, Tag, Collection }
@@ -21,7 +20,6 @@ public partial class DepotBrowseViewModel : GamesCollectionViewModel
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
- private readonly INavigationService _navigationService;
// ── Locked filter ─────────────────────────────────────────────────────────
@@ -59,13 +57,16 @@ public partial class DepotBrowseViewModel : GamesCollectionViewModel
public override bool ShowInLibraryFilter => true;
public override bool ShowInstalledFilter => false;
+ // ── Events ────────────────────────────────────────────────────────────────
+
+ public event EventHandler? BackToDepotRequested;
+
// ─────────────────────────────────────────────────────────────────────────
public DepotBrowseViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_logger = serviceProvider.GetRequiredService>();
- _navigationService = serviceProvider.GetRequiredService();
}
///
@@ -112,14 +113,14 @@ public partial class DepotBrowseViewModel : GamesCollectionViewModel
{
var genre = AvailableGenres.FirstOrDefault(g =>
string.Equals(g.Name, preFilterGenre, StringComparison.OrdinalIgnoreCase));
-
if (genre != null)
SelectedGenre = genre;
}
if (!string.IsNullOrEmpty(preFilterTag))
{
- SelectedTag = AvailableTags.FirstOrDefault(t => string.Equals(t, preFilterTag, StringComparison.OrdinalIgnoreCase));
+ SelectedTag = AvailableTags.FirstOrDefault(t =>
+ string.Equals(t, preFilterTag, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrEmpty(preFilterCollection))
@@ -141,7 +142,7 @@ public partial class DepotBrowseViewModel : GamesCollectionViewModel
public override Task LoadGamesAsync() => Task.CompletedTask;
[RelayCommand]
- private void GoBack() => _navigationService.GoBack();
+ private void GoBack() => BackToDepotRequested?.Invoke(this, EventArgs.Empty);
///
/// Clears user-added filters while preserving the locked initial filter.
@@ -178,8 +179,7 @@ public partial class DepotBrowseViewModel : GamesCollectionViewModel
if (IsOfflineMode)
{
var gameService = scope.ServiceProvider.GetRequiredService();
- var localGame = await gameService.GetAsync(gameItem.Id);
-
+ var localGame = await gameService.GetAsync(gameItem.Id);
if (localGame != null)
RaiseGameSelected(new SDK.Models.Game
{
@@ -193,11 +193,8 @@ public partial class DepotBrowseViewModel : GamesCollectionViewModel
else
{
var gameClient = scope.ServiceProvider.GetRequiredService();
-
- var game = await gameClient.GetAsync(gameItem.Id);
-
- if (game != null)
- RaiseGameSelected(game);
+ var game = await gameClient.GetAsync(gameItem.Id);
+ if (game != null) RaiseGameSelected(game);
}
}
catch (Exception ex)
diff --git a/LANCommander.Launcher/ViewModels/DepotGameDetailViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/DepotGameDetailViewModel.cs
similarity index 90%
rename from LANCommander.Launcher/ViewModels/DepotGameDetailViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/DepotGameDetailViewModel.cs
index f708b662..a2d155d3 100644
--- a/LANCommander.Launcher/ViewModels/DepotGameDetailViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/DepotGameDetailViewModel.cs
@@ -1,6 +1,6 @@
using System;
-namespace LANCommander.Launcher.ViewModels;
+namespace LANCommander.Launcher.Avalonia.ViewModels;
///
/// Depot-specific game detail view model. Extends
diff --git a/LANCommander.Launcher/ViewModels/DepotViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/DepotViewModel.cs
similarity index 80%
rename from LANCommander.Launcher/ViewModels/DepotViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/DepotViewModel.cs
index 8948620b..b1d1b93f 100644
--- a/LANCommander.Launcher/ViewModels/DepotViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/DepotViewModel.cs
@@ -1,12 +1,12 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.IO;
using System.Linq;
-using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.ViewModels.Components;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Models;
@@ -14,18 +14,13 @@ using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-namespace LANCommander.Launcher.ViewModels;
+namespace LANCommander.Launcher.Avalonia.ViewModels;
public partial class DepotViewModel : ViewModelBase
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
- // Serializes loads so overlapping invocations (e.g. a depot load still in flight
- // when LibraryChanged fires on install/uninstall) never mutate the carousels
- // concurrently, which corrupts the ObservableCollections.
- private readonly SemaphoreSlim _loadLock = new(1, 1);
-
// ── State ────────────────────────────────────────────────────────────────
[ObservableProperty] private bool _isLoading;
@@ -43,7 +38,6 @@ public partial class DepotViewModel : ViewModelBase
[ObservableProperty] private bool _hasBrowseGenres;
[ObservableProperty] private bool _hasBrowseTags;
[ObservableProperty] private bool _hasBrowseCollections;
- [ObservableProperty] private bool _hasContent;
// ── Carousels ─────────────────────────────────────────────────────────────
@@ -83,20 +77,6 @@ public partial class DepotViewModel : ViewModelBase
[RelayCommand]
private async Task LoadInternalAsync()
- {
- await _loadLock.WaitAsync();
-
- try
- {
- await LoadCoreAsync();
- }
- finally
- {
- _loadLock.Release();
- }
- }
-
- private async Task LoadCoreAsync()
{
IsLoading = true;
HasError = false;
@@ -130,7 +110,6 @@ public partial class DepotViewModel : ViewModelBase
// Collect base game list
var allGames = new List();
-
foreach (var item in depotItems ?? [])
{
if (item.DataItem is DepotGame dg &&
@@ -139,20 +118,21 @@ public partial class DepotViewModel : ViewModelBase
allGames.Add(dg);
}
- // Resolve library membership in one query; build cover URLs (streamed on demand,
- // never downloaded to disk).
- var coverCache = new Dictionary();
- var coverMimeCache = new Dictionary();
- var librarySet = await libraryService.GetLibraryGameIdsAsync();
+ // Parallel: download covers + resolve library membership
+ var coverCache = new Dictionary();
+ var librarySet = new HashSet();
- foreach (var game in allGames)
+ await Task.Run(async () =>
{
- if (game.Cover != null)
+ foreach (var game in allGames)
{
- coverCache[game.Id] = MediaUrl(game.Cover, mediaClient);
- coverMimeCache[game.Id] = game.Cover.MimeType;
+ if (await libraryService.IsInLibraryAsync(game.Id))
+ librarySet.Add(game.Id);
+
+ if (game.Cover != null)
+ coverCache[game.Id] = await GetOrDownloadMediaAsync(game.Cover, mediaClient);
}
- }
+ });
// ── Popular games: newest 10 (by CreatedOn desc), fetch full data for hero+logo ──
@@ -170,7 +150,7 @@ public partial class DepotViewModel : ViewModelBase
// ── New Releases: top 20 by ReleasedOn desc ──────────────────────────────────────
foreach (var game in allGames.OrderByDescending(g => g.ReleasedOn).Take(20))
- NewReleases.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), coverMimeCache.GetValueOrDefault(game.Id), librarySet.Contains(game.Id)));
+ NewReleases.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), librarySet.Contains(game.Id)));
// ── Multiplayer: games with any multiplayer mode ──────────────────────────────────
@@ -178,7 +158,7 @@ public partial class DepotViewModel : ViewModelBase
.Where(g => g.MultiplayerModes?.Any() == true)
.OrderBy(g => g.SortTitle ?? g.Title)
.Take(20))
- MultiplayerGames.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), coverMimeCache.GetValueOrDefault(game.Id), librarySet.Contains(game.Id)));
+ MultiplayerGames.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), librarySet.Contains(game.Id)));
// ── Backlog: library games ────────────────────────────────────────────────────────
@@ -186,7 +166,7 @@ public partial class DepotViewModel : ViewModelBase
.Where(g => librarySet.Contains(g.Id))
.OrderBy(g => g.SortTitle ?? g.Title)
.Take(20))
- BacklogGames.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), coverMimeCache.GetValueOrDefault(game.Id), inLibrary: true, showInLibraryBadge: false));
+ BacklogGames.Add(new GameItemViewModel(game, coverCache.GetValueOrDefault(game.Id), inLibrary: true, showInLibraryBadge: false));
// ── Browse data ───────────────────────────────────────────────────────────────────
@@ -202,7 +182,6 @@ public partial class DepotViewModel : ViewModelBase
{
if (!genreGamesMap.TryGetValue(g.Name, out var list))
genreGamesMap[g.Name] = list = new List();
-
list.Add(game);
}
@@ -212,7 +191,6 @@ public partial class DepotViewModel : ViewModelBase
{
if (!collectionGamesMap.TryGetValue(c.Name, out var list))
collectionGamesMap[c.Name] = list = new List();
-
list.Add(game);
}
@@ -234,13 +212,11 @@ public partial class DepotViewModel : ViewModelBase
.Select(async kv =>
{
var popularRep = kv.Value.FirstOrDefault(g => popularHeroMap.ContainsKey(g.Id));
-
if (popularRep != null)
return (Name: kv.Key, HeroPath: popularHeroMap[popularRep.Id]);
var rep = kv.Value[rng.Next(kv.Value.Count)];
var heroPath = await FetchGameHeroAsync(rep, mediaClient, gameClient);
-
return (Name: kv.Key, HeroPath: heroPath);
})
.ToList();
@@ -255,13 +231,11 @@ public partial class DepotViewModel : ViewModelBase
.Select(async kv =>
{
var popularRep = kv.Value.FirstOrDefault(g => popularHeroMap.ContainsKey(g.Id));
-
if (popularRep != null)
return (Name: kv.Key, HeroPath: popularHeroMap[popularRep.Id]);
var rep = kv.Value[rng.Next(kv.Value.Count)];
var heroPath = await FetchGameHeroAsync(rep, mediaClient, gameClient);
-
return (Name: kv.Key, HeroPath: heroPath);
})
.ToList();
@@ -282,7 +256,6 @@ public partial class DepotViewModel : ViewModelBase
HasBrowseTags = BrowseTags.Count > 0;
HasBrowseCollections = BrowseCollections.Count > 0;
HasBrowseData = HasBrowseGenres || HasBrowseTags || HasBrowseCollections;
- HasContent = HasPopularGames || HasNewReleases || HasMultiplayerGames || HasBacklogGames || HasBrowseData;
_logger.LogInformation(
"Depot home loaded — popular:{P} new:{N} mp:{M} backlog:{B}",
@@ -312,14 +285,11 @@ public partial class DepotViewModel : ViewModelBase
string? GetLocalPath(MediaType type)
{
var media = game.Media?.FirstOrDefault(m => m.Type == type);
-
return (media != null && mediaService.FileExists(media))
? mediaService.GetImagePath(media) : null;
}
- var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover);
- var vm = new GameItemViewModel(game, GetLocalPath(MediaType.Cover), coverMedia?.MimeType, inLibrary: true, showInLibraryBadge: false);
-
+ var vm = new GameItemViewModel(game, GetLocalPath(MediaType.Cover), inLibrary: true, showInLibraryBadge: false);
vm.HeroPath = GetLocalPath(MediaType.Background);
vm.LogoPath = GetLocalPath(MediaType.Logo);
@@ -333,7 +303,6 @@ public partial class DepotViewModel : ViewModelBase
HasBrowseGenres = false;
HasBrowseTags = false;
HasBrowseCollections = false;
- HasContent = HasPopularGames || HasBacklogGames;
}
// ── Commands ──────────────────────────────────────────────────────────────
@@ -341,8 +310,7 @@ public partial class DepotViewModel : ViewModelBase
[RelayCommand]
private async Task ViewGameAsync(GameItemViewModel? item)
{
- if (item == null)
- return;
+ if (item == null) return;
try
{
@@ -351,9 +319,7 @@ public partial class DepotViewModel : ViewModelBase
if (IsOfflineMode)
{
var gameService = scope.ServiceProvider.GetRequiredService();
-
var local = await gameService.GetAsync(item.Id);
-
if (local != null)
GameSelected?.Invoke(this, new SDK.Models.Game
{
@@ -367,11 +333,8 @@ public partial class DepotViewModel : ViewModelBase
else
{
var gameClient = scope.ServiceProvider.GetRequiredService();
-
var game = await gameClient.GetAsync(item.Id);
-
- if (game != null)
- GameSelected?.Invoke(this, game);
+ if (game != null) GameSelected?.Invoke(this, game);
}
}
catch (Exception ex)
@@ -388,20 +351,16 @@ public partial class DepotViewModel : ViewModelBase
}
[RelayCommand]
- private void BrowseByGenre(string name)
- => BrowseByGenreRequested?.Invoke(this, name);
+ private void BrowseByGenre(string name) => BrowseByGenreRequested?.Invoke(this, name);
[RelayCommand]
- private void BrowseByTag(string name)
- => BrowseByTagRequested?.Invoke(this, name);
+ private void BrowseByTag(string name) => BrowseByTagRequested?.Invoke(this, name);
[RelayCommand]
- private void BrowseByCollection(string name)
- => BrowseByCollectionRequested?.Invoke(this, name);
+ private void BrowseByCollection(string name) => BrowseByCollectionRequested?.Invoke(this, name);
[RelayCommand]
- private void BrowseAll()
- => BrowseAllRequested?.Invoke(this, EventArgs.Empty);
+ private void BrowseAll() => BrowseAllRequested?.Invoke(this, EventArgs.Empty);
// ── Helpers ───────────────────────────────────────────────────────────────
@@ -414,27 +373,22 @@ public partial class DepotViewModel : ViewModelBase
try
{
var game = await gameClient.GetAsync(depotGame.Id);
-
- if (game == null)
- return null;
+ if (game == null) return null;
var inLibrary = librarySet.Contains(game.Id);
- var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover);
- var coverPath = MediaUrl(coverMedia, mediaClient);
- var heroPath = MediaUrl(game.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
- var logoPath = MediaUrl(game.Media?.FirstOrDefault(m => m.Type == MediaType.Logo), mediaClient);
+ var coverPath = await GetOrDownloadMediaAsync(game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover), mediaClient);
+ var heroPath = await GetOrDownloadMediaAsync(game.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
+ var logoPath = await GetOrDownloadMediaAsync(game.Media?.FirstOrDefault(m => m.Type == MediaType.Logo), mediaClient);
- var vm = new GameItemViewModel(depotGame, coverPath, coverMedia?.MimeType, inLibrary);
-
+ var vm = new GameItemViewModel(depotGame, coverPath, inLibrary);
vm.HeroPath = heroPath;
vm.LogoPath = logoPath;
-
return vm;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch full game data for {Id}", depotGame.Id);
- return new GameItemViewModel(depotGame, null, null, librarySet.Contains(depotGame.Id));
+ return new GameItemViewModel(depotGame, null, librarySet.Contains(depotGame.Id));
}
}
@@ -443,8 +397,7 @@ public partial class DepotViewModel : ViewModelBase
try
{
var game = await gameClient.GetAsync(depotGame.Id);
-
- return MediaUrl(game?.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
+ return await GetOrDownloadMediaAsync(game?.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient);
}
catch
{
@@ -452,17 +405,19 @@ public partial class DepotViewModel : ViewModelBase
}
}
- // Depot media is streamed from the server on demand (see RemoteImageCache / AsyncImage),
- // never persisted to disk. Still images use the server-resized thumbnail; animated
- // (video) covers use the range-capable stream endpoint.
- private static string? MediaUrl(Media? media, MediaClient mediaClient)
+ private static async Task GetOrDownloadMediaAsync(Media? media, MediaClient mediaClient)
{
- if (media == null)
+ if (media == null) return null;
+ try
+ {
+ var localPath = mediaClient.GetLocalPath(media);
+ if (File.Exists(localPath)) return localPath;
+ var file = await mediaClient.DownloadAsync(media, localPath);
+ return file.Exists ? file.FullName : null;
+ }
+ catch
+ {
return null;
-
- if (media.MimeType?.StartsWith("video/", StringComparison.OrdinalIgnoreCase) == true)
- return mediaClient.GetAbsoluteStreamUrl(media);
-
- return mediaClient.GetAbsoluteThumbnailUrl(media);
+ }
}
}
diff --git a/LANCommander.Launcher.Avalonia/ViewModels/DownloadQueueViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/DownloadQueueViewModel.cs
new file mode 100644
index 00000000..1cde4fca
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/ViewModels/DownloadQueueViewModel.cs
@@ -0,0 +1,409 @@
+using System;
+using System.Collections.ObjectModel;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.Launcher.Avalonia.Services;
+using LANCommander.Launcher.Models;
+using LANCommander.Launcher.Services;
+using LANCommander.SDK.Enums;
+using LANCommander.SDK.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+///
+/// ViewModel for the download/install queue panel
+///
+public partial class DownloadQueueViewModel : ViewModelBase
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+ private readonly NotificationService _notificationService;
+ private readonly TaskbarProgressService _taskbarProgressService;
+ private InstallService? _installService;
+
+ [ObservableProperty]
+ private bool _isExpanded;
+
+ [ObservableProperty]
+ private ObservableCollection _queueItems = new();
+
+ [ObservableProperty]
+ private InstallQueueItemViewModel? _currentItem;
+
+ [ObservableProperty]
+ private string _currentStatus = string.Empty;
+
+ [ObservableProperty]
+ private float _currentProgress;
+
+ [ObservableProperty]
+ private string _currentProgressText = string.Empty;
+
+ [ObservableProperty]
+ private long _currentTransferSpeed;
+
+ [ObservableProperty]
+ private string _transferSpeedText = string.Empty;
+
+ [ObservableProperty]
+ private string _timeRemainingText = string.Empty;
+
+ [ObservableProperty]
+ private bool _hasActiveDownload;
+
+ [ObservableProperty]
+ private bool _hasQueuedItems;
+
+ [ObservableProperty]
+ private bool _hasCompletedItems;
+
+ [ObservableProperty]
+ private bool _hasItems;
+
+ [ObservableProperty]
+ private bool _hasPendingItems;
+
+ [ObservableProperty]
+ private int _activeCount;
+
+ public event EventHandler? InstallCompleted;
+ public event EventHandler? BackRequested;
+
+ public DownloadQueueViewModel(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = serviceProvider.GetRequiredService>();
+ _notificationService = serviceProvider.GetRequiredService();
+ _taskbarProgressService = serviceProvider.GetRequiredService();
+ }
+
+ public void Initialize()
+ {
+ // InstallService is a singleton — resolve directly from the root provider,
+ // not through a child scope that would be disposed immediately.
+ _installService = _serviceProvider.GetRequiredService();
+
+ _installService.OnQueueChanged += OnQueueChanged;
+ _installService.OnProgress += OnProgress;
+ _installService.OnInstallComplete += OnInstallComplete;
+ _installService.OnInstallFail += OnInstallFail;
+
+ RefreshQueue();
+ }
+
+ private Task OnQueueChanged()
+ {
+ Dispatcher.UIThread.Post(async () => await RefreshQueueAsync());
+ return Task.CompletedTask;
+ }
+
+ private Task OnProgress(InstallProgress progress)
+ {
+ _taskbarProgressService.SetProgress(progress.Progress);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ CurrentStatus = GetDisplayName(progress.Status);
+ CurrentProgress = progress.Progress;
+ CurrentTransferSpeed = progress.TransferSpeed;
+
+ // Format progress text
+ var bytesDownloaded = FormatBytes(progress.BytesTransferred);
+ var totalBytes = FormatBytes(progress.TotalBytes);
+ CurrentProgressText = $"{bytesDownloaded} / {totalBytes} ({progress.Progress:P0})";
+
+ // Format transfer speed
+ TransferSpeedText = $"{FormatBytes(progress.TransferSpeed)}/s";
+
+ // Format time remaining
+ var bytesRemaining = progress.TotalBytes - progress.BytesTransferred;
+ if (progress.TransferSpeed > 0 && bytesRemaining > 0)
+ {
+ var seconds = (double)bytesRemaining / progress.TransferSpeed;
+ var ts = TimeSpan.FromSeconds(seconds);
+ TimeRemainingText = ts.TotalHours >= 1
+ ? $"{(int)ts.TotalHours}h {ts.Minutes}m remaining"
+ : ts.TotalMinutes >= 1
+ ? $"{ts.Minutes}m {ts.Seconds}s remaining"
+ : $"{ts.Seconds}s remaining";
+ }
+ else
+ {
+ TimeRemainingText = string.Empty;
+ }
+
+ // Update the matching queue item
+ var item = QueueItems.FirstOrDefault(i => i.Id == progress.Game?.Id);
+ if (item != null)
+ {
+ item.Status = progress.Status;
+ item.Progress = progress.Progress;
+ item.TransferSpeed = progress.TransferSpeed;
+ item.BytesDownloaded = progress.BytesTransferred;
+ item.TotalBytes = progress.TotalBytes;
+
+ // Ensure footer visibility and CurrentItem are up-to-date
+ // without waiting for the next OnQueueChanged cycle
+ if (!HasActiveDownload)
+ HasActiveDownload = true;
+
+ CurrentItem ??= item;
+ }
+ });
+
+ return Task.CompletedTask;
+ }
+
+ private async Task OnInstallComplete(Data.Models.Game game)
+ {
+ _logger.LogInformation("Install complete for game {GameTitle}", game.Title);
+
+ _taskbarProgressService.ClearProgress();
+
+ // Resolve icon and grid art paths for the notification
+ string? iconPath = null;
+ string? gridPath = null;
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var mediaService = scope.ServiceProvider.GetRequiredService();
+
+ var icon = await mediaService.FirstOrDefaultAsync(m => m.GameId == game.Id && m.Type == MediaType.Icon);
+ if (icon != null && mediaService.FileExists(icon))
+ iconPath = mediaService.GetImagePath(icon);
+
+ var grid = await mediaService.FirstOrDefaultAsync(m => m.GameId == game.Id && m.Type == MediaType.Grid);
+ if (grid != null && mediaService.FileExists(grid))
+ gridPath = mediaService.GetImagePath(grid);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to resolve media for notification");
+ }
+
+ _notificationService.NotifyInstallComplete(game.Title ?? "Game", iconPath, gridPath, game.Id);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ RefreshQueue();
+ InstallCompleted?.Invoke(this, game.Id);
+ });
+ }
+
+ private Task OnInstallFail(Data.Models.Game game)
+ {
+ _logger.LogError("Install failed for game {GameTitle}", game.Title);
+
+ _taskbarProgressService.ClearProgress();
+ _notificationService.NotifyInstallFailed(game.Title ?? "Game", game.Id);
+
+ Dispatcher.UIThread.Post(RefreshQueue);
+ return Task.CompletedTask;
+ }
+
+ private void RefreshQueue() => _ = RefreshQueueAsync();
+
+ private async Task RefreshQueueAsync()
+ {
+ if (_installService == null) return;
+
+ QueueItems.Clear();
+
+ foreach (var item in _installService.Queue)
+ {
+ var vm = new InstallQueueItemViewModel(item);
+
+ // Resolve icon path from the media database
+ if (vm.IconId != Guid.Empty)
+ {
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var mediaService = scope.ServiceProvider.GetRequiredService();
+ if (await mediaService.FileExists(vm.IconId))
+ {
+ vm.IconPath = await mediaService.GetImagePath(vm.IconId);
+ vm.HasIcon = !string.IsNullOrEmpty(vm.IconPath);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to resolve icon for queue item {Title}", vm.Title);
+ }
+ }
+
+ QueueItems.Add(vm);
+ }
+
+ // Update state flags
+ HasActiveDownload = QueueItems.Any(i => i.IsActive);
+ HasQueuedItems = QueueItems.Any(i => i.Status == InstallStatus.Queued);
+ HasCompletedItems = QueueItems.Any(i => i.Status == InstallStatus.Complete);
+ HasItems = QueueItems.Any();
+ ActiveCount = QueueItems.Count(i => i.IsActive || i.Status == InstallStatus.Queued);
+ HasPendingItems = ActiveCount > 0;
+
+ CurrentItem = QueueItems.FirstOrDefault(i => i.IsActive);
+
+ // Auto-expand when there's an active download
+ if (HasActiveDownload && !IsExpanded)
+ {
+ IsExpanded = true;
+ }
+ }
+
+ [RelayCommand]
+ private void Back() => BackRequested?.Invoke(this, EventArgs.Empty);
+
+ [RelayCommand]
+ public void ToggleExpanded()
+ {
+ IsExpanded = !IsExpanded;
+ }
+
+ [RelayCommand]
+ public void Show()
+ {
+ IsExpanded = true;
+ }
+
+ [RelayCommand]
+ private async Task CancelAsync(InstallQueueItemViewModel? item)
+ {
+ if (item == null || _installService == null) return;
+
+ _logger.LogInformation("Canceling install for {Title}", item.Title);
+ await _installService.CancelInstallAsync(item.Id);
+ }
+
+ [RelayCommand]
+ private void ClearCompleted()
+ {
+ if (_installService == null) return;
+ foreach (var item in QueueItems.Where(i => i.IsCompleted || i.IsFailed).ToList())
+ _installService.Remove(item.Id);
+ }
+
+ [RelayCommand]
+ private void Remove(InstallQueueItemViewModel? item)
+ {
+ if (item == null || _installService == null) return;
+
+ _logger.LogInformation("Removing {Title} from queue", item.Title);
+ _installService.Remove(item.Id);
+ }
+
+ private static string GetDisplayName(InstallStatus status)
+ {
+ var member = typeof(InstallStatus).GetField(status.ToString());
+ var display = member?.GetCustomAttribute();
+ return display?.Name ?? status.ToString();
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ string[] sizes = { "B", "KB", "MB", "GB", "TB" };
+ int order = 0;
+ double size = bytes;
+
+ while (size >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ size /= 1024;
+ }
+
+ return $"{size:0.##} {sizes[order]}";
+ }
+}
+
+///
+/// ViewModel for an individual queue item
+///
+public partial class InstallQueueItemViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private Guid _id;
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private InstallStatus _status;
+
+ [ObservableProperty]
+ private float _progress;
+
+ [ObservableProperty]
+ private long _transferSpeed;
+
+ [ObservableProperty]
+ private long _bytesDownloaded;
+
+ [ObservableProperty]
+ private long _totalBytes;
+
+ [ObservableProperty]
+ private Guid _coverId;
+
+ [ObservableProperty]
+ private Guid _iconId;
+
+ [ObservableProperty]
+ private string? _iconPath;
+
+ [ObservableProperty]
+ private bool _hasIcon;
+
+ [ObservableProperty]
+ private bool _isUpdate;
+
+ public bool IsActive => Status != InstallStatus.Queued &&
+ Status != InstallStatus.Complete &&
+ Status != InstallStatus.Failed &&
+ Status != InstallStatus.Canceled;
+
+ public bool IsQueued => Status == InstallStatus.Queued;
+ public bool IsCompleted => Status == InstallStatus.Complete;
+ public bool IsFailed => Status == InstallStatus.Failed;
+
+ public string ProgressText => $"{FormatBytes(BytesDownloaded)} / {FormatBytes(TotalBytes)}";
+ public string SpeedText => $"{FormatBytes(TransferSpeed)}/s";
+
+ public InstallQueueItemViewModel() { }
+
+ public InstallQueueItemViewModel(IInstallQueueItem item)
+ {
+ Id = item.Id;
+ Title = item.Title;
+ Status = item.Status;
+ Progress = item.Progress;
+ TransferSpeed = (long)item.TransferSpeed;
+ BytesDownloaded = item.BytesDownloaded;
+ TotalBytes = item.TotalBytes;
+ CoverId = item.CoverId;
+ IconId = item.IconId;
+ IsUpdate = item.IsUpdate;
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ string[] sizes = { "B", "KB", "MB", "GB", "TB" };
+ int order = 0;
+ double size = bytes;
+
+ while (size >= 1024 && order < sizes.Length - 1)
+ {
+ order++;
+ size /= 1024;
+ }
+
+ return $"{size:0.##} {sizes[order]}";
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs
similarity index 51%
rename from LANCommander.Launcher/ViewModels/GameDetailViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs
index 2201d740..60ccf4f9 100644
--- a/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/GameDetailViewModel.cs
@@ -1,543 +1,466 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.IO;
-using System.Linq;
-using System.Threading.Tasks;
-using Avalonia.Media.Imaging;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.Services;
-using LANCommander.Launcher.ViewModels.Components;
-using LANCommander.Launcher.Services;
-using LANCommander.SDK.Enums;
-using LANCommander.SDK.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace LANCommander.Launcher.ViewModels;
-
-public partial class GameDetailViewModel : ViewModelBase
-{
- ///
- /// In-memory cache of carousel media metadata per game, so skeletons can be shown
- /// immediately on repeat visits without waiting for the API.
- ///
- private static readonly ConcurrentDictionary> _mediaCache = new();
-
- private record CachedMediaEntry(MediaType Type, string? MimeType);
-
- private readonly IServiceProvider _serviceProvider;
- private readonly INavigationService _navigationService;
- private readonly ILogger _logger;
-
- [ObservableProperty]
- private Guid _id;
-
- [ObservableProperty]
- private string _title = string.Empty;
-
- [ObservableProperty]
- private string _description = string.Empty;
-
- [ObservableProperty]
- private string? _coverPath;
-
- [ObservableProperty]
- private string? _coverMimeType;
-
- [ObservableProperty]
- private string? _logoPath;
-
- [ObservableProperty]
- private string? _backgroundPath;
-
- [ObservableProperty]
- private string? _iconPath;
-
- [ObservableProperty]
- private DateTime _releasedOn;
-
- [ObservableProperty]
- private string _releaseYear = string.Empty;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
- private bool _singleplayer;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(GenreList))]
- private string _genres = string.Empty;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(DeveloperList))]
- private string _developers = string.Empty;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(PublisherList))]
- private string _publishers = string.Empty;
-
- [ObservableProperty]
- private string _platforms = string.Empty;
-
- [ObservableProperty]
- private string _multiplayerModes = string.Empty;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(TagList))]
- [NotifyPropertyChangedFor(nameof(VisibleTagList))]
- [NotifyPropertyChangedFor(nameof(HasMoreTags))]
- [NotifyPropertyChangedFor(nameof(ExtraTagCount))]
- [NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
- private string _tags = string.Empty;
-
- // ── Tags expand/collapse ─────────────���────────────────────────────────────
-
- private const int TagsVisibleLimit = 5;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(VisibleTagList))]
- [NotifyPropertyChangedFor(nameof(HasMoreTags))]
- [NotifyPropertyChangedFor(nameof(ExtraTagCount))]
- [NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
- private bool _tagsExpanded;
-
- public IEnumerable VisibleTagList =>
- TagsExpanded ? TagList : TagList.Take(TagsVisibleLimit);
-
- public bool HasMoreTags => TagList.Count() > TagsVisibleLimit;
- public int ExtraTagCount => Math.Max(0, TagList.Count() - TagsVisibleLimit);
- public string ShowMoreTagsLabel =>
- TagsExpanded ? "Show less" : $"+{ExtraTagCount} more";
-
- [RelayCommand]
- private void ToggleTagsExpanded() => TagsExpanded = !TagsExpanded;
-
- // ── Screenshots / videos ──────────────────────���────────────────────────���──
-
- public ObservableCollection MediaItems { get; } = new();
-
- public bool HasMedia => MediaItems.Count > 0;
-
- // ── Tools ─────────────────────────────────────────────────────────────────
-
- public ObservableCollection Tools { get; } = new();
-
- public bool HasTools => Tools.Count > 0;
-
- // ── Multiplayer modes ─────────────────────────────────────────────────────
-
- public ObservableCollection MultiplayerModeDetails { get; } = new();
-
- // ── Other ─────────────────────────���───────────────────────────────────────
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(BackLabel))]
- private bool _fromLibrary;
-
- public string BackLabel => FromLibrary ? "Back to Library" : "Back to Depot";
-
- // Split list properties for chip rendering
- public IEnumerable GenreList => SplitCsv(Genres);
- public IEnumerable DeveloperList => SplitCsv(Developers);
- public IEnumerable PublisherList => SplitCsv(Publishers);
- public IEnumerable TagList => SplitCsv(Tags);
-
- private static IEnumerable SplitCsv(string csv) =>
- csv.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0);
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
- private bool _hasMultiplayer;
-
- public bool HasPlayerInfo => Singleplayer || HasMultiplayer;
-
- [ObservableProperty]
- private bool _isLoadingMedia;
-
- private bool _isOfflineMode;
- public bool IsOfflineMode
- {
- get => _isOfflineMode;
- set
- {
- if (SetProperty(ref _isOfflineMode, value))
- {
- ActionBar.IsOfflineMode = value;
- }
- }
- }
-
- // Action bar component
- public GameActionBarViewModel ActionBar { get; }
-
- public event EventHandler? LibraryChanged;
- public event EventHandler? InstallRequested;
- public event EventHandler? SearchRequested;
-
- public GameDetailViewModel(IServiceProvider serviceProvider)
- {
- _serviceProvider = serviceProvider;
- _logger = serviceProvider.GetRequiredService>();
- _navigationService = serviceProvider.GetRequiredService();
-
- // Create action bar and wire up events
- ActionBar = new GameActionBarViewModel(serviceProvider);
- ActionBar.LibraryChanged += (_, _) => LibraryChanged?.Invoke(this, EventArgs.Empty);
- ActionBar.InstallRequested += (_, _) => InstallRequested?.Invoke(this, EventArgs.Empty);
- }
-
- ///
- /// Refreshes the install status from the database.
- /// Called after an installation completes.
- ///
- public async Task RefreshInstallStatusAsync()
- {
- if (Id == Guid.Empty)
- return;
-
- await ActionBar.RefreshAsync();
- }
-
- ///
- /// Load game from server API (SDK.Models.Game)
- /// Used when selecting from the depot/all games list.
- /// Loads essential media (cover, logo, background, icon) and metadata inline,
- /// then loads screenshots/videos in the background so the UI is not blocked.
- ///
- public async Task LoadGameAsync(SDK.Models.Game game)
- {
- Id = game.Id;
- Title = game.Title ?? "Unknown";
- Description = game.Description ?? string.Empty;
- ReleasedOn = game.ReleasedOn;
- ReleaseYear = game.ReleasedOn.Year > 1 ? game.ReleasedOn.Year.ToString() : "Unknown";
- Singleplayer = game.Singleplayer;
-
- // Reset media paths while loading
- CoverPath = null;
- CoverMimeType = null;
- LogoPath = null;
- BackgroundPath = null;
- IconPath = null;
-
- // Collections
- Genres = game.Genres != null
- ? string.Join(", ", game.Genres.Select(g => g.Name))
- : string.Empty;
-
- Developers = game.Developers != null
- ? string.Join(", ", game.Developers.Select(d => d.Name))
- : string.Empty;
-
- Publishers = game.Publishers != null
- ? string.Join(", ", game.Publishers.Select(p => p.Name))
- : string.Empty;
-
- Platforms = game.Platforms != null
- ? string.Join(", ", game.Platforms.Select(p => p.Name))
- : string.Empty;
-
- Tags = game.Tags != null
- ? string.Join(", ", game.Tags.Select(t => t.Name))
- : string.Empty;
-
- // Multiplayer info
- HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
- MultiplayerModeDetails.Clear();
-
- if (HasMultiplayer)
- {
- var modes = game.MultiplayerModes!
- .Select(m => m.Type.ToString())
- .Distinct();
- MultiplayerModes = string.Join(", ", modes);
-
- foreach (var mode in game.MultiplayerModes!)
- MultiplayerModeDetails.Add(FormatMultiplayerMode(mode));
- }
- else
- MultiplayerModes = string.Empty;
-
- // Tools
- Tools.Clear();
- await LoadToolsAsync(game);
- OnPropertyChanged(nameof(HasTools));
-
- // Reset media items and tags state while we re-load
- MediaItems.Clear();
- TagsExpanded = false;
-
- // Determine carousel media entries and populate skeletons immediately.
- // Use the game's media list if available, otherwise fall back to cache.
- var carouselMediaEntries = game.Media?
- .Where(m => m.Type == MediaType.Screenshot || m.Type == MediaType.Video)
- .ToList();
-
- if (carouselMediaEntries == null || carouselMediaEntries.Count == 0)
- {
- // Try cached metadata from a previous visit
- if (_mediaCache.TryGetValue(game.Id, out var cached))
- foreach (var _ in cached)
- MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
- }
- else
- {
- // Cache the media metadata for future visits
- _mediaCache[game.Id] = carouselMediaEntries
- .Select(m => new CachedMediaEntry(m.Type, m.MimeType))
- .ToList();
-
- // Add skeleton placeholders so the carousel renders immediately
- foreach (var _ in carouselMediaEntries)
- MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true });
- }
-
- OnPropertyChanged(nameof(HasMedia));
-
- // Load action bar state
- await ActionBar.LoadFromSdkGameAsync(game);
-
- if (game.Media != null && game.Media.Any())
- {
- // Start loading screenshots/videos in the background immediately so videos
- // begin streaming without waiting on the essential media downloads below.
- _ = LoadCarouselMediaAsync(game);
-
- // Load essential media (cover, logo, background, icon) — needed for page layout
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var mediaClient = scope.ServiceProvider.GetRequiredService();
-
- CoverPath = ResolveEssentialMediaPath(game.Media, MediaType.Cover, mediaClient);
- CoverMimeType = game.Media.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType;
- LogoPath = ResolveEssentialMediaPath(game.Media, MediaType.Logo, mediaClient);
- BackgroundPath = ResolveEssentialMediaPath(game.Media, MediaType.Background, mediaClient);
- IconPath = ResolveEssentialMediaPath(game.Media, MediaType.Icon, mediaClient);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to load essential media for game {GameId}", game.Id);
- }
- }
- }
-
- ///
- /// Loads screenshots and videos into the media carousel in the background.
- /// Videos are set up immediately so they begin streaming right away; screenshots
- /// are downloaded/decoded concurrently and pop in as each one finishes, so no item
- /// blocks the others.
- ///
- private async Task LoadCarouselMediaAsync(SDK.Models.Game game)
- {
- if (game.Media == null)
- return;
-
- var carouselMedia = game.Media.Where(m =>
- m.Type == MediaType.Screenshot || m.Type == MediaType.Video).ToList();
-
- if (!carouselMedia.Any())
- return;
-
- IsLoadingMedia = true;
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var mediaClient = scope.ServiceProvider.GetRequiredService();
-
- var screenshotTasks = new List();
-
- for (var i = 0; i < carouselMedia.Count; i++)
- {
- var index = i;
- var media = carouselMedia[i];
-
- if (media.Type == MediaType.Video)
- {
- // Videos only need a stream URL — set them immediately so they don't
- // wait behind screenshot downloads.
- ReplaceMediaItem(index, new GameMediaItemViewModel
- {
- IsVideo = true,
- MimeType = media.MimeType ?? string.Empty,
- Path = mediaClient.GetAbsoluteStreamUrl(media)
- });
- }
- else
- {
- screenshotTasks.Add(LoadScreenshotAsync(index, media, mediaClient));
- }
- }
-
- await Task.WhenAll(screenshotTasks);
-
- // Remove any remaining skeletons (e.g. if some items failed to load)
- for (var i = MediaItems.Count - 1; i >= 0; i--)
- {
- if (MediaItems[i].IsSkeleton)
- MediaItems.RemoveAt(i);
- }
-
- OnPropertyChanged(nameof(HasMedia));
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to load carousel media for game {GameId}", game.Id);
- }
- finally
- {
- IsLoadingMedia = false;
- }
- }
-
- ///
- /// Downloads (if needed) and decodes a single screenshot, then swaps it into the
- /// carousel in place of its skeleton. Runs concurrently with other screenshots.
- ///
- private async Task LoadScreenshotAsync(int index, SDK.Models.Media media, MediaClient mediaClient)
- {
- try
- {
- var localPath = mediaClient.GetLocalPath(media);
-
- if (!File.Exists(localPath))
- {
- var fileInfo = await mediaClient.DownloadAsync(media, localPath);
- localPath = fileInfo.FullName;
- }
-
- // Decode downscaled: the carousel slot is 384 logical px, so ~2x covers
- // HiDPI/UniformToFill without holding the full-resolution source in memory.
- // The lightbox loads the full-res image from Path when it needs it.
- var bitmap = await Task.Run(() =>
- {
- using var stream = File.OpenRead(localPath);
- return Bitmap.DecodeToWidth(stream, 768, BitmapInterpolationMode.HighQuality);
- });
-
- ReplaceMediaItem(index, new GameMediaItemViewModel
- {
- IsVideo = false,
- MimeType = media.MimeType ?? string.Empty,
- Path = localPath,
- ImageSource = bitmap
- });
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to load media {MediaId} from server", media.Id);
- }
- }
-
- ///
- /// Replaces the skeleton at with the loaded item, or appends
- /// it if the index no longer points at a skeleton. Always invoked on the UI thread via
- /// awaited continuations, so collection access is serialized.
- ///
- private void ReplaceMediaItem(int index, GameMediaItemViewModel item)
- {
- if (index < MediaItems.Count && MediaItems[index].IsSkeleton)
- MediaItems[index] = item;
- else
- MediaItems.Add(item);
-
- OnPropertyChanged(nameof(HasMedia));
- }
-
- private static string FormatMultiplayerMode(Data.Models.MultiplayerMode mode) =>
- FormatMultiplayerMode(mode.Type, mode.MinPlayers, mode.MaxPlayers);
-
- private static string FormatMultiplayerMode(SDK.Models.MultiplayerMode mode) =>
- FormatMultiplayerMode(mode.Type, mode.MinPlayers, mode.MaxPlayers);
-
- private static string FormatMultiplayerMode(SDK.Enums.MultiplayerType type, int minPlayers, int maxPlayers)
- {
- var typeLabel = type switch
- {
- SDK.Enums.MultiplayerType.Local => "Local Multiplayer",
- SDK.Enums.MultiplayerType.LAN => "LAN Multiplayer",
- SDK.Enums.MultiplayerType.Online => "Online Multiplayer",
- _ => type.ToString()
- };
-
- if (maxPlayers > 0)
- {
- var range = minPlayers > 1 && minPlayers < maxPlayers
- ? $"{minPlayers}–{maxPlayers} players"
- : $"Up to {maxPlayers} players";
- return $"{typeLabel} · {range}";
- }
-
- return typeLabel;
- }
-
- // Resolves an essential asset (cover/logo/background/icon) to a local path when the import has
- // already cached it, otherwise a server stream URL so it renders instantly without blocking on a
- // download. The background import still caches these to disk for offline use.
- private static string? ResolveEssentialMediaPath(System.Collections.Generic.IEnumerable mediaCollection, MediaType type, MediaClient mediaClient)
- => MediaSourceResolver.Resolve(mediaCollection.FirstOrDefault(m => m.Type == type), mediaClient);
-
- private async Task LoadToolsAsync(SDK.Models.Game game)
- {
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var gameClient = scope.ServiceProvider.GetRequiredService();
- var toolService = scope.ServiceProvider.GetRequiredService();
-
- var tools = game.Tools;
-
- if (tools == null || !tools.Any())
- {
- tools = await gameClient.GetToolsAsync(game.Id);
- }
-
- if (tools != null)
- {
- foreach (var tool in tools)
- {
- var isInstalled = await toolService.IsToolInstalledForGameAsync(game.Id, tool.Id);
- Tools.Add(new ToolItemViewModel(tool, isInstalled));
- }
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to load tools for game {GameId}", game.Id);
- }
- }
-
- [RelayCommand]
- private void GoBack()
- {
- ActionBar.StopRunningCheck();
-
- _navigationService.GoBack();
- }
-
- [RelayCommand]
- private void SearchFor(string term)
- {
- if (!string.IsNullOrWhiteSpace(term))
- SearchRequested?.Invoke(this, term);
- }
-}
-
-///
-/// ViewModel for a tool associated with a game.
-///
-public partial class ToolItemViewModel : ViewModelBase
-{
- public Guid Id { get; }
- public string Name { get; }
- public bool IsInstalled { get; }
-
- public ToolItemViewModel(SDK.Models.Tool tool, bool isInstalled)
- {
- Id = tool.Id;
- Name = tool.Name ?? "Unknown Tool";
- IsInstalled = isInstalled;
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
+using LANCommander.Launcher.Services;
+using LANCommander.SDK.Enums;
+using LANCommander.SDK.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class GameDetailViewModel : ViewModelBase
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ [ObservableProperty]
+ private Guid _id;
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private string _description = string.Empty;
+
+ [ObservableProperty]
+ private string? _coverPath;
+
+ [ObservableProperty]
+ private string? _logoPath;
+
+ [ObservableProperty]
+ private string? _backgroundPath;
+
+ [ObservableProperty]
+ private string? _iconPath;
+
+ [ObservableProperty]
+ private DateTime _releasedOn;
+
+ [ObservableProperty]
+ private string _releaseYear = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
+ private bool _singleplayer;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(GenreList))]
+ private string _genres = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(DeveloperList))]
+ private string _developers = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(PublisherList))]
+ private string _publishers = string.Empty;
+
+ [ObservableProperty]
+ private string _platforms = string.Empty;
+
+ [ObservableProperty]
+ private string _multiplayerModes = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(TagList))]
+ [NotifyPropertyChangedFor(nameof(VisibleTagList))]
+ [NotifyPropertyChangedFor(nameof(HasMoreTags))]
+ [NotifyPropertyChangedFor(nameof(ExtraTagCount))]
+ [NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
+ private string _tags = string.Empty;
+
+ // ── Tags expand/collapse ─────────────���────────────────────────────────────
+
+ private const int TagsVisibleLimit = 5;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(VisibleTagList))]
+ [NotifyPropertyChangedFor(nameof(HasMoreTags))]
+ [NotifyPropertyChangedFor(nameof(ExtraTagCount))]
+ [NotifyPropertyChangedFor(nameof(ShowMoreTagsLabel))]
+ private bool _tagsExpanded;
+
+ public IEnumerable VisibleTagList =>
+ TagsExpanded ? TagList : TagList.Take(TagsVisibleLimit);
+
+ public bool HasMoreTags => TagList.Count() > TagsVisibleLimit;
+ public int ExtraTagCount => Math.Max(0, TagList.Count() - TagsVisibleLimit);
+ public string ShowMoreTagsLabel =>
+ TagsExpanded ? "Show less" : $"+{ExtraTagCount} more";
+
+ [RelayCommand]
+ private void ToggleTagsExpanded() => TagsExpanded = !TagsExpanded;
+
+ // ── Screenshots / videos ──────────────────────���────────────────────────���──
+
+ public ObservableCollection MediaItems { get; } = new();
+
+ public bool HasMedia => MediaItems.Count > 0;
+
+ // ── Multiplayer modes ─────────────────────────────────────────────────────
+
+ public ObservableCollection MultiplayerModeDetails { get; } = new();
+
+ // ── Other ─────────────────────────���───────────────────────────────────────
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(BackLabel))]
+ private bool _fromLibrary;
+
+ public string BackLabel => FromLibrary ? "Back to Library" : "Back to Depot";
+
+ // Split list properties for chip rendering
+ public IEnumerable GenreList => SplitCsv(Genres);
+ public IEnumerable DeveloperList => SplitCsv(Developers);
+ public IEnumerable PublisherList => SplitCsv(Publishers);
+ public IEnumerable TagList => SplitCsv(Tags);
+
+ private static IEnumerable SplitCsv(string csv) =>
+ csv.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0);
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasPlayerInfo))]
+ private bool _hasMultiplayer;
+
+ public bool HasPlayerInfo => Singleplayer || HasMultiplayer;
+
+ [ObservableProperty]
+ private bool _isLoadingMedia;
+
+ private bool _isOfflineMode;
+ public bool IsOfflineMode
+ {
+ get => _isOfflineMode;
+ set
+ {
+ if (SetProperty(ref _isOfflineMode, value))
+ {
+ ActionBar.IsOfflineMode = value;
+ }
+ }
+ }
+
+ // Action bar component
+ public GameActionBarViewModel ActionBar { get; }
+
+ public event EventHandler? BackRequested;
+ public event EventHandler? LibraryChanged;
+ public event EventHandler? InstallRequested;
+ public event EventHandler? SearchRequested;
+
+ public GameDetailViewModel(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = serviceProvider.GetRequiredService>();
+
+ // Create action bar and wire up events
+ ActionBar = new GameActionBarViewModel(serviceProvider);
+ ActionBar.LibraryChanged += (_, _) => LibraryChanged?.Invoke(this, EventArgs.Empty);
+ ActionBar.InstallRequested += (_, _) => InstallRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Refreshes the install status from the database.
+ /// Called after an installation completes.
+ ///
+ public async Task RefreshInstallStatusAsync()
+ {
+ if (Id == Guid.Empty) return;
+ await ActionBar.RefreshAsync();
+ }
+
+ ///
+ /// Load game from local cache (Data.Models.Game)
+ /// Used when selecting from the library sidebar
+ ///
+ public async void LoadGame(Data.Models.Game game)
+ {
+ Id = game.Id;
+ Title = game.Title ?? "Unknown";
+ Description = game.Description ?? string.Empty;
+ ReleasedOn = game.ReleasedOn ?? DateTime.MinValue;
+ ReleaseYear = game.ReleasedOn?.Year > 1 ? game.ReleasedOn.Value.Year.ToString() : "Unknown";
+ Singleplayer = game.Singleplayer;
+
+ // Get media paths from local storage
+ using var scope = _serviceProvider.CreateScope();
+ var mediaService = scope.ServiceProvider.GetRequiredService();
+
+ CoverPath = GetLocalMediaPath(game.Media, MediaType.Cover, mediaService);
+ LogoPath = GetLocalMediaPath(game.Media, MediaType.Logo, mediaService);
+ BackgroundPath = GetLocalMediaPath(game.Media, MediaType.Background, mediaService);
+ IconPath = GetLocalMediaPath(game.Media, MediaType.Icon, mediaService);
+
+ // Collections
+ Genres = game.Genres != null
+ ? string.Join(", ", game.Genres.Select(g => g.Name))
+ : string.Empty;
+
+ Developers = game.Developers != null
+ ? string.Join(", ", game.Developers.Select(d => d.Name))
+ : string.Empty;
+
+ Publishers = game.Publishers != null
+ ? string.Join(", ", game.Publishers.Select(p => p.Name))
+ : string.Empty;
+
+ Platforms = game.Platforms != null
+ ? string.Join(", ", game.Platforms.Select(p => p.Name))
+ : string.Empty;
+
+ Tags = game.Tags != null
+ ? string.Join(", ", game.Tags.Select(t => t.Name))
+ : string.Empty;
+
+ // Multiplayer info
+ HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
+ MultiplayerModeDetails.Clear();
+ if (HasMultiplayer)
+ {
+ var modes = game.MultiplayerModes!
+ .Select(m => m.Type.ToString())
+ .Distinct();
+ MultiplayerModes = string.Join(", ", modes);
+ foreach (var mode in game.MultiplayerModes!)
+ MultiplayerModeDetails.Add(FormatMultiplayerMode(mode));
+ }
+ else
+ {
+ MultiplayerModes = string.Empty;
+ }
+
+ // Media items (screenshots / videos from local cache)
+ MediaItems.Clear();
+ TagsExpanded = false;
+ if (game.Media != null)
+ {
+ foreach (var m in game.Media.Where(m =>
+ m.Type == MediaType.Screenshot || m.Type == MediaType.Video))
+ {
+ var path = mediaService.FileExists(m) ? mediaService.GetImagePath(m) : null;
+ if (path != null)
+ MediaItems.Add(new GameMediaItemViewModel
+ {
+ Path = path,
+ IsVideo = m.Type == MediaType.Video,
+ MimeType = string.Empty
+ });
+ }
+ }
+ OnPropertyChanged(nameof(HasMedia));
+
+ // Load action bar state
+ await ActionBar.LoadFromLocalGameAsync(game);
+ }
+
+ ///
+ /// Load game from server API (SDK.Models.Game)
+ /// Used when selecting from the depot/all games list
+ ///
+ public async Task LoadGameAsync(SDK.Models.Game game)
+ {
+ Id = game.Id;
+ Title = game.Title ?? "Unknown";
+ Description = game.Description ?? string.Empty;
+ ReleasedOn = game.ReleasedOn;
+ ReleaseYear = game.ReleasedOn.Year > 1 ? game.ReleasedOn.Year.ToString() : "Unknown";
+ Singleplayer = game.Singleplayer;
+
+ // Reset media paths while loading
+ CoverPath = null;
+ LogoPath = null;
+ BackgroundPath = null;
+ IconPath = null;
+
+ // Collections
+ Genres = game.Genres != null
+ ? string.Join(", ", game.Genres.Select(g => g.Name))
+ : string.Empty;
+
+ Developers = game.Developers != null
+ ? string.Join(", ", game.Developers.Select(d => d.Name))
+ : string.Empty;
+
+ Publishers = game.Publishers != null
+ ? string.Join(", ", game.Publishers.Select(p => p.Name))
+ : string.Empty;
+
+ Platforms = game.Platforms != null
+ ? string.Join(", ", game.Platforms.Select(p => p.Name))
+ : string.Empty;
+
+ Tags = game.Tags != null
+ ? string.Join(", ", game.Tags.Select(t => t.Name))
+ : string.Empty;
+
+ // Multiplayer info
+ HasMultiplayer = game.MultiplayerModes != null && game.MultiplayerModes.Any();
+ MultiplayerModeDetails.Clear();
+ if (HasMultiplayer)
+ {
+ var modes = game.MultiplayerModes!
+ .Select(m => m.Type.ToString())
+ .Distinct();
+ MultiplayerModes = string.Join(", ", modes);
+ foreach (var mode in game.MultiplayerModes!)
+ MultiplayerModeDetails.Add(FormatMultiplayerMode(mode));
+ }
+ else
+ {
+ MultiplayerModes = string.Empty;
+ }
+
+ // Reset media items and tags state while we re-load
+ MediaItems.Clear();
+ TagsExpanded = false;
+ OnPropertyChanged(nameof(HasMedia));
+
+ // Load action bar state
+ await ActionBar.LoadFromSdkGameAsync(game);
+
+ // Load media asynchronously
+ if (game.Media != null && game.Media.Any())
+ {
+ IsLoadingMedia = true;
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var mediaClient = scope.ServiceProvider.GetRequiredService();
+
+ CoverPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Cover, mediaClient);
+ LogoPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Logo, mediaClient);
+ BackgroundPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Background, mediaClient);
+ IconPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Icon, mediaClient);
+
+ // Screenshots and videos
+ foreach (var media in game.Media.Where(m =>
+ m.Type == MediaType.Screenshot || m.Type == MediaType.Video))
+ {
+ var path = await GetOrDownloadSingleMediaAsync(media, mediaClient);
+ if (path != null)
+ MediaItems.Add(new GameMediaItemViewModel
+ {
+ Path = path,
+ IsVideo = media.Type == MediaType.Video,
+ MimeType = media.MimeType ?? string.Empty
+ });
+ }
+ OnPropertyChanged(nameof(HasMedia));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load media for game {GameId}", game.Id);
+ }
+ finally
+ {
+ IsLoadingMedia = false;
+ }
+ }
+ }
+
+ private static string FormatMultiplayerMode(Data.Models.MultiplayerMode mode) =>
+ FormatMultiplayerMode(mode.Type, mode.MinPlayers, mode.MaxPlayers);
+
+ private static string FormatMultiplayerMode(SDK.Models.MultiplayerMode mode) =>
+ FormatMultiplayerMode(mode.Type, mode.MinPlayers, mode.MaxPlayers);
+
+ private static string FormatMultiplayerMode(SDK.Enums.MultiplayerType type, int minPlayers, int maxPlayers)
+ {
+ var typeLabel = type switch
+ {
+ SDK.Enums.MultiplayerType.Local => "Local Multiplayer",
+ SDK.Enums.MultiplayerType.LAN => "LAN Multiplayer",
+ SDK.Enums.MultiplayerType.Online => "Online Multiplayer",
+ _ => type.ToString()
+ };
+
+ if (maxPlayers > 0)
+ {
+ var range = minPlayers > 1 && minPlayers < maxPlayers
+ ? $"{minPlayers}–{maxPlayers} players"
+ : $"Up to {maxPlayers} players";
+ return $"{typeLabel} · {range}";
+ }
+
+ return typeLabel;
+ }
+
+ private async Task GetOrDownloadSingleMediaAsync(SDK.Models.Media media, MediaClient mediaClient)
+ {
+ try
+ {
+ var localPath = mediaClient.GetLocalPath(media);
+ if (File.Exists(localPath)) return localPath;
+ var fileInfo = await mediaClient.DownloadAsync(media, localPath);
+ return fileInfo.Exists ? fileInfo.FullName : null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to download media {MediaId}", media.Id);
+ return null;
+ }
+ }
+
+ private string? GetLocalMediaPath(System.Collections.Generic.ICollection? mediaCollection, MediaType type, MediaService mediaService)
+ {
+ var media = mediaCollection?.FirstOrDefault(m => m.Type == type);
+ if (media == null) return null;
+
+ var path = mediaService.GetImagePath(media);
+ return mediaService.FileExists(media) ? path : null;
+ }
+
+ private async Task GetOrDownloadMediaPathAsync(System.Collections.Generic.IEnumerable mediaCollection, MediaType type, MediaClient mediaClient)
+ {
+ var media = mediaCollection.FirstOrDefault(m => m.Type == type);
+ if (media == null) return null;
+
+ try
+ {
+ var localPath = mediaClient.GetLocalPath(media);
+
+ // Check if file exists locally
+ if (File.Exists(localPath))
+ {
+ return localPath;
+ }
+
+ // Download the media
+ _logger.LogDebug("Downloading media {MediaId} of type {Type}", media.Id, type);
+ var fileInfo = await mediaClient.DownloadAsync(media, localPath);
+
+ if (fileInfo.Exists)
+ {
+ return fileInfo.FullName;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get or download media {MediaId}", media.Id);
+ }
+
+ return null;
+ }
+
+ [RelayCommand]
+ private void GoBack()
+ {
+ ActionBar.StopRunningCheck();
+ BackRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ [RelayCommand]
+ private void SearchFor(string term)
+ {
+ if (!string.IsNullOrWhiteSpace(term))
+ SearchRequested?.Invoke(this, term);
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/GameGroupViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GameGroupViewModel.cs
similarity index 72%
rename from LANCommander.Launcher/ViewModels/GameGroupViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/GameGroupViewModel.cs
index a5a18256..0ee81b39 100644
--- a/LANCommander.Launcher/ViewModels/GameGroupViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/GameGroupViewModel.cs
@@ -1,7 +1,7 @@
using System.Collections.ObjectModel;
-using LANCommander.Launcher.ViewModels.Components;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
-namespace LANCommander.Launcher.ViewModels;
+namespace LANCommander.Launcher.Avalonia.ViewModels;
///
/// A named group of games used when GroupBy is active.
diff --git a/LANCommander.Launcher/ViewModels/GamesCollectionViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GamesCollectionViewModel.cs
similarity index 85%
rename from LANCommander.Launcher/ViewModels/GamesCollectionViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/GamesCollectionViewModel.cs
index 91380bd8..40e3e252 100644
--- a/LANCommander.Launcher/ViewModels/GamesCollectionViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/GamesCollectionViewModel.cs
@@ -6,13 +6,12 @@ using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.ViewModels.Components;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
using LANCommander.Launcher.Settings.Enums;
using LANCommander.SDK.Models;
using LANCommander.SDK.Enums;
-using LANCommander.SDK.Extensions;
-namespace LANCommander.Launcher.ViewModels;
+namespace LANCommander.Launcher.Avalonia.ViewModels;
///
/// Shared base for Depot (GamesListViewModel) and Library (LibraryViewModel).
@@ -51,9 +50,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
[ObservableProperty]
private bool _isOfflineMode;
- [ObservableProperty]
- private bool _hasGames;
-
// ── View appearance ───────────────────────────────────────────────────────
/// Human-readable heading shown in the view header.
@@ -73,7 +69,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsHorizontalView))]
[NotifyPropertyChangedFor(nameof(IsGridViewFlat))]
[NotifyPropertyChangedFor(nameof(IsListViewFlat))]
- [NotifyPropertyChangedFor(nameof(IsLibraryListViewFlat))]
[NotifyPropertyChangedFor(nameof(IsHorizontalViewFlat))]
[NotifyPropertyChangedFor(nameof(AvailableGroupByOptions))]
private GameViewType _selectedViewType = GameViewType.Grid;
@@ -82,8 +77,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
{
if (value == GameViewType.Horizontal && SelectedGroupBy == GroupBy.None)
SelectedGroupBy = GroupBy.FirstLetter;
- else if (value != GameViewType.Horizontal && IsLibraryContext && SelectedGroupBy != GroupBy.None)
- SelectedGroupBy = GroupBy.None;
}
public bool IsGridView => SelectedViewType == GameViewType.Grid;
@@ -92,19 +85,9 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
// Flat variants: only true when NOT grouped
public bool IsGridViewFlat => IsGridView && !IsGrouped;
- public bool IsListViewFlat => IsListView && !IsGrouped && !IsLibraryContext;
- public bool IsLibraryListViewFlat => IsListView && !IsGrouped && IsLibraryContext;
+ public bool IsListViewFlat => IsListView && !IsGrouped;
public bool IsHorizontalViewFlat => IsHorizontalView && !IsGrouped;
- /// Override in LibraryViewModel to enable the library-specific list layout.
- public virtual bool IsLibraryContext => false;
-
- /// Whether a collection filter is active (used by LibraryRowView).
- public virtual bool IsCollectionFiltered => false;
-
- /// Name of the currently filtered collection (used by LibraryRowView).
- public virtual string FilteredCollectionName => string.Empty;
-
// ── Grouping ──────────────────────────────────────────────────────────────
[ObservableProperty]
@@ -112,11 +95,10 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsGroupByFirstLetter))]
[NotifyPropertyChangedFor(nameof(IsGridViewFlat))]
[NotifyPropertyChangedFor(nameof(IsListViewFlat))]
- [NotifyPropertyChangedFor(nameof(IsLibraryListViewFlat))]
[NotifyPropertyChangedFor(nameof(IsHorizontalViewFlat))]
- private GroupBy? _selectedGroupBy = GroupBy.None;
+ private GroupBy _selectedGroupBy = GroupBy.None;
- public bool IsGrouped => SelectedGroupBy != null && SelectedGroupBy != GroupBy.None;
+ public bool IsGrouped => SelectedGroupBy != GroupBy.None;
public bool IsGroupByFirstLetter => SelectedGroupBy == GroupBy.FirstLetter;
public IReadOnlyList AvailableGroupByOptions =>
@@ -138,14 +120,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
[ObservableProperty]
private ObservableCollection _availableGenres = new();
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(IsCollectionFiltered))]
- [NotifyPropertyChangedFor(nameof(FilteredCollectionName))]
- private string? _selectedCollection;
-
- [ObservableProperty]
- private ObservableCollection _availableCollections = new();
-
[ObservableProperty]
private string? _selectedTag;
@@ -205,7 +179,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
{
SearchText = string.Empty;
SelectedGenre = null;
- SelectedCollection = null;
SelectedTag = null;
SelectedDeveloper = null;
SelectedPublisher = null;
@@ -234,8 +207,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
{
var filtered = _allGames.AsEnumerable();
- filtered = filtered.Where(g => g.Type.ValueIsIn(GameType.MainGame, GameType.StandaloneExpansion, GameType.StandaloneMod));
-
if (!string.IsNullOrWhiteSpace(SearchText))
filtered = filtered.Where(g =>
g.Title.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ||
@@ -249,12 +220,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
!string.IsNullOrEmpty(g.Genres) &&
g.Genres.Contains(SelectedGenre.Name, StringComparison.OrdinalIgnoreCase));
- if (!string.IsNullOrEmpty(SelectedCollection))
- filtered = filtered.Where(g =>
- !string.IsNullOrEmpty(g.Collections) &&
- g.Collections.Split(", ", StringSplitOptions.RemoveEmptyEntries)
- .Any(c => c.Equals(SelectedCollection, StringComparison.OrdinalIgnoreCase)));
-
if (!string.IsNullOrEmpty(SelectedTag))
filtered = filtered.Where(g =>
!string.IsNullOrEmpty(g.Tags) &&
@@ -300,14 +265,11 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
var materialised = filtered.ToList();
Games.Clear();
-
foreach (var g in materialised)
Games.Add(g);
RebuildGroups(materialised);
- HasGames = Games.Count > 0;
-
var suffix = IsOfflineMode ? " (offline)" : string.Empty;
StatusMessage = $"{Games.Count} of {_allGames.Count} games{suffix}";
}
@@ -316,7 +278,7 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
{
GroupedGames.Clear();
- if (SelectedGroupBy is null or GroupBy.None)
+ if (SelectedGroupBy == GroupBy.None)
return;
IEnumerable groups = SelectedGroupBy switch
@@ -351,7 +313,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
{
var title = (string.IsNullOrEmpty(g.SortTitle) ? g.Title : g.SortTitle).TrimStart();
var first = title.FirstOrDefault();
-
return char.IsLetter(first) ? char.ToUpper(first).ToString() : "#";
}
@@ -359,7 +320,6 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
{
if (string.IsNullOrWhiteSpace(value))
return [fallback];
-
return value.Split(", ", StringSplitOptions.RemoveEmptyEntries);
}
@@ -370,9 +330,7 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
partial void OnSearchTextChanged(string value)
{
_searchDebounce?.Cancel();
- _searchDebounce?.Dispose();
_searchDebounce = new CancellationTokenSource();
-
var token = _searchDebounce.Token;
_ = Task.Delay(250, token).ContinueWith(
@@ -386,8 +344,7 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
partial void OnSortAscendingChanged(bool value) => ApplyFilters();
partial void OnShowInLibraryOnlyChanged(bool value) => ApplyFilters();
partial void OnSelectedGenreChanged(Genre? value) => ApplyFilters();
- partial void OnSelectedGroupByChanged(GroupBy? value) => ApplyFilters();
- partial void OnSelectedCollectionChanged(string? value) => ApplyFilters();
+ partial void OnSelectedGroupByChanged(GroupBy value) => ApplyFilters();
partial void OnSelectedTagChanged(string? value) => ApplyFilters();
partial void OnSelectedDeveloperChanged(string? value) => ApplyFilters();
partial void OnSelectedPublisherChanged(string? value) => ApplyFilters();
@@ -400,32 +357,14 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
protected void RaiseGameSelected(SDK.Models.Game game) =>
GameSelected?.Invoke(this, game);
- protected void PopulateCollections()
- {
- AvailableCollections.Clear();
-
- var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (var g in _allGames)
- if (!string.IsNullOrEmpty(g.Collections))
- foreach (var c in g.Collections.Split(", ", StringSplitOptions.RemoveEmptyEntries))
- seen.Add(c);
-
- foreach (var c in seen.OrderBy(x => x))
- AvailableCollections.Add(c);
- }
-
protected void PopulateTags()
{
AvailableTags.Clear();
-
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
-
foreach (var g in _allGames)
if (!string.IsNullOrEmpty(g.Tags))
foreach (var t in g.Tags.Split(", ", StringSplitOptions.RemoveEmptyEntries))
seen.Add(t);
-
foreach (var t in seen.OrderBy(x => x))
AvailableTags.Add(t);
}
@@ -433,14 +372,11 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
protected void PopulateDevelopers()
{
AvailableDevelopers.Clear();
-
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
-
foreach (var g in _allGames)
if (!string.IsNullOrEmpty(g.Developers))
foreach (var d in g.Developers.Split(", ", StringSplitOptions.RemoveEmptyEntries))
seen.Add(d);
-
foreach (var d in seen.OrderBy(x => x))
AvailableDevelopers.Add(d);
}
@@ -448,14 +384,11 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
protected void PopulatePublishers()
{
AvailablePublishers.Clear();
-
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
-
foreach (var g in _allGames)
if (!string.IsNullOrEmpty(g.Publishers))
foreach (var p in g.Publishers.Split(", ", StringSplitOptions.RemoveEmptyEntries))
seen.Add(p);
-
foreach (var p in seen.OrderBy(x => x))
AvailablePublishers.Add(p);
}
@@ -463,14 +396,13 @@ public abstract partial class GamesCollectionViewModel : ViewModelBase
protected void PopulateGenres()
{
AvailableGenres.Clear();
-
var genres = new HashSet(new GenreComparer());
-
foreach (var g in _allGames)
+ {
if (!string.IsNullOrEmpty(g.Genres))
foreach (var name in g.Genres.Split(", ", StringSplitOptions.RemoveEmptyEntries))
genres.Add(new Genre { Name = name });
-
+ }
foreach (var genre in genres.OrderBy(g => g.Name))
AvailableGenres.Add(genre);
}
diff --git a/LANCommander.Launcher/ViewModels/GamesListViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs
similarity index 97%
rename from LANCommander.Launcher/ViewModels/GamesListViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs
index b56b02ba..db6f14a9 100644
--- a/LANCommander.Launcher/ViewModels/GamesListViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/GamesListViewModel.cs
@@ -4,7 +4,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.ViewModels.Components;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
using LANCommander.Launcher.Models;
using LANCommander.Launcher.Services;
using LANCommander.SDK.Enums;
@@ -13,7 +13,7 @@ using LANCommander.SDK.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-namespace LANCommander.Launcher.ViewModels;
+namespace LANCommander.Launcher.Avalonia.ViewModels;
public partial class GamesListViewModel : GamesCollectionViewModel
{
@@ -60,7 +60,9 @@ public partial class GamesListViewModel : GamesCollectionViewModel
var mediaClient = scope.ServiceProvider.GetRequiredService();
if (IsOfflineMode)
+ {
await LoadFromLocalLibraryAsync(scope, libraryService);
+ }
else
{
_depotItems = await depotService.GetItemsAsync();
@@ -81,7 +83,7 @@ public partial class GamesListViewModel : GamesCollectionViewModel
var inLibrary = await libraryService.IsInLibraryAsync(depotGame.Id);
var coverPath = await GetOrDownloadCoverAsync(depotGame.Cover, mediaClient);
- items.Add(new GameItemViewModel(depotGame, coverPath, depotGame.Cover?.MimeType, inLibrary));
+ items.Add(new GameItemViewModel(depotGame, coverPath, inLibrary));
if (depotGame.Genres != null)
foreach (var genre in depotGame.Genres)
@@ -131,7 +133,7 @@ public partial class GamesListViewModel : GamesCollectionViewModel
if (coverMedia != null && mediaService.FileExists(coverMedia))
coverPath = mediaService.GetImagePath(coverMedia);
- _allGames.Add(new GameItemViewModel(game, coverPath, coverMedia?.MimeType, inLibrary: true));
+ _allGames.Add(new GameItemViewModel(game, coverPath, inLibrary: true));
}
}
@@ -194,7 +196,6 @@ public partial class GamesListViewModel : GamesCollectionViewModel
if (!IsOfflineMode)
{
var fileInfo = await mediaClient.DownloadAsync(cover, localPath);
-
if (fileInfo.Exists)
return fileInfo.FullName;
}
diff --git a/LANCommander.Launcher.Avalonia/ViewModels/InstallOptionsViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/InstallOptionsViewModel.cs
new file mode 100644
index 00000000..4c6c3bff
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/ViewModels/InstallOptionsViewModel.cs
@@ -0,0 +1,57 @@
+using System.Collections.ObjectModel;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+using LANCommander.SDK.Enums;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class InstallOptionsViewModel : ViewModelBase
+{
+ // ── Install directory ──────────────────────────────────────────────────────
+
+ [ObservableProperty]
+ private ObservableCollection _installDirectories = new();
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasMultipleDirectories))]
+ private string _selectedInstallDirectory = string.Empty;
+
+ public bool HasMultipleDirectories => InstallDirectories.Count > 1;
+
+ // ── Addons ────────────────────────────────────────────────────────────────
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasAddons))]
+ private ObservableCollection _addons = new();
+
+ public bool HasAddons => Addons.Count > 0;
+
+ // ── Result ────────────────────────────────────────────────────────────────
+
+ /// The addons the user chose to install.
+ public SDK.Models.Game[] SelectedAddons =>
+ Addons.Where(a => a.IsSelected).Select(a => a.Game).ToArray();
+}
+
+public partial class InstallAddonItemViewModel : ViewModelBase
+{
+ public SDK.Models.Game Game { get; }
+
+ public string Title => Game.Title ?? "Unknown";
+
+ public string TypeLabel => Game.Type switch
+ {
+ GameType.Expansion => "Expansion",
+ GameType.Mod => "Mod",
+ _ => Game.Type.ToString()
+ };
+
+ [ObservableProperty]
+ private bool _isSelected;
+
+ public InstallAddonItemViewModel(SDK.Models.Game game, bool selectedByDefault = false)
+ {
+ Game = game;
+ IsSelected = selectedByDefault;
+ }
+}
diff --git a/LANCommander.Launcher.Avalonia/ViewModels/LibraryViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/LibraryViewModel.cs
new file mode 100644
index 00000000..95aca0fd
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/ViewModels/LibraryViewModel.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.Launcher.Avalonia.ViewModels.Components;
+using LANCommander.Launcher.Services;
+using LANCommander.SDK.Enums;
+using LANCommander.SDK.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class LibraryViewModel : GamesCollectionViewModel
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ public override string ViewTitle => "My Library";
+ public override bool ShowInLibraryFilter => false;
+ public override bool ShowInstalledFilter => true;
+
+ public LibraryViewModel(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = serviceProvider.GetRequiredService>();
+ }
+
+ public override Task LoadGamesAsync() => LoadLibraryAsync();
+
+ [RelayCommand]
+ private async Task LoadLibraryAsync()
+ {
+ IsLoading = true;
+ HasError = false;
+ StatusMessage = "Loading library...";
+ Games.Clear();
+ _allGames.Clear();
+ AvailableGenres.Clear();
+ AvailableTags.Clear();
+ AvailableDevelopers.Clear();
+ AvailablePublishers.Clear();
+
+ _logger.LogInformation("Loading library (offline: {IsOffline})...", IsOfflineMode);
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var libraryService = scope.ServiceProvider.GetRequiredService();
+ var mediaService = scope.ServiceProvider.GetRequiredService();
+ var gameService = scope.ServiceProvider.GetRequiredService();
+
+ var items = await libraryService.GetItemsAsync();
+
+ foreach (var item in items ?? [])
+ {
+ if (item.DataItem is not LANCommander.Launcher.Data.Models.Game game)
+ continue;
+
+ string? coverPath = null;
+ var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover);
+ if (coverMedia != null && mediaService.FileExists(coverMedia))
+ coverPath = mediaService.GetImagePath(coverMedia);
+
+ _allGames.Add(new GameItemViewModel(game, coverPath, inLibrary: true, showInLibraryBadge: false));
+ }
+
+ PopulateGenres();
+ PopulateTags();
+ PopulateDevelopers();
+ PopulatePublishers();
+ ApplyFilters();
+ _logger.LogInformation("Loaded {Count} library games", _allGames.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load library");
+ StatusMessage = $"Failed to load library: {ex.Message}";
+ HasError = true;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ protected override async Task ViewGameDetailsAsync(GameItemViewModel? gameItem)
+ {
+ if (gameItem == null) return;
+
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+
+ if (IsOfflineMode)
+ {
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var localGame = await gameService.GetAsync(gameItem.Id);
+
+ if (localGame != null)
+ {
+ var sdkGame = new SDK.Models.Game
+ {
+ Id = localGame.Id,
+ Title = localGame.Title ?? "Unknown",
+ SortTitle = localGame.SortTitle,
+ Description = localGame.Description,
+ ReleasedOn = localGame.ReleasedOn ?? DateTime.MinValue
+ };
+ RaiseGameSelected(sdkGame);
+ }
+ }
+ else
+ {
+ var gameClient = scope.ServiceProvider.GetRequiredService();
+ var game = await gameClient.GetAsync(gameItem.Id);
+
+ if (game != null)
+ RaiseGameSelected(game);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to fetch game {GameId}", gameItem.Id);
+ }
+ }
+}
diff --git a/LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs
new file mode 100644
index 00000000..ba34ea05
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/ViewModels/LoginViewModel.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.Launcher.Services;
+using LANCommander.SDK.Providers;
+using LANCommander.SDK.Services;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class LoginViewModel : ViewModelBase
+{
+ private readonly IConnectionClient _connectionClient;
+ private readonly AuthenticationService _authenticationService;
+ private readonly SettingsProvider _settingsProvider;
+
+ [ObservableProperty]
+ private string _username = string.Empty;
+
+ [ObservableProperty]
+ private string _password = string.Empty;
+
+ [ObservableProperty]
+ private string _statusMessage = string.Empty;
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private bool _hasError;
+
+ [ObservableProperty]
+ private string _serverAddress = string.Empty;
+
+ [ObservableProperty]
+ private bool _isServerOffline;
+
+ public event EventHandler? LoginSucceeded;
+ public event EventHandler? ChangeServerRequested;
+
+ public LoginViewModel(
+ IConnectionClient connectionClient,
+ AuthenticationService authenticationService,
+ SettingsProvider settingsProvider)
+ {
+ _connectionClient = connectionClient;
+ _authenticationService = authenticationService;
+ _settingsProvider = settingsProvider;
+
+ ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? "Not connected";
+ }
+
+ [RelayCommand]
+ private async Task LoginAsync()
+ {
+ if (IsServerOffline)
+ {
+ StatusMessage = "Server is offline. Please try again later or change server.";
+ HasError = true;
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
+ {
+ StatusMessage = "Please enter username and password";
+ HasError = true;
+ return;
+ }
+
+ IsLoading = true;
+ HasError = false;
+ StatusMessage = "Logging in...";
+
+ try
+ {
+ var serverAddress = _connectionClient.GetServerAddress();
+ if (serverAddress == null)
+ {
+ StatusMessage = "No server configured";
+ HasError = true;
+ return;
+ }
+
+ await _authenticationService.Login(serverAddress, Username, Password);
+
+ StatusMessage = "Login successful!";
+ LoginSucceeded?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Login failed: {ex.Message}";
+ HasError = true;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private void ChangeServer()
+ {
+ ChangeServerRequested?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/MainWindowViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs
similarity index 77%
rename from LANCommander.Launcher/ViewModels/MainWindowViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs
index ae7feae6..bc735a62 100644
--- a/LANCommander.Launcher/ViewModels/MainWindowViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/MainWindowViewModel.cs
@@ -1,227 +1,172 @@
-using System;
-using System.Threading.Tasks;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.Services;
-using LANCommander.SDK.Providers;
-using LANCommander.SDK.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace LANCommander.Launcher.ViewModels;
-
-public partial class MainWindowViewModel : ViewModelBase
-{
- private readonly IServiceProvider _serviceProvider;
- private readonly IConnectionClient _connectionClient;
- private readonly AuthenticationService _authenticationService;
- private readonly SettingsProvider _settingsProvider;
- private readonly ILogger _logger;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(IsLogoVisible))]
- private ViewModelBase _currentView;
-
- [ObservableProperty]
- private string _title = "LANCommander Launcher";
-
- [ObservableProperty]
- private bool _isShellActive;
-
- [ObservableProperty]
- [NotifyPropertyChangedFor(nameof(IsNotBigScreenMode))]
- private bool _isBigScreenMode;
-
- public bool IsNotBigScreenMode => !IsBigScreenMode;
-
- public bool IsLogoVisible => CurrentView != ServerSelectionViewModel && CurrentView != LoginViewModel;
- public bool ShowTitlebarTint => !IsShellActive || ShellViewModel.IsTitlebarTinted;
-
- public event EventHandler? BigScreenModeChanged;
- public event EventHandler? ExitLauncherRequested;
-
- [RelayCommand]
- private void EnterBigScreenMode()
- {
- IsBigScreenMode = true;
- _settingsProvider.Update(s => s.Window.BigScreenMode = true);
- BigScreenModeChanged?.Invoke(this, EventArgs.Empty);
- }
-
- [RelayCommand]
- private void ExitBigScreenMode()
- {
- IsBigScreenMode = false;
- _settingsProvider.Update(s => s.Window.BigScreenMode = false);
- BigScreenModeChanged?.Invoke(this, EventArgs.Empty);
- }
-
- [RelayCommand]
- private void ExitLauncher()
- {
- ExitLauncherRequested?.Invoke(this, EventArgs.Empty);
- }
-
- partial void OnCurrentViewChanged(ViewModelBase value)
- {
- IsShellActive = value is ShellViewModel;
- OnPropertyChanged(nameof(ShowTitlebarTint));
- }
-
- public SplashViewModel SplashViewModel { get; }
- public ServerSelectionViewModel ServerSelectionViewModel { get; }
- public LoginViewModel LoginViewModel { get; }
- public ShellViewModel ShellViewModel { get; }
-
- public MainWindowViewModel(
- IServiceProvider serviceProvider,
- IConnectionClient connectionClient,
- AuthenticationService authenticationService,
- SettingsProvider settingsProvider)
- {
- _serviceProvider = serviceProvider;
- _connectionClient = connectionClient;
- _authenticationService = authenticationService;
- _settingsProvider = settingsProvider;
- _logger = serviceProvider.GetRequiredService>();
-
- SplashViewModel = new SplashViewModel();
- ServerSelectionViewModel = new ServerSelectionViewModel(connectionClient, settingsProvider, serviceProvider.GetRequiredService());
- LoginViewModel = new LoginViewModel(connectionClient, authenticationService, serviceProvider.GetRequiredService(), settingsProvider);
- ShellViewModel = new ShellViewModel(serviceProvider);
-
- // Propagate shell titlebar tint changes
- ShellViewModel.PropertyChanged += (_, e) =>
- {
- if (e.PropertyName == nameof(ShellViewModel.IsTitlebarTinted))
- OnPropertyChanged(nameof(ShowTitlebarTint));
- };
-
- // Wire up navigation events
- ServerSelectionViewModel.ServerConnected += OnServerConnected;
- LoginViewModel.LoginSucceeded += OnLoginSucceeded;
- LoginViewModel.ChangeServerRequested += OnChangeServerRequested;
- ShellViewModel.LogoutRequested += OnLogoutRequested;
-
- // Start with splash screen
- _currentView = SplashViewModel;
-
- // Restore big screen mode from settings or command line
- if (settingsProvider.CurrentValue.Window.BigScreenMode)
- _isBigScreenMode = true;
- }
-
- ///
- /// Enables big screen mode from an external source (e.g. command line).
- /// Must be called before InitializeAsync so the event fires after the window is ready.
- ///
- public void SetBigScreenMode()
- {
- _isBigScreenMode = true;
- }
-
- public async Task InitializeAsync()
- {
- SplashViewModel.UpdateStatus("Checking connection...");
-
- // Check if we have a saved server address and valid token
- var settings = _settingsProvider.CurrentValue;
-
- if (settings.Authentication?.ServerAddress != null)
- {
- SplashViewModel.UpdateStatus("Connecting to server...");
- await _connectionClient.UpdateServerAddressAsync(settings.Authentication.ServerAddress.ToString());
-
- // Check if server is reachable
- var serverOnline = await _connectionClient.PingAsync();
-
- if (_authenticationService.HasStoredCredentials())
- {
- if (serverOnline)
- {
- try
- {
- SplashViewModel.UpdateStatus("Authenticating...");
- // Try to login with stored credentials
- await _authenticationService.Login();
-
- if (_connectionClient.IsConnected())
- {
- SplashViewModel.UpdateStatus("Loading library...");
- // Token is valid - go directly to shell in online mode
- ShellViewModel.SetOfflineMode(false);
- await ShellViewModel.InitializeAsync();
- CurrentView = ShellViewModel;
- return;
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Token validation failed");
- // Token validation failed - continue to check offline mode
- }
- }
- else
- {
- // Server offline but we have stored credentials - go to shell in offline mode
- _logger.LogInformation("Server unreachable, starting in offline mode with stored credentials");
- SplashViewModel.UpdateStatus("Server offline, starting in offline mode...");
- await _connectionClient.EnableOfflineModeAsync();
- ShellViewModel.SetOfflineMode(true);
- await ShellViewModel.InitializeAsync();
- CurrentView = ShellViewModel;
- return;
- }
- }
-
- // We have a server but no valid token - go to login
- // If server is offline and no credentials, user stays on login (can't proceed)
- LoginViewModel.ServerAddress = settings.Authentication.ServerAddress.ToString();
- LoginViewModel.IsServerOffline = !serverOnline;
-
- if (serverOnline)
- await LoginViewModel.LoadAuthenticationProvidersAsync();
-
- CurrentView = LoginViewModel;
-
- if (serverOnline)
- await LoginViewModel.TryAutoRedirectToProviderAsync();
-
- return;
- }
-
- // No saved server - show server selection
- CurrentView = ServerSelectionViewModel;
- }
-
- private async void OnServerConnected(object? sender, EventArgs e)
- {
- LoginViewModel.ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? string.Empty;
- LoginViewModel.IsServerOffline = false;
- await LoginViewModel.LoadAuthenticationProvidersAsync();
- CurrentView = LoginViewModel;
- await LoginViewModel.TryAutoRedirectToProviderAsync();
- }
-
- private async void OnLoginSucceeded(object? sender, EventArgs e)
- {
- // Show the splash screen with a loading message while initializing
- SplashViewModel.UpdateStatus("Loading library...");
- CurrentView = SplashViewModel;
-
- // Initialize shell fully before switching view to avoid rendering uninitialized state
- ShellViewModel.SetOfflineMode(false);
- await ShellViewModel.InitializeAsync();
- CurrentView = ShellViewModel;
- }
-
- private void OnChangeServerRequested(object? sender, EventArgs e)
- {
- CurrentView = ServerSelectionViewModel;
- }
-
- private void OnLogoutRequested(object? sender, EventArgs e)
- {
- CurrentView = LoginViewModel;
- }
-}
+using System;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using LANCommander.Launcher.Services;
+using LANCommander.SDK.Providers;
+using LANCommander.SDK.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class MainWindowViewModel : ViewModelBase
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IConnectionClient _connectionClient;
+ private readonly AuthenticationService _authenticationService;
+ private readonly SettingsProvider _settingsProvider;
+ private readonly ILogger _logger;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsLogoVisible))]
+ private ViewModelBase _currentView;
+
+ [ObservableProperty]
+ private string _title = "LANCommander Launcher";
+
+ [ObservableProperty]
+ private bool _isShellActive;
+
+ public bool IsLogoVisible => CurrentView != ServerSelectionViewModel && CurrentView != LoginViewModel;
+ public bool ShowTitlebarTint => !IsShellActive || ShellViewModel.IsTitlebarTinted;
+
+ partial void OnCurrentViewChanged(ViewModelBase value)
+ {
+ IsShellActive = value is ShellViewModel;
+ OnPropertyChanged(nameof(ShowTitlebarTint));
+ }
+
+ public SplashViewModel SplashViewModel { get; }
+ public ServerSelectionViewModel ServerSelectionViewModel { get; }
+ public LoginViewModel LoginViewModel { get; }
+ public ShellViewModel ShellViewModel { get; }
+
+ public MainWindowViewModel(
+ IServiceProvider serviceProvider,
+ IConnectionClient connectionClient,
+ AuthenticationService authenticationService,
+ SettingsProvider settingsProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _connectionClient = connectionClient;
+ _authenticationService = authenticationService;
+ _settingsProvider = settingsProvider;
+ _logger = serviceProvider.GetRequiredService>();
+
+ SplashViewModel = new SplashViewModel();
+ ServerSelectionViewModel = new ServerSelectionViewModel(connectionClient, settingsProvider);
+ LoginViewModel = new LoginViewModel(connectionClient, authenticationService, settingsProvider);
+ ShellViewModel = new ShellViewModel(serviceProvider);
+
+ // Propagate shell titlebar tint changes
+ ShellViewModel.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(ShellViewModel.IsTitlebarTinted))
+ OnPropertyChanged(nameof(ShowTitlebarTint));
+ };
+
+ // Wire up navigation events
+ ServerSelectionViewModel.ServerConnected += OnServerConnected;
+ LoginViewModel.LoginSucceeded += OnLoginSucceeded;
+ LoginViewModel.ChangeServerRequested += OnChangeServerRequested;
+ ShellViewModel.LogoutRequested += OnLogoutRequested;
+
+ // Start with splash screen
+ _currentView = SplashViewModel;
+ }
+
+ public async Task InitializeAsync()
+ {
+ SplashViewModel.UpdateStatus("Checking connection...");
+
+ // Check if we have a saved server address and valid token
+ var settings = _settingsProvider.CurrentValue;
+
+ if (settings.Authentication?.ServerAddress != null)
+ {
+ SplashViewModel.UpdateStatus("Connecting to server...");
+ await _connectionClient.UpdateServerAddressAsync(settings.Authentication.ServerAddress.ToString());
+
+ // Check if server is reachable
+ var serverOnline = await _connectionClient.PingAsync();
+
+ if (_authenticationService.HasStoredCredentials())
+ {
+ if (serverOnline)
+ {
+ try
+ {
+ SplashViewModel.UpdateStatus("Authenticating...");
+ // Try to login with stored credentials
+ await _authenticationService.Login();
+
+ if (_connectionClient.IsConnected())
+ {
+ SplashViewModel.UpdateStatus("Loading library...");
+ // Token is valid - go directly to shell in online mode
+ ShellViewModel.SetOfflineMode(false);
+ await ShellViewModel.InitializeAsync();
+ CurrentView = ShellViewModel;
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Token validation failed");
+ // Token validation failed - continue to check offline mode
+ }
+ }
+ else
+ {
+ // Server offline but we have stored credentials - go to shell in offline mode
+ _logger.LogInformation("Server unreachable, starting in offline mode with stored credentials");
+ SplashViewModel.UpdateStatus("Server offline, starting in offline mode...");
+ await _connectionClient.EnableOfflineModeAsync();
+ ShellViewModel.SetOfflineMode(true);
+ await ShellViewModel.InitializeAsync();
+ CurrentView = ShellViewModel;
+ return;
+ }
+ }
+
+ // We have a server but no valid token - go to login
+ // If server is offline and no credentials, user stays on login (can't proceed)
+ LoginViewModel.ServerAddress = settings.Authentication.ServerAddress.ToString();
+ LoginViewModel.IsServerOffline = !serverOnline;
+ CurrentView = LoginViewModel;
+ return;
+ }
+
+ // No saved server - show server selection
+ CurrentView = ServerSelectionViewModel;
+ }
+
+ private void OnServerConnected(object? sender, EventArgs e)
+ {
+ LoginViewModel.ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? string.Empty;
+ LoginViewModel.IsServerOffline = false;
+ CurrentView = LoginViewModel;
+ }
+
+ private async void OnLoginSucceeded(object? sender, EventArgs e)
+ {
+ // Show the splash screen with a loading message while initializing
+ SplashViewModel.UpdateStatus("Loading library...");
+ CurrentView = SplashViewModel;
+
+ // Initialize shell fully before switching view to avoid rendering uninitialized state
+ ShellViewModel.SetOfflineMode(false);
+ await ShellViewModel.InitializeAsync();
+ CurrentView = ShellViewModel;
+ }
+
+ private void OnChangeServerRequested(object? sender, EventArgs e)
+ {
+ CurrentView = ServerSelectionViewModel;
+ }
+
+ private void OnLogoutRequested(object? sender, EventArgs e)
+ {
+ CurrentView = LoginViewModel;
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/ManualViewerViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/ManualViewerViewModel.cs
similarity index 87%
rename from LANCommander.Launcher/ViewModels/ManualViewerViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/ManualViewerViewModel.cs
index e6407451..4f7c5b38 100644
--- a/LANCommander.Launcher/ViewModels/ManualViewerViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/ManualViewerViewModel.cs
@@ -1,28 +1,28 @@
-using System;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-
-namespace LANCommander.Launcher.ViewModels;
-
-public partial class ManualViewerViewModel : ViewModelBase
-{
- [ObservableProperty]
- private string _title = "Manual";
-
- [ObservableProperty]
- private string _filePath = string.Empty;
-
- public Action? CloseAction { get; set; }
-
- public ManualViewerViewModel(string title, string filePath)
- {
- Title = title;
- FilePath = filePath;
- }
-
- [RelayCommand]
- private void Close()
- {
- CloseAction?.Invoke();
- }
-}
+using System;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class ManualViewerViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _title = "Manual";
+
+ [ObservableProperty]
+ private string _filePath = string.Empty;
+
+ public Action? CloseAction { get; set; }
+
+ public ManualViewerViewModel(string title, string filePath)
+ {
+ Title = title;
+ FilePath = filePath;
+ }
+
+ [RelayCommand]
+ private void Close()
+ {
+ CloseAction?.Invoke();
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/PowerShellConsoleViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/PowerShellConsoleViewModel.cs
similarity index 90%
rename from LANCommander.Launcher/ViewModels/PowerShellConsoleViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/PowerShellConsoleViewModel.cs
index 699d9ad6..6c3fb8f0 100644
--- a/LANCommander.Launcher/ViewModels/PowerShellConsoleViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/PowerShellConsoleViewModel.cs
@@ -1,49 +1,49 @@
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using System;
-
-namespace LANCommander.Launcher.ViewModels;
-
-public partial class PowerShellConsoleViewModel : ViewModelBase
-{
- [ObservableProperty]
- private string _title = "PowerShell Console";
-
- [ObservableProperty]
- private string? _statusMessage;
-
- [ObservableProperty]
- private bool _canClose = true;
-
- [ObservableProperty]
- private string _workingDirectory = string.Empty;
-
- public Action? CloseAction { get; set; }
-
- public PowerShellConsoleViewModel()
- {
- }
-
- public PowerShellConsoleViewModel(string title, string workingDirectory)
- {
- Title = title;
- WorkingDirectory = workingDirectory;
- }
-
- [RelayCommand]
- private void Close()
- {
- CloseAction?.Invoke();
- }
-
- public void OnScriptCompleted()
- {
- StatusMessage = "Script execution complete. Terminal is interactive.";
- }
-
- public void OnSessionEnded()
- {
- StatusMessage = "Session ended.";
- CanClose = true;
- }
-}
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using System;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class PowerShellConsoleViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string _title = "PowerShell Console";
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ [ObservableProperty]
+ private bool _canClose = true;
+
+ [ObservableProperty]
+ private string _workingDirectory = string.Empty;
+
+ public Action? CloseAction { get; set; }
+
+ public PowerShellConsoleViewModel()
+ {
+ }
+
+ public PowerShellConsoleViewModel(string title, string workingDirectory)
+ {
+ Title = title;
+ WorkingDirectory = workingDirectory;
+ }
+
+ [RelayCommand]
+ private void Close()
+ {
+ CloseAction?.Invoke();
+ }
+
+ public void OnScriptCompleted()
+ {
+ StatusMessage = "Script execution complete. Terminal is interactive.";
+ }
+
+ public void OnSessionEnded()
+ {
+ StatusMessage = "Session ended.";
+ CanClose = true;
+ }
+}
diff --git a/LANCommander.Launcher.Avalonia/ViewModels/ServerSelectionViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/ServerSelectionViewModel.cs
new file mode 100644
index 00000000..4ba553bc
--- /dev/null
+++ b/LANCommander.Launcher.Avalonia/ViewModels/ServerSelectionViewModel.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.SDK.Providers;
+using LANCommander.SDK.Services;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class ServerSelectionViewModel : ViewModelBase
+{
+ private readonly IConnectionClient _connectionClient;
+ private readonly SettingsProvider _settingsProvider;
+
+ [ObservableProperty]
+ private string _serverAddress = string.Empty;
+
+ [ObservableProperty]
+ private string _statusMessage = string.Empty;
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private bool _hasError;
+
+ public event EventHandler? ServerConnected;
+
+ public ServerSelectionViewModel(
+ IConnectionClient connectionClient,
+ SettingsProvider settingsProvider)
+ {
+ _connectionClient = connectionClient;
+ _settingsProvider = settingsProvider;
+
+ // Load saved server address if available
+ if (_settingsProvider.CurrentValue.Authentication?.ServerAddress != null)
+ ServerAddress = _settingsProvider.CurrentValue.Authentication.ServerAddress.ToString();
+ }
+
+ [RelayCommand]
+ private async Task ConnectAsync()
+ {
+ if (string.IsNullOrWhiteSpace(ServerAddress))
+ {
+ StatusMessage = "Please enter a server address";
+ HasError = true;
+ return;
+ }
+
+ IsLoading = true;
+ HasError = false;
+ StatusMessage = "Testing connection...";
+
+ try
+ {
+ // UpdateServerAddressAsync discovers and validates the server internally
+ // (pings candidate URIs). If it returns without throwing, the server is reachable.
+ await _connectionClient.UpdateServerAddressAsync(ServerAddress);
+
+ ServerAddress = _connectionClient.GetServerAddress().ToString();
+
+ StatusMessage = "Connected!";
+ ServerConnected?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Connection failed: {ex.Message}";
+ HasError = true;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+}
diff --git a/LANCommander.Launcher/ViewModels/SettingsViewModel.cs b/LANCommander.Launcher.Avalonia/ViewModels/SettingsViewModel.cs
similarity index 74%
rename from LANCommander.Launcher/ViewModels/SettingsViewModel.cs
rename to LANCommander.Launcher.Avalonia/ViewModels/SettingsViewModel.cs
index d4b54d6e..bf8b77bd 100644
--- a/LANCommander.Launcher/ViewModels/SettingsViewModel.cs
+++ b/LANCommander.Launcher.Avalonia/ViewModels/SettingsViewModel.cs
@@ -1,261 +1,221 @@
-using System;
-using System.Collections.ObjectModel;
-using System.Globalization;
-using System.Linq;
-using System.Threading.Tasks;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using LANCommander.Launcher.Services;
-using LANCommander.Launcher.Settings;
-using LANCommander.SDK.Models;
-using LANCommander.SDK.Providers;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace LANCommander.Launcher.ViewModels;
-
-public partial class SettingsViewModel : ViewModelBase
-{
- private readonly IServiceProvider _serviceProvider;
- private readonly ILogger _logger;
- private readonly INavigationService _navigationService;
-
- // Game Settings
- [ObservableProperty]
- private ObservableCollection _installDirectories = new();
-
- [ObservableProperty]
- private int _maxInstallAttempts = 10;
-
- // Media Settings
- [ObservableProperty]
- private string _mediaStoragePath = string.Empty;
-
- // UI Settings
- [ObservableProperty]
- private CultureItem? _selectedCultureItem;
-
- [ObservableProperty]
- private ObservableCollection _availableCultures = new();
-
- // Notification Settings
- [ObservableProperty]
- private bool _notifyOnInstallComplete = true;
-
- [ObservableProperty]
- private bool _notifyOnInstallFailed = true;
-
- [ObservableProperty]
- private bool _notifyOnChatMessage = true;
-
- [ObservableProperty]
- private NotificationSoundTheme _selectedSoundTheme = NotificationSoundTheme.SystemDefault;
-
- [ObservableProperty]
- private ObservableCollection _availableSoundThemes = new();
-
- // Debug Settings
- [ObservableProperty]
- private bool _enableScriptDebugging;
-
- [ObservableProperty]
- private string _loggingPath = string.Empty;
-
- [ObservableProperty]
- private LogLevel _selectedLogLevel = LogLevel.Warning;
-
- [ObservableProperty]
- private ObservableCollection _availableLogLevels = new();
-
- [ObservableProperty]
- private string? _statusMessage;
-
- [ObservableProperty]
- private bool _isSaving;
-
- public event EventHandler? SettingsSaved;
-
- public SettingsViewModel(IServiceProvider serviceProvider)
- {
- _serviceProvider = serviceProvider;
- _logger = serviceProvider.GetRequiredService>();
- _navigationService = serviceProvider.GetRequiredService();
-
- // Initialize available cultures
- var cultures = new[]
- {
- "en-US", "de", "es", "fr", "it", "pt-BR", "nl", "ja", "zh", "ko", "uk"
- };
-
- foreach (var code in cultures)
- {
- try
- {
- var culture = CultureInfo.GetCultureInfo(code);
- AvailableCultures.Add(new CultureItem(code, culture.NativeName));
- }
- catch
- {
- AvailableCultures.Add(new CultureItem(code, code));
- }
- }
-
- // Initialize sound themes
- foreach (var theme in Enum.GetValues())
- AvailableSoundThemes.Add(theme);
-
- // Initialize log levels
- foreach (var level in Enum.GetValues())
- AvailableLogLevels.Add(level);
- }
-
- public void Load()
- {
- _logger.LogInformation("Loading settings...");
-
- using var scope = _serviceProvider.CreateScope();
- var settingsProvider = scope.ServiceProvider.GetRequiredService>();
- var settings = settingsProvider.CurrentValue;
-
- // Game settings
- InstallDirectories.Clear();
-
- if (settings.Games.InstallDirectories?.Length > 0)
- foreach (var dir in settings.Games.InstallDirectories)
- InstallDirectories.Add(new InstallDirectoryItem(dir));
- else
- InstallDirectories.Add(new InstallDirectoryItem(string.Empty));
-
- MaxInstallAttempts = settings.Games.MaxInstallAttempts;
-
- // Media settings
- MediaStoragePath = settings.Media.StoragePath ?? string.Empty;
-
- // UI settings
- var cultureCode = settings.Culture ?? "en-US";
-
- SelectedCultureItem = AvailableCultures.FirstOrDefault(c => c.Code == cultureCode)
- ?? AvailableCultures.First();
-
- // Notification settings
- NotifyOnInstallComplete = settings.Notifications.NotifyOnInstallComplete;
- NotifyOnInstallFailed = settings.Notifications.NotifyOnInstallFailed;
- NotifyOnChatMessage = settings.Notifications.NotifyOnChatMessage;
- SelectedSoundTheme = settings.Notifications.SoundTheme;
-
- // Debug settings
- EnableScriptDebugging = settings.Debug.EnableScriptDebugging;
- LoggingPath = settings.Debug.LoggingPath ?? "Logs";
- SelectedLogLevel = settings.Debug.LogLevel;
-
- StatusMessage = null;
-
- _logger.LogInformation("Settings loaded");
- }
-
- [RelayCommand]
- private async Task SaveAsync()
- {
- if (IsSaving)
- return;
-
- IsSaving = true;
- StatusMessage = "Saving...";
-
- try
- {
- using var scope = _serviceProvider.CreateScope();
- var settingsProvider = scope.ServiceProvider.GetRequiredService>();
-
- settingsProvider.Update(s =>
- {
- // Game settings
- s.Games.InstallDirectories = InstallDirectories
- .Where(d => !string.IsNullOrWhiteSpace(d.Path))
- .Select(d => d.Path)
- .ToArray();
-
- s.Games.MaxInstallAttempts = Math.Max(1, MaxInstallAttempts);
-
- // Media settings
- s.Media.StoragePath = MediaStoragePath;
-
- // UI settings
- s.Culture = SelectedCultureItem?.Code ?? "en-US";
-
- // Notification settings
- s.Notifications.NotifyOnInstallComplete = NotifyOnInstallComplete;
- s.Notifications.NotifyOnInstallFailed = NotifyOnInstallFailed;
- s.Notifications.NotifyOnChatMessage = NotifyOnChatMessage;
- s.Notifications.SoundTheme = SelectedSoundTheme;
-
- // Debug settings
- s.Debug.EnableScriptDebugging = EnableScriptDebugging;
- s.Debug.LoggingPath = LoggingPath;
- s.Debug.LogLevel = SelectedLogLevel;
- });
-
- StatusMessage = "Settings saved!";
- _logger.LogInformation("Settings saved successfully");
-
- SettingsSaved?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to save settings");
- StatusMessage = $"Failed to save: {ex.Message}";
- }
- finally
- {
- IsSaving = false;
- }
- }
-
- [RelayCommand]
- private void AddInstallDirectory()
- {
- InstallDirectories.Add(new InstallDirectoryItem(string.Empty));
- }
-
- [RelayCommand]
- private void RemoveInstallDirectory(InstallDirectoryItem? item)
- {
- if (item == null || InstallDirectories.Count <= 1)
- return;
-
- InstallDirectories.Remove(item);
- }
-
- [RelayCommand]
- private void GoBack()
- {
- _navigationService.GoBack();
- }
-}
-
-public partial class InstallDirectoryItem : ObservableObject
-{
- [ObservableProperty]
- private string _path;
-
- public Guid Id { get; } = Guid.NewGuid();
-
- public InstallDirectoryItem(string path)
- {
- _path = path;
- }
-}
-
-public class CultureItem
-{
- public string Code { get; }
- public string DisplayName { get; }
-
- public CultureItem(string code, string displayName)
- {
- Code = code;
- DisplayName = displayName;
- }
-
- public override string ToString() => DisplayName;
-}
+using System;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LANCommander.SDK.Models;
+using LANCommander.SDK.Providers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LANCommander.Launcher.Avalonia.ViewModels;
+
+public partial class SettingsViewModel : ViewModelBase
+{
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ // Game Settings
+ [ObservableProperty]
+ private ObservableCollection _installDirectories = new();
+
+ // Media Settings
+ [ObservableProperty]
+ private string _mediaStoragePath = string.Empty;
+
+ // UI Settings
+ [ObservableProperty]
+ private CultureItem? _selectedCultureItem;
+
+ [ObservableProperty]
+ private ObservableCollection _availableCultures = new();
+
+ // Debug Settings
+ [ObservableProperty]
+ private bool _enableScriptDebugging;
+
+ [ObservableProperty]
+ private string _loggingPath = string.Empty;
+
+ [ObservableProperty]
+ private LogLevel _selectedLogLevel = LogLevel.Warning;
+
+ [ObservableProperty]
+ private ObservableCollection _availableLogLevels = new();
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ [ObservableProperty]
+ private bool _isSaving;
+
+ public event EventHandler? BackRequested;
+ public event EventHandler? SettingsSaved;
+
+ public SettingsViewModel(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ _logger = serviceProvider.GetRequiredService>();
+
+ // Initialize available cultures
+ var cultures = new[]
+ {
+ "en-US", "de", "es", "fr", "it", "pt-BR", "nl", "ja", "zh", "ko", "uk"
+ };
+
+ foreach (var code in cultures)
+ {
+ try
+ {
+ var culture = CultureInfo.GetCultureInfo(code);
+ AvailableCultures.Add(new CultureItem(code, culture.NativeName));
+ }
+ catch
+ {
+ AvailableCultures.Add(new CultureItem(code, code));
+ }
+ }
+
+ // Initialize log levels
+ foreach (var level in Enum.GetValues())
+ {
+ AvailableLogLevels.Add(level);
+ }
+ }
+
+ public void Load()
+ {
+ _logger.LogInformation("Loading settings...");
+
+ using var scope = _serviceProvider.CreateScope();
+ var settingsProvider = scope.ServiceProvider.GetRequiredService